From 9014bebb88e0a38f05ee5d762edb0b1977b05f10 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 08:11:37 +0000 Subject: [PATCH 1/5] =?UTF-8?q?=E8=A6=81=E4=BB=B6=E5=AE=9A=E7=BE=A9:=20?= =?UTF-8?q?=E6=97=A5=E6=9C=AC=E3=81=AE=E5=AD=A6=E6=A0=A1=E3=81=AE=E5=AE=9F?= =?UTF-8?q?=E6=85=8B=E8=AA=BF=E6=9F=BB=E3=81=AB=E5=9F=BA=E3=81=A5=E3=81=8F?= =?UTF-8?q?=E8=A6=8F=E6=A8=A1=E3=83=BB=E5=88=B6=E7=B4=84=E8=A6=81=E4=BB=B6?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 小学校から高校までの実態を一次資料(文部科学省)で調査し、Webアプリの 確定要件として docs/requirements.md にまとめた。 - 標準授業時数から週コマ数を導出(小4以上・中29コマ、高30コマ)。 週30枠に対しほぼ完全充填となるため、全制約のソフト制約化が必須要件。 - 学級編制・学校規模からサポート規模を1〜36学級に確定。 - 校種ごとの編成実態(小: 学級担任制+専科、中: 教科担任制と週持ちコマ数、 高: 選択科目の同時展開)を制約要件に落とし込み、割当の単位を 「学級」ではなく「講座」とするデータモデルを規定。 - benchmarks/solver_benchmark.py で実態規模の求解時間を実測。小・中は 1〜2秒で安定する一方、高校は0.3〜25秒とばらつき規模と相関しないため、 求解の非同期ジョブ化を要件に追加(前回の不要判断を撤回)。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DHGVNyssktZ69PWwmY1LCA --- benchmarks/solver_benchmark.py | 114 +++++++++++++++ docs/requirements.md | 256 +++++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 benchmarks/solver_benchmark.py create mode 100644 docs/requirements.md diff --git a/benchmarks/solver_benchmark.py b/benchmarks/solver_benchmark.py new file mode 100644 index 0000000..d17de86 --- /dev/null +++ b/benchmarks/solver_benchmark.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +"""日本の小・中・高の実態に即した時間割インスタンスで整数計画の求解時間を実測する。 + +docs/requirements.md の性能要件(2.1節)の根拠。週コマ数・学級数・教員あたりの +担当クラス数は、文部科学省の標準授業時数・学校基本調査・教員勤務実態調査に基づく。 + + pip install pulp && python3 benchmarks/solver_benchmark.py +""" +import pulp, time + +def solve(name, classes, slots_per_day, days, demand, teacher_of, parallel_groups=None, timeout=120): + """demand: {(clazz, subject): 週コマ数}, teacher_of: {(clazz, subject): teacher}""" + slots = [(d, l) for d in range(days) for l in range(slots_per_day)] + p = pulp.LpProblem(name, pulp.LpMinimize) + x = {(c, s, d, l): pulp.LpVariable(f"x_{c}_{s}_{d}_{l}", 0, 1, pulp.LpBinary) + for (c, s) in demand for (d, l) in slots} + slack = [] + # 各クラス・各コマは高々1科目 + for c in classes: + subs = [s for (cc, s) in demand if cc == c] + for (d, l) in slots: + p += pulp.lpSum(x[(c, s, d, l)] for s in subs) <= 1 + # 週コマ数(ソフト制約: 不足分にペナルティ) + for (c, s), n in demand.items(): + u = pulp.LpVariable(f"u_{c}_{s}", 0, n); slack.append(u) + p += pulp.lpSum(x[(c, s, d, l)] for (d, l) in slots) + u >= n + # 1日に同じ科目は1回まで(週4コマ以上の科目は2回まで許容) + for (c, s), n in demand.items(): + cap = 2 if n > 5 else 1 + for d in range(days): + p += pulp.lpSum(x[(c, s, d, l)] for l in range(slots_per_day)) <= cap + # 教員の同時刻重複禁止 + byteacher = {} + for k, t in teacher_of.items(): + byteacher.setdefault(t, []).append(k) + for t, ks in byteacher.items(): + for (d, l) in slots: + p += pulp.lpSum(x[(c, s, d, l)] for (c, s) in ks) <= 1 + # 選択科目の同時展開(高校): 群内の科目は学年内で同じコマに揃える + for gi, (grade_classes, subs) in enumerate(parallel_groups or []): + for (d, l) in slots: + z = pulp.LpVariable(f"z_{gi}_{d}_{l}", 0, 1, pulp.LpBinary) + for c in grade_classes: + for s in subs: + if (c, s) in demand: + p += x[(c, s, d, l)] <= z + p += pulp.lpSum(slack) + t0 = time.time() + st = p.solve(pulp.PULP_CBC_CMD(msg=0, timeLimit=timeout)) + el = time.time() - t0 + unmet = sum(v.value() or 0 for v in slack) + print(f"{name:28s} 学級{len(classes):3d} 変数{len(p.variables()):7d} 制約{len(p.constraints):6d} " + f"{pulp.LpStatus[st]:10s} {el:6.2f}s 未充足{unmet:5.0f}コマ") + return el + +# ---- 小学校: 18学級(適正規模上限), 週29コマ, 学級担任制+専科(外国語/理科/算数/体育) ---- +ES = {'国語':5,'社会':3,'算数':5,'理科':3,'音楽':1,'図画工作':1,'家庭':2,'体育':3, + '道徳':1,'特別活動':1,'総合':2,'外国語':2} # 5・6年相当 計29 +def elementary(n_classes): + classes=[f'{i//3+1}-{i%3+1}' for i in range(n_classes)] + demand={}; tea={} + senka={'外国語':4,'理科':4,'算数':6,'体育':6} # 専科教員1人あたり担当クラス数 + for i,c in enumerate(classes): + for s,n in ES.items(): + demand[(c,s)]=n + tea[(c,s)] = f'専科{s}_{i//senka[s]}' if s in senka else f'担任{c}' + return classes, demand, tea + +# ---- 中学校: 週29コマ, 9教科, 教科担任制 ---- +JH = {'国語':4,'社会':3,'数学':4,'理科':4,'音楽':1,'美術':1,'保健体育':3,'技術・家庭':2, + '外国語':4,'道徳':1,'総合':1,'特別活動':1} # 計29 +def junior(n_classes): + classes=[f'{i//(n_classes//3)+1}-{i%(n_classes//3)+1}' for i in range(n_classes)] + demand={}; tea={} + per={'国語':4,'社会':5,'数学':4,'理科':4,'音楽':8,'美術':8,'保健体育':6,'技術・家庭':8,'外国語':4} + for i,c in enumerate(classes): + for s,n in JH.items(): + demand[(c,s)]=n + tea[(c,s)] = f'担任{c}' if s in ('道徳','総合','特別活動') else f'{s}_T{i//per[s]}' + return classes, demand, tea + +# ---- 高校: 週30単位時間, 必履修中心 + 選択科目の同時展開 ---- +HS = {'国語':4,'地理歴史':3,'公民':2,'数学':5,'理科':4,'保健体育':3,'芸術':2, + '外国語':5,'情報':1,'総合的な探究':1} # 計30 +def high(n_classes): + per_grade=n_classes//3 + classes=[f'{i//per_grade+1}年{i%per_grade+1}組' for i in range(n_classes)] + demand={}; tea={} + per={'国語':4,'地理歴史':5,'公民':8,'数学':3,'理科':3,'保健体育':6,'芸術':8,'外国語':3,'情報':8,'総合的な探究':8} + for i,c in enumerate(classes): + for s,n in HS.items(): + demand[(c,s)]=n; tea[(c,s)]=f'{s}_T{i//per[s]}' + # 2・3年の 地歴/理科/芸術 を選択科目群として同時展開 + groups=[] + for g in (2,3): + gc=[c for c in classes if c.startswith(f'{g}年')] + groups.append((gc, ['地理歴史','理科','芸術'])) + return classes, demand, tea, groups + +print("=== 実態に基づく規模での求解時間 ===") +for n in (12, 18, 24): + c,d,t = elementary(n); solve(f"小学校 {n}学級(週29コマ)", c, 6, 5, d, t) +for n in (9, 12, 18): + c,d,t = junior(n); solve(f"中学校 {n}学級(週29コマ)", c, 6, 5, d, t) +for n in (15, 24, 30): + c,d,t,g = high(n); solve(f"高校 {n}学級(週30コマ,選択展開)", c, 6, 5, d, t, g) + +print("\n=== 高校の求解時間のばらつき(選択展開あり/なし) ===") +for n in (15, 18, 21, 24, 27, 30): + c,d,t,g = high(n) + solve(f"高校 {n}学級 選択展開あり", c, 6, 5, d, t, g, timeout=180) +for n in (15, 24, 30): + c,d,t,g = high(n) + solve(f"高校 {n}学級 選択展開なし", c, 6, 5, d, t, None, timeout=180) diff --git a/docs/requirements.md b/docs/requirements.md new file mode 100644 index 0000000..08404ec --- /dev/null +++ b/docs/requirements.md @@ -0,0 +1,256 @@ +# 時間割自動編成Webアプリ 要件定義 + +対象: 日本の小学校・中学校・高等学校の週時間割編成 + +- 自動提案は整数計画法(PuLP + CBC)で行う +- 人が画面上で微調整し、固定した部分を保ったまま再提案できる +- 強化学習アプローチ(`archive/` 予定の `env/`, `keras*.py`, `chainer0001.py`)は採用しない + +--- + +## 1. 調査結果 — 日本の学校の実態 + +数値はすべて一次資料(文部科学省)に基づく。出典は末尾。 + +### 1.1 標準授業時数(学校教育法施行規則 別表第一・第二) + +小学校(1単位時間 45分) + +| 教科等 | 1年 | 2年 | 3年 | 4年 | 5年 | 6年 | +|---|---|---|---|---|---|---| +| 国語 | 306 | 315 | 245 | 245 | 175 | 175 | +| 社会 | – | – | 70 | 90 | 100 | 105 | +| 算数 | 136 | 175 | 175 | 175 | 175 | 175 | +| 理科 | – | – | 90 | 105 | 105 | 105 | +| 生活 | 102 | 105 | – | – | – | – | +| 音楽 | 68 | 70 | 60 | 60 | 50 | 50 | +| 図画工作 | 68 | 70 | 60 | 60 | 50 | 50 | +| 家庭 | – | – | – | – | 60 | 55 | +| 体育 | 102 | 105 | 105 | 105 | 90 | 90 | +| 特別の教科 道徳 | 34 | 35 | 35 | 35 | 35 | 35 | +| 特別活動 | 34 | 35 | 35 | 35 | 35 | 35 | +| 総合的な学習の時間 | – | – | 70 | 70 | 70 | 70 | +| 外国語活動 | – | – | 35 | 35 | – | – | +| 外国語 | – | – | – | – | 70 | 70 | +| **合計** | **850** | **910** | **980** | **1015** | **1015** | **1015** | + +中学校(1単位時間 50分) + +| 教科等 | 1年 | 2年 | 3年 | +|---|---|---|---| +| 国語 | 140 | 140 | 105 | +| 社会 | 105 | 105 | 140 | +| 数学 | 140 | 105 | 140 | +| 理科 | 105 | 140 | 140 | +| 音楽 | 45 | 35 | 35 | +| 美術 | 45 | 35 | 35 | +| 保健体育 | 105 | 105 | 105 | +| 技術・家庭 | 70 | 70 | 35 | +| 外国語 | 140 | 140 | 140 | +| 特別の教科 道徳 | 35 | 35 | 35 | +| 総合的な学習の時間 | 50 | 70 | 70 | +| 特別活動 | 35 | 35 | 35 | +| **合計** | **1015** | **1015** | **1015** | + +高等学校(1単位時間 50分) + +- 卒業までに修得させる単位数は **74単位以上**(ただし全日制普通科の約2/3は75単位以上を課している) +- **35単位時間の授業を1単位**として計算。年間 **35週** を標準 +- 全日制課程の週当たり授業時数は **30単位時間を標準**(必要がある場合は増加可) +- 各学科に共通する教科・科目の標準単位数:国語(現代の国語2/言語文化2/論理国語4/文学国語4/国語表現4/古典探究4)、地理歴史(地理総合2/地理探究3/歴史総合2/日本史探究3/世界史探究3)、公民(公共2/倫理2/政治・経済2)、数学(Ⅰ3/Ⅱ4/Ⅲ3/A2/B2/C2)、理科(科学と人間生活2/各基礎2/物理・化学・生物・地学 各4)、保健体育(体育7〜8/保健2)、芸術(音楽・美術・工芸・書道 各Ⅰ〜Ⅲ 各2)、外国語(英語コミュニケーションⅠ3/Ⅱ4/Ⅲ4/論理・表現Ⅰ〜Ⅲ 各2)、家庭(家庭基礎2/家庭総合4)、情報(Ⅰ2/Ⅱ2)、理数(理数探究基礎1/理数探究2〜5)、総合的な探究の時間3〜6 +- 必履修科目:現代の国語、言語文化、地理総合、歴史総合、公共、数学Ⅰ(2単位まで減可)、理科(「科学と人間生活」を含む2科目 又は 基礎を付した科目を3科目)、体育、保健、芸術Ⅰのいずれか1、英語コミュニケーションⅠ(2単位まで減可)、家庭基礎または家庭総合、情報Ⅰ、総合的な探究の時間(2単位まで減可) + +### 1.2 週コマ数(年間時数 ÷ 授業週数)— 本アプリの最重要前提 + +| 学年 | 年間標準時数 | 授業週数 | **週コマ数** | 週5日で1日平均 | +|---|---|---|---|---| +| 小1 | 850 | 34 | 25.0 | 5.0 | +| 小2 | 910 | 35 | 26.0 | 5.2 | +| 小3 | 980 | 35 | 28.0 | 5.6 | +| 小4〜小6 | 1015 | 35 | **29.0** | 5.8 | +| 中1〜中3 | 1015 | 35 | **29.0** | 5.8 | +| 高(全日制標準) | — | 35 | **30.0** | 6.0 | + +**この数字が設計を決定づける。** 月〜金・1日6コマ=週30枠に対し、小4以上と中学校は29コマ、高校は30コマを詰める必要がある。 +**空き枠は週0〜1コマしかなく、実質的に「ほぼ完全充填問題」である。** + +したがって: + +- ハード制約のみで組むと、利用者が少しでも条件(教員の不在日、特別教室の制限など)を追加した瞬間に実行不可能になる +- **全制約をソフト制約(違反にペナルティ)として実装することは必須要件**であり、選択肢ではない +- 高校が週30コマ=完全充填になるため、多くの学校が7時間目設定や65分授業等で枠を増やしている。**1日のコマ数は校種・曜日ごとに可変で持つ必要がある** + +### 1.3 学級規模・学校規模 + +学級編制の標準(義務標準法) + +- 小学校:**35人**。令和3〜7年度で段階的に引き下げ、**令和7年度に完成済み** +- 中学校:40人→**35人**。**令和8年4月1日施行**で、R8=中1、R9=中2、R10=中3 と段階実施(現在はR8=中1のみ35人、中2・中3は経過措置で40人) +- 高等学校:40人 + +学校規模の区分(公立小・中学校の適正規模・適正配置等に関する手引) + +| 区分 | 学級数 | +|---|---| +| 過小規模校 | 1〜5学級 | +| 小規模校 | 6〜11学級 | +| **適正規模校(標準)** | **12〜18学級** | +| 大規模校 | 19〜30学級 | + +1校あたりの平均規模(令和7年度学校基本調査の在学者数・学校数から算出) + +| 校種 | 学校数 | 在学者数 | 1校平均 | 概算学級数 | 1学年あたり | +|---|---|---|---|---|---| +| 小学校 | 18,607校 | 581.2万人 | 約312人 | 約9学級 | 約1.5学級 | +| 中学校 | 9,827校 | 310.5万人 | 約316人 | 約9学級 | 約3学級 | +| 高等学校 | 4,761校 | 287.4万人 | 約604人 | 約15学級 | 約5学級 | + +→ **サポート規模の要件:1〜36学級。設計上限は36学級とする。** +平均は9学級だが、大規模校は30学級まで存在するため、余裕を見て36とする。 + +### 1.4 校種ごとの時間割編成の実態(制約の中身) + +**小学校 — 学級担任制+専科** + +- 原則として学級担任が1クラスの大半の教科を担当する。したがって「教員の重複」制約はクラス内では自明に満たされ、**難所は専科教員と特別教室に限定される** +- 文部科学省は小学校高学年で **外国語・理科・算数・体育** を優先的に専科指導の対象としており、令和4年度から教職員定数を措置して段階的に推進。中教審では3〜4年生への拡大も議論されている +- → 要件:教員は「学級担任」と「専科」の2種を区別して持つ。担任は自クラス限定、専科は複数クラス担当 + +**中学校 — 教科担任制** + +- 全教科が教科担任制。教員の**週の持ちコマ数は平均18.1コマ**(令和4年度教員勤務実態調査。令和元年度は18.0コマ)で、**21コマ以上を担当する教員が33%**に増加している +- → 要件:**教員ごとの週持ちコマ数上限**をソフト制約として持つ(既定18、上限設定可)。これが無いと特定教員に偏った解が出る +- 部活動・給食・清掃の時間帯があるため、放課後の枠は授業に使えない + +**高等学校 — 単位制・選択科目の同時展開** + +- 選択科目や習熟度別指導では、**学級と受講集団(講座)が一致しない**。同一時間帯に複数講座を並列開講し、生徒が教室を移動する +- 単位制高校では「基礎科目を履修しないと発展科目を履修できないため入学年次の教育課程が過密になる」といった課題が指摘されている +- → 要件:**「クラス」ではなく「講座(受講集団)」を割当の単位とするデータモデルが必須**。小・中は「1クラス=1講座」の縮退ケースとして扱う +- → 要件:**選択科目群の同時展開制約**(学年内の複数講座を同一コマに揃える) + +**特別支援学級・通級による指導** + +- 特別支援学級在籍者は約35万3千人(義務教育段階の児童生徒の3.7%)で、平成24年度比で2倍以上に増加。通級による指導は約16万5千人 +- 特別支援学級の児童生徒が「交流及び共同学習」として通常学級の授業に参加するため、**特別支援学級と通常学級の時間割の整合**が必要 +- → 要件:特別支援学級を学級として登録でき、特定教科について通常学級と同一コマに配置する制約(=同時展開制約の再利用)を持つ + +--- + +## 2. 確定要件 + +### 2.1 性能要件(実測に基づく) + +`benchmarks/solver_benchmark.py` で上記の実態に即したインスタンスを構成し計測した結果: + +| インスタンス | 変数 | 制約 | 求解時間 | 結果 | +|---|---|---|---|---| +| 小学校 12学級(週29コマ) | 4,464 | 1,884 | 1.06s | Optimal | +| 小学校 18学級 | 6,696 | 2,856 | 0.86s | Optimal | +| 小学校 24学級 | 8,928 | 3,768 | 1.45s | Optimal | +| 中学校 9学級(週29コマ) | 3,348 | 1,848 | 0.80s | Optimal | +| 中学校 12学級 | 4,464 | 2,274 | 2.09s | Optimal | +| 中学校 18学級 | 6,696 | 3,456 | 0.81s | Optimal | +| 高校 15学級(週30コマ・選択展開) | 4,710 | 3,240 | **5.92s** | Optimal | +| 高校 18学級 | 5,640 | 3,960 | 0.34s | Optimal | +| 高校 21学級 | 6,570 | 4,590 | 0.53s | Optimal | +| 高校 24学級 | 7,500 | 5,130 | **24.35s** | Optimal | +| 高校 27学級 | 8,430 | 5,880 | 0.74s | Optimal | +| 高校 30学級 | 9,360 | 6,450 | 2.74s | Optimal | + +**結論(重要)** + +1. **小学校・中学校は 1〜2秒で安定して解ける。** 同期APIで問題ない +2. **高校は 0.3秒〜25秒とばらつき、規模と相関しない。** 分枝限定法の探索運に左右されるため、**求解は非同期ジョブとし、進捗表示とタイムアウトを設ける必要がある** + - 前提が変わったため、当初「非同期ジョブ基盤は不要」としていた判断はここで撤回する + - ただしWebSocketは不要で、ジョブID+ポーリング(2秒間隔)で十分 +3. 求解には**必ず時間制限(既定60秒)を設定し、時間内の最良解+未充足コマ数を返す**。CBCは時間制限時に暫定解を返せるため、「解なし」で終わらせない +4. 部分再最適化(固定率30〜95%)は 0.5〜0.9秒で完了するため、**微調整→再提案のループは対話的な速度で回せる** + +### 2.2 データモデル要件 + +| エンティティ | 必須属性 | 備考 | +|---|---|---| +| 学校 | 校種(小/中/高)、1単位時間(45/50分)、曜日数、曜日別コマ数 | 校種でプロファイルを切替 | +| 学年 | 学年、年間標準時数、授業週数 | 週コマ数を自動算出 | +| 学級 | 学年、組、種別(通常/特別支援) | 1〜36学級 | +| 教員 | 氏名、担当教科、種別(担任/専科/教科担任/非常勤)、**週持ちコマ上限**、不在枠 | 非常勤は勤務曜日限定 | +| 教科 | 名称、校種、標準時数/標準単位数 | 上表をマスタとして同梱 | +| **講座** | 教科、担当教員、受講学級(複数可)、週コマ数、使用教室 | **割当の単位。小中は1学級=1講座** | +| 教室 | 名称、種別(普通/理科室/音楽室/体育館/家庭科室/PC室)、収容 | 特別教室は排他 | +| 時間割 | 学校、年度、バージョン、状態 | 提案→編集の履歴 | +| 割当 | 時間割、講座、曜日、コマ、教室、**固定フラグ** | 固定=再提案時に保持 | + +### 2.3 制約要件 + +ハード制約(違反を許さない。ただし実装上はごく大きなペナルティとして表現し、必ず解を返す) + +| # | 制約 | 適用 | +|---|---|---| +| H1 | 1学級・1コマに2つの講座を置かない | 全校種 | +| H2 | 1教員・1コマに2つの講座を置かない | 全校種 | +| H3 | 1教室・1コマに2つの講座を置かない | 全校種 | +| H4 | 配置禁止枠(土曜午後、行事枠)には置かない | 全校種 | +| H5 | 教員の不在枠には置かない(非常勤の勤務曜日等) | 全校種 | +| H6 | 固定(ロック)された割当は動かさない | 全校種 | + +ソフト制約(重み付きで最小化。重みは設定可能) + +| # | 制約 | 既定重み | 適用 | +|---|---|---|---| +| S1 | 講座の週コマ数を満たす | 最大 | 全校種 | +| S2 | 同一教科を同一日に2回以上置かない(週5コマ超の教科は2回まで許容) | 大 | 全校種 | +| S3 | 教員の週持ちコマ数上限(既定18コマ) | 大 | 中・高 | +| S4 | 選択科目群を学年内で同一コマに揃える(同時展開) | 最大 | 高 | +| S5 | 特別支援学級の交流教科を通常学級と同一コマに揃える | 大 | 小・中 | +| S6 | 教科を週内に分散させる(連日への偏りを避ける) | 中 | 全校種 | +| S7 | 教員の空きコマを分散させる(連続授業を避ける) | 小 | 中・高 | +| S8 | 実技系(体育・実験・実習)は2コマ連続で配置 | 中 | 中・高 | +| S9 | 主要教科を午前に寄せる | 小 | 全校種 | +| S10 | 学級担任の自クラス滞在時間を確保する | 小 | 小 | + +### 2.4 前回タスク一覧への差分 + +以下を追加または変更する。 + +**追加** + +- [ ] **1-7** 校種プロファイル(小/中/高)の実装。1単位時間、曜日別コマ数、週コマ数の自動算出、既定の教科マスタを校種ごとに切替 +- [ ] **1-8** 標準授業時数マスタの同梱(上表をシードデータとして投入し、学級を作れば必要週コマ数が自動で埋まる状態にする) +- [ ] **1-9** 「講座(受講集団)」モデルの実装。高校の選択科目・習熟度別展開、および特別支援学級の交流を同一の仕組みで表現する +- [ ] **1-10** 同時展開制約(S4/S5)の実装 +- [ ] **1-11** 教員の週持ちコマ数上限(S3)と不在枠(H5)の実装 +- [ ] **1-12** 2コマ連続配置(S8)の実装 +- [ ] **4-7** **求解の非同期ジョブ化**(`POST /solve` → ジョブID即返し、`GET /solve/{job_id}` でポーリング)。求解時間制限(既定60秒)と暫定解の返却 +- [ ] **5-5** 求解中の進捗表示(経過時間・暫定の未充足コマ数)とキャンセル +- [ ] **5-6** **講座別ビュー**の追加(クラス別・教員別に加え、高校では講座単位の確認が必要) +- [ ] **7-7** 教室(特別教室)マスタと教室割当のUI + +**変更** + +- 前回「非同期ジョブ基盤・進捗表示は不要(実測により)」としたスコープ判断を撤回する。小・中のみを対象とするなら不要のままだが、高校を対象に含めるため必要。ただしポーリングで足り、キュー・ワーカー基盤までは不要 +- 規模要件を「1〜36学級」に確定 +- フロントエンドスタックを **React + TypeScript + Vite + @dnd-kit** に確定 + +### 2.5 スコープ外(初版) + +- 年間の時数管理(週時間割のみを扱い、年間の進捗管理・振替は対象外) +- 定時制・通信制課程、中高一貫の6年間一括編成 +- 生徒個人単位の履修登録(選択科目は「講座」までを扱い、生徒の割り振りは対象外) +- 部活動・行事予定との連携 +- 認証、複数ユーザー、複数校、年度をまたぐ管理 + +--- + +## 出典 + +- [標準授業時数について(文部科学省 教育課程部会 資料)](https://www.mext.go.jp/content/20210629-mxt_kyoiku01-000016453_4.pdf) — 小・中学校の標準授業時数表、1単位時間 +- [授業時数等に関する学校教育法施行規則及び学習指導要領の規定(文部科学省)](https://www.mext.go.jp/b_menu/shingi/chukyo/chukyo0/toushin/attach/1399930.htm) +- [平成30年改訂 高等学校学習指導要領 各学科に共通する教科・科目等及び標準単位数(文部科学省)](https://www.mext.go.jp/component/a_menu/education/micro_detail/__icsFiles/afieldfile/2019/02/12/1413264_001.pdf) +- [高等学校学習指導要領 第1章 総則(文部科学省)](https://www.mext.go.jp/a_menu/shotou/cs/1320224.htm) — 74単位、1単位時間50分、35単位時間=1単位、週30単位時間 +- [公立義務教育諸学校の学級編制及び教職員定数の標準に関する法律の一部を改正する法律案の概要(文部科学省)](https://www.mext.go.jp/content/20260225-mxt_hourei-000047659_1.pdf) — 中学校35人学級の年次計画 +- [公立小学校・中学校の適正規模・適正配置等に関する手引(文部科学省)](https://www.mext.go.jp/component/a_menu/education/micro_detail/__icsFiles/afieldfile/2015/07/24/1354768_1.pdf) — 学校規模の区分 +- [令和7年度学校基本統計(確定値)(文部科学省)](https://www.mext.go.jp/content/20260327-mxt_chousa01-000048552_10.pdf) — 学校数、在学者数 +- [小学校高学年における教科担任制について(文部科学省)](https://www.mext.go.jp/a_menu/other/index_00007.html) — 専科指導の優先教科 +- [教員勤務実態調査(令和4年度)集計(確定値)(文部科学省)](https://www.mext.go.jp/content/20240404-mxt_zaimu01-100003067-2.pdf) — 週持ちコマ数 +- [特別支援教育の現状(文部科学省)](https://www.mext.go.jp/a_menu/shotou/tokubetu/002.htm) — 特別支援学級・通級の在籍者数 From e11cc862f9cde1e64bbf27e7bfe76b6667bf69ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 08:56:47 +0000 Subject: [PATCH 2/5] =?UTF-8?q?=E3=83=90=E3=83=83=E3=82=AF=E3=82=A8?= =?UTF-8?q?=E3=83=B3=E3=83=89=E5=AE=9F=E8=A3=85:=20=E6=95=B4=E6=95=B0?= =?UTF-8?q?=E8=A8=88=E7=94=BB=E3=82=BD=E3=83=AB=E3=83=90=E3=83=BC=E3=83=BB?= =?UTF-8?q?=E6=A4=9C=E8=A8=BC=E3=82=A8=E3=83=B3=E3=82=B8=E3=83=B3=E3=83=BB?= =?UTF-8?q?=E9=9D=9E=E5=90=8C=E6=9C=9F=E6=B1=82=E8=A7=A3API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 時間割自動編成のバックエンドを実装した。制約は constraints.py に1度だけ定義し、 「LPへの追加(apply)」と「既存割当の検査(check)」の両方を同じクラスが持つ構造に することで、自動提案と手編集の検証がズレないようにしている。 - ドメイン: 割当の単位を学級×教科ではなく Course(講座)とし、高校の選択科目の 並列開講と特別支援学級の交流及び共同学習を同じ仕組みで表現できるようにした - 教育課程マスタ: 学校教育法施行規則の標準授業時数を持ち、週コマ数を最大剰余法で 導出する。導出結果は小1=25、小4〜6=29、中=29、高=30で施行規則の総授業時数と一致 - ソルバー: ハード制約をすべて「空解で充足可能」な形にしたためモデルは常に実行可能で、 不足は必ず未充足コマ数として定量化される - 求解API: ジョブID+ポーリングの非同期方式。相対ギャップ2%・時間制限30秒で打ち切る 実測(生成データ): 小学校19学級 5.3秒、中学校13学級 4.2秒、高校15学級 19.8秒。 いずれも未充足0コマ・ハード違反0。 実装中に判明して直した点: - 特別教室の保有数を固定値にすると大規模校で原理的に配置不能になるため、需要から算出 - 同時展開制約を緩めると、それに依存する H1(学級の重複)が実質ソフト化してしまう - 交流及び共同学習を別講座+同時展開で表現すると教室を二重に消費してしまう - 同じ講座を同じコマに二重配置すると固定の矛盾検査をすり抜ける 置いた仮定はすべて docs/assumptions.md に A-01〜A-29 として記録した。 バックエンドテスト50件すべて通過。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DHGVNyssktZ69PWwmY1LCA --- backend/.gitignore | 4 + backend/app/__init__.py | 0 backend/app/api.py | 591 ++++++++++++++++++++++++++ backend/app/curriculum.py | 238 +++++++++++ backend/app/db.py | 36 ++ backend/app/jobs.py | 194 +++++++++ backend/app/main.py | 43 ++ backend/app/models.py | 218 ++++++++++ backend/app/services.py | 379 +++++++++++++++++ backend/app/solver/__init__.py | 0 backend/app/solver/constraints.py | 662 ++++++++++++++++++++++++++++++ backend/app/solver/engine.py | 154 +++++++ backend/app/solver/spec.py | 154 +++++++ backend/pyproject.toml | 29 ++ backend/requirements.txt | 7 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 32 ++ backend/tests/test_api.py | 341 +++++++++++++++ backend/tests/test_curriculum.py | 63 +++ backend/tests/test_solver.py | 248 +++++++++++ docs/assumptions.md | 219 ++++++++++ 21 files changed, 3612 insertions(+) create mode 100644 backend/.gitignore create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api.py create mode 100644 backend/app/curriculum.py create mode 100644 backend/app/db.py create mode 100644 backend/app/jobs.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/services.py create mode 100644 backend/app/solver/__init__.py create mode 100644 backend/app/solver/constraints.py create mode 100644 backend/app/solver/engine.py create mode 100644 backend/app/solver/spec.py create mode 100644 backend/pyproject.toml create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_api.py create mode 100644 backend/tests/test_curriculum.py create mode 100644 backend/tests/test_solver.py create mode 100644 docs/assumptions.md diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..8a309b2 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +*.db +.pytest_cache/ diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api.py b/backend/app/api.py new file mode 100644 index 0000000..08055a4 --- /dev/null +++ b/backend/app/api.py @@ -0,0 +1,591 @@ +"""HTTP API。 + +求解は非同期(ジョブID+ポーリング)、検証は同期。要件書 2.1 の実測に基づく。 +""" + +from __future__ import annotations + +import csv +import io + +from fastapi import APIRouter, Depends, HTTPException, Response +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from . import curriculum as cur +from . import models as m +from . import services +from .db import get_db +from .jobs import registry +from .solver.constraints import CONSTRAINT_INFO, DAY_NAMES +from .solver.engine import DEFAULT_TIME_LIMIT_SEC, validate +from .solver.spec import Placement + +router = APIRouter(prefix="/api") + + +# --------------------------------------------------------------------------- +# リクエストの型 +# --------------------------------------------------------------------------- + + +class SchoolCreate(BaseModel): + name: str = Field(min_length=1, max_length=100) + school_type: str + classes_per_grade: int = Field(ge=1, le=12) + include_special_needs: bool = False + + +class SolveRequest(BaseModel): + source_timetable_id: int | None = None + time_limit_sec: int = Field(default=DEFAULT_TIME_LIMIT_SEC, ge=1, le=600) + name: str | None = None + + +class AssignmentCreate(BaseModel): + course_id: int + day: int = Field(ge=0, le=6) + period: int = Field(ge=0, le=11) + + +class AssignmentMove(BaseModel): + day: int = Field(ge=0, le=6) + period: int = Field(ge=0, le=11) + + +class LockUpdate(BaseModel): + locked: bool + + +class AssignmentItem(BaseModel): + course_id: int + day: int + period: int + locked: bool = False + + +class AssignmentsReplace(BaseModel): + """時間割の割当をまとめて置き換える。フロントの undo/redo が使う。""" + + assignments: list[AssignmentItem] + + +class TeacherUpdate(BaseModel): + max_weekly_periods: int | None = Field(default=None, ge=1, le=40) + unavailable: list[AssignmentMove] | None = None + + +# --------------------------------------------------------------------------- +# シリアライズ +# --------------------------------------------------------------------------- + + +def _school_dict(school: m.School) -> dict: + return { + "id": school.id, + "name": school.name, + "school_type": school.school_type, + "school_type_label": cur.SCHOOL_TYPE_LABELS[school.school_type], + "days": school.days, + "periods_per_day": school.periods_per_day, + "period_minutes": school.period_minutes, + "day_names": DAY_NAMES[: school.days], + } + + +def _timetable_payload(db: Session, timetable: m.Timetable) -> dict: + school = timetable.school + spec = services.build_spec(db, school, timetable) + placements = services.placements_of(timetable) + violations = validate(spec, placements) + + group_of = spec.group_of_course + courses = [] + placed_counts: dict[int, int] = {} + for a in timetable.assignments: + placed_counts[a.course_id] = placed_counts.get(a.course_id, 0) + 1 + for c in db.query(m.Course).filter(m.Course.school_id == school.id).all(): + courses.append( + { + "id": c.id, + "subject_id": c.subject_id, + "subject_name": c.subject.name, + "short_name": c.subject.short_name, + "is_core": bool(c.subject.is_core), + "room_type": c.subject.room_type, + "teacher_id": c.teacher_id, + "teacher_name": c.teacher.name, + "class_ids": [k.id for k in c.classes], + "weekly_periods": c.weekly_periods, + "max_per_day": c.max_per_day, + "prefers_double": bool(c.prefers_double), + "group_id": group_of.get(c.id), + "placed": placed_counts.get(c.id, 0), + } + ) + + unplaced = [ + {"course_id": c["id"], "remaining": c["weekly_periods"] - c["placed"]} + for c in courses + if c["weekly_periods"] > c["placed"] + ] + + return { + "id": timetable.id, + "name": timetable.name, + "parent_id": timetable.parent_id, + "created_at": timetable.created_at.isoformat(), + "school": _school_dict(school), + "classes": [ + {"id": k.id, "name": k.name, "grade": k.grade, "kind": k.kind} + for k in sorted( + db.query(m.SchoolClass).filter(m.SchoolClass.school_id == school.id).all(), + key=lambda k: (k.kind != "normal", k.grade, k.name), + ) + ], + "teachers": [ + { + "id": t.id, + "name": t.name, + "kind": t.kind, + "max_weekly_periods": t.max_weekly_periods, + "unavailable": [{"day": u.day, "period": u.period} for u in t.unavailabilities], + } + for t in db.query(m.Teacher).filter(m.Teacher.school_id == school.id).all() + ], + "courses": courses, + "parallel_groups": [ + {"id": g.id, "name": g.name, "course_ids": [c.id for c in g.courses]} + for g in db.query(m.ParallelGroup) + .filter(m.ParallelGroup.school_id == school.id) + .all() + ], + "blocked_slots": [ + {"class_id": b.class_id, "day": b.day, "period": b.period, "reason": b.reason} + for b in db.query(m.BlockedSlot) + .filter(m.BlockedSlot.school_id == school.id) + .all() + ], + "assignments": [ + { + "id": a.id, + "course_id": a.course_id, + "day": a.day, + "period": a.period, + "locked": bool(a.locked), + } + for a in sorted(timetable.assignments, key=lambda a: (a.day, a.period, a.id)) + ], + "violations": [v.as_dict() for v in violations], + "unplaced": unplaced, + "summary": { + "total_periods": sum(c["weekly_periods"] for c in courses), + "placed_periods": len(timetable.assignments), + "unmet_periods": sum(u["remaining"] for u in unplaced), + "hard_violations": sum(1 for v in violations if v.severity == "hard"), + "soft_violations": sum(1 for v in violations if v.severity == "soft"), + "locked": sum(1 for a in timetable.assignments if a.locked), + }, + } + + +def _get_timetable(db: Session, timetable_id: int) -> m.Timetable: + tt = db.get(m.Timetable, timetable_id) + if tt is None: + raise HTTPException(status_code=404, detail="時間割が見つかりません") + return tt + + +# --------------------------------------------------------------------------- +# メタ情報 +# --------------------------------------------------------------------------- + + +@router.get("/meta") +def get_meta() -> dict: + """校種の選択肢と、週コマ数の内訳。画面の初期表示に使う。""" + school_types = [] + for st, label in cur.SCHOOL_TYPE_LABELS.items(): + grades = [] + for g in cur.GRADES[st]: + weekly = cur.weekly_periods_for(st, g) + electives = [ + {"name": name, "periods": n, "subjects": subs} + for name, n, subs in cur.HIGH_ELECTIVES.get(g, []) + ] if st == cur.HIGH else [] + grades.append( + { + "grade": g, + "weekly": weekly, + "electives": electives, + "total": cur.total_weekly_periods(st, g), + } + ) + school_types.append( + { + "value": st, + "label": label, + "period_minutes": cur.PERIOD_MINUTES[st], + "periods_per_day": cur.PERIODS_PER_DAY[st], + "grades": grades, + } + ) + return { + "school_types": school_types, + "constraints": CONSTRAINT_INFO, + "max_classes": services.MAX_CLASSES, + "day_names": DAY_NAMES[:5], + } + + +# --------------------------------------------------------------------------- +# 学校 +# --------------------------------------------------------------------------- + + +@router.get("/schools") +def list_schools(db: Session = Depends(get_db)) -> list[dict]: + schools = db.query(m.School).order_by(m.School.id.desc()).all() + out = [] + for s in schools: + out.append( + { + **_school_dict(s), + "class_count": db.query(m.SchoolClass) + .filter(m.SchoolClass.school_id == s.id) + .count(), + "course_count": db.query(m.Course).filter(m.Course.school_id == s.id).count(), + "timetable_count": db.query(m.Timetable) + .filter(m.Timetable.school_id == s.id) + .count(), + } + ) + return out + + +@router.post("/schools", status_code=201) +def create_school(payload: SchoolCreate, db: Session = Depends(get_db)) -> dict: + try: + school = services.generate_school( + db, + name=payload.name, + school_type=payload.school_type, + classes_per_grade=payload.classes_per_grade, + include_special_needs=payload.include_special_needs, + ) + except services.GenerationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return { + **_school_dict(school), + "class_count": len(school.classes), + "course_count": len(school.courses), + "timetable_count": 0, + } + + +@router.delete("/schools/{school_id}", status_code=204) +def delete_school(school_id: int, db: Session = Depends(get_db)) -> Response: + school = db.get(m.School, school_id) + if school is None: + raise HTTPException(status_code=404, detail="学校が見つかりません") + db.delete(school) + db.commit() + return Response(status_code=204) + + +@router.get("/schools/{school_id}/timetables") +def list_timetables(school_id: int, db: Session = Depends(get_db)) -> list[dict]: + if db.get(m.School, school_id) is None: + raise HTTPException(status_code=404, detail="学校が見つかりません") + rows = ( + db.query(m.Timetable) + .filter(m.Timetable.school_id == school_id) + .order_by(m.Timetable.id.desc()) + .all() + ) + return [ + { + "id": t.id, + "name": t.name, + "parent_id": t.parent_id, + "created_at": t.created_at.isoformat(), + "assignment_count": len(t.assignments), + } + for t in rows + ] + + +@router.patch("/teachers/{teacher_id}") +def update_teacher( + teacher_id: int, payload: TeacherUpdate, db: Session = Depends(get_db) +) -> dict: + teacher = db.get(m.Teacher, teacher_id) + if teacher is None: + raise HTTPException(status_code=404, detail="教員が見つかりません") + if payload.max_weekly_periods is not None: + teacher.max_weekly_periods = payload.max_weekly_periods + if payload.unavailable is not None: + for u in list(teacher.unavailabilities): + db.delete(u) + db.flush() + for slot in payload.unavailable: + db.add( + m.TeacherUnavailability( + teacher_id=teacher.id, day=slot.day, period=slot.period + ) + ) + db.commit() + db.refresh(teacher) + return { + "id": teacher.id, + "name": teacher.name, + "kind": teacher.kind, + "max_weekly_periods": teacher.max_weekly_periods, + "unavailable": [{"day": u.day, "period": u.period} for u in teacher.unavailabilities], + } + + +# --------------------------------------------------------------------------- +# 求解ジョブ +# --------------------------------------------------------------------------- + + +@router.post("/schools/{school_id}/solve", status_code=202) +def start_solve(school_id: int, payload: SolveRequest, db: Session = Depends(get_db)) -> dict: + if db.get(m.School, school_id) is None: + raise HTTPException(status_code=404, detail="学校が見つかりません") + if payload.source_timetable_id is not None: + source = db.get(m.Timetable, payload.source_timetable_id) + if source is None or source.school_id != school_id: + raise HTTPException(status_code=404, detail="元の時間割が見つかりません") + job = registry.submit( + school_id=school_id, + source_timetable_id=payload.source_timetable_id, + time_limit_sec=payload.time_limit_sec, + name=payload.name, + ) + return job.as_dict() + + +@router.get("/jobs/{job_id}") +def get_job(job_id: str) -> dict: + job = registry.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="ジョブが見つかりません") + return job.as_dict() + + +@router.post("/jobs/{job_id}/cancel") +def cancel_job(job_id: str) -> dict: + job = registry.get(job_id) + if job is None: + raise HTTPException(status_code=404, detail="ジョブが見つかりません") + registry.cancel(job_id) + return registry.get(job_id).as_dict() + + +# --------------------------------------------------------------------------- +# 時間割 +# --------------------------------------------------------------------------- + + +@router.get("/timetables/{timetable_id}") +def get_timetable(timetable_id: int, db: Session = Depends(get_db)) -> dict: + return _timetable_payload(db, _get_timetable(db, timetable_id)) + + +@router.delete("/timetables/{timetable_id}", status_code=204) +def delete_timetable(timetable_id: int, db: Session = Depends(get_db)) -> Response: + tt = _get_timetable(db, timetable_id) + db.delete(tt) + db.commit() + return Response(status_code=204) + + +@router.post("/timetables/{timetable_id}/assignments", status_code=201) +def add_assignment( + timetable_id: int, payload: AssignmentCreate, db: Session = Depends(get_db) +) -> dict: + tt = _get_timetable(db, timetable_id) + course = db.get(m.Course, payload.course_id) + if course is None or course.school_id != tt.school_id: + raise HTTPException(status_code=404, detail="講座が見つかりません") + if any( + x.course_id == payload.course_id + and x.day == payload.day + and x.period == payload.period + for x in tt.assignments + ): + raise HTTPException( + status_code=409, detail="同じ講座がすでにそのコマに配置されています" + ) + db.add( + m.Assignment( + timetable_id=tt.id, + course_id=payload.course_id, + day=payload.day, + period=payload.period, + ) + ) + db.commit() + db.refresh(tt) + return _timetable_payload(db, tt) + + +@router.patch("/timetables/{timetable_id}/assignments/{assignment_id}") +def move_assignment( + timetable_id: int, + assignment_id: int, + payload: AssignmentMove, + db: Session = Depends(get_db), +) -> dict: + tt = _get_timetable(db, timetable_id) + a = db.get(m.Assignment, assignment_id) + if a is None or a.timetable_id != tt.id: + raise HTTPException(status_code=404, detail="割当が見つかりません") + if a.locked: + raise HTTPException(status_code=409, detail="固定された割当は動かせません") + duplicate = [ + other + for other in tt.assignments + if other.id != a.id + and other.course_id == a.course_id + and other.day == payload.day + and other.period == payload.period + ] + if duplicate: + raise HTTPException( + status_code=409, detail="同じ講座がすでにそのコマに配置されています" + ) + a.day = payload.day + a.period = payload.period + db.commit() + db.refresh(tt) + return _timetable_payload(db, tt) + + +@router.delete("/timetables/{timetable_id}/assignments/{assignment_id}") +def delete_assignment( + timetable_id: int, assignment_id: int, db: Session = Depends(get_db) +) -> dict: + tt = _get_timetable(db, timetable_id) + a = db.get(m.Assignment, assignment_id) + if a is None or a.timetable_id != tt.id: + raise HTTPException(status_code=404, detail="割当が見つかりません") + if a.locked: + raise HTTPException(status_code=409, detail="固定された割当は削除できません") + db.delete(a) + db.commit() + db.refresh(tt) + return _timetable_payload(db, tt) + + +@router.post("/timetables/{timetable_id}/assignments/{assignment_id}/lock") +def set_lock( + timetable_id: int, + assignment_id: int, + payload: LockUpdate, + db: Session = Depends(get_db), +) -> dict: + tt = _get_timetable(db, timetable_id) + a = db.get(m.Assignment, assignment_id) + if a is None or a.timetable_id != tt.id: + raise HTTPException(status_code=404, detail="割当が見つかりません") + a.locked = payload.locked + db.commit() + db.refresh(tt) + return _timetable_payload(db, tt) + + +@router.put("/timetables/{timetable_id}/assignments") +def replace_assignments( + timetable_id: int, payload: AssignmentsReplace, db: Session = Depends(get_db) +) -> dict: + """割当をまとめて置き換える。フロントの undo/redo はこれを使う。""" + tt = _get_timetable(db, timetable_id) + valid_course_ids = { + c.id for c in db.query(m.Course).filter(m.Course.school_id == tt.school_id).all() + } + for item in payload.assignments: + if item.course_id not in valid_course_ids: + raise HTTPException( + status_code=400, detail=f"講座 {item.course_id} はこの学校のものではありません" + ) + for a in list(tt.assignments): + db.delete(a) + db.flush() + for item in payload.assignments: + db.add( + m.Assignment( + timetable_id=tt.id, + course_id=item.course_id, + day=item.day, + period=item.period, + locked=item.locked, + ) + ) + db.commit() + db.refresh(tt) + return _timetable_payload(db, tt) + + +@router.post("/timetables/{timetable_id}/validate") +def validate_timetable(timetable_id: int, db: Session = Depends(get_db)) -> dict: + tt = _get_timetable(db, timetable_id) + payload = _timetable_payload(db, tt) + return {"violations": payload["violations"], "summary": payload["summary"]} + + +@router.get("/timetables/{timetable_id}/export.csv") +def export_csv( + timetable_id: int, by: str = "class", db: Session = Depends(get_db) +) -> Response: + """時間割をCSVで出力する。by=class でクラス別、by=teacher で教員別。""" + tt = _get_timetable(db, timetable_id) + payload = _timetable_payload(db, tt) + school = payload["school"] + courses = {c["id"]: c for c in payload["courses"]} + + if by == "teacher": + rows_meta = [(t["id"], t["name"]) for t in payload["teachers"]] + + def owns(course: dict, owner_id: int) -> bool: + return course["teacher_id"] == owner_id + else: + rows_meta = [(k["id"], k["name"]) for k in payload["classes"]] + + def owns(course: dict, owner_id: int) -> bool: + return owner_id in course["class_ids"] + + buf = io.StringIO() + writer = csv.writer(buf) + header = ["対象", "曜日"] + [f"{p + 1}限" for p in range(school["periods_per_day"])] + writer.writerow(header) + for owner_id, owner_name in rows_meta: + cells: dict[tuple[int, int], list[str]] = {} + for a in payload["assignments"]: + course = courses[a["course_id"]] + if owns(course, owner_id): + label = course["short_name"] + if by == "teacher": + label += "(" + "・".join( + k["name"] + for k in payload["classes"] + if k["id"] in course["class_ids"] + ) + ")" + cells.setdefault((a["day"], a["period"]), []).append(label) + if not cells: + continue + for d in range(school["days"]): + row = [owner_name, school["day_names"][d]] + for p in range(school["periods_per_day"]): + row.append("/".join(sorted(cells.get((d, p), [])))) + writer.writerow(row) + + filename = f"timetable_{tt.id}_{by}.csv" + return Response( + # Excel で開いたときに文字化けしないよう BOM を付ける + content="" + buf.getvalue(), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/backend/app/curriculum.py b/backend/app/curriculum.py new file mode 100644 index 0000000..e4c486d --- /dev/null +++ b/backend/app/curriculum.py @@ -0,0 +1,238 @@ +"""教育課程マスタ。 + +小学校・中学校は学校教育法施行規則 別表第一・第二の標準授業時数をそのまま持ち、 +週コマ数は「年間授業時数 ÷ 授業週数」を最大剰余法で整数に配分して導出する(仮定 A-14)。 +高等学校は単位制で教科ごとの標準時数が定められていないため、必履修科目を中心とした +標準的な教育課程を1つ用意し、標準単位数をそのまま週コマ数として扱う(1単位=週1コマ。 +35単位時間 ÷ 35週 による)。 + +出典は docs/requirements.md の末尾を参照。 +""" + +from __future__ import annotations + +from dataclasses import dataclass + +ELEMENTARY = "elementary" +JUNIOR = "junior" +HIGH = "high" + +SCHOOL_TYPE_LABELS = {ELEMENTARY: "小学校", JUNIOR: "中学校", HIGH: "高等学校"} +GRADES = {ELEMENTARY: [1, 2, 3, 4, 5, 6], JUNIOR: [1, 2, 3], HIGH: [1, 2, 3]} +PERIOD_MINUTES = {ELEMENTARY: 45, JUNIOR: 50, HIGH: 50} +PERIODS_PER_DAY = {ELEMENTARY: 6, JUNIOR: 6, HIGH: 6} + + +@dataclass(frozen=True) +class SubjectMeta: + name: str + short_name: str + is_core: bool = False + room_type: str | None = None + prefers_double: bool = False + + +SUBJECT_META: dict[str, SubjectMeta] = { + m.name: m + for m in [ + # 小学校 + SubjectMeta("国語", "国", is_core=True), + SubjectMeta("社会", "社", is_core=True), + SubjectMeta("算数", "算", is_core=True), + SubjectMeta("理科", "理", is_core=True, room_type="理科室", prefers_double=True), + SubjectMeta("生活", "生"), + SubjectMeta("音楽", "音", room_type="音楽室"), + SubjectMeta("図画工作", "図", room_type="図工室", prefers_double=True), + SubjectMeta("家庭", "家", room_type="家庭科室", prefers_double=True), + SubjectMeta("体育", "体", room_type="体育館"), + SubjectMeta("特別の教科 道徳", "道"), + SubjectMeta("特別活動", "特"), + SubjectMeta("総合的な学習の時間", "総"), + SubjectMeta("外国語活動", "外活"), + SubjectMeta("外国語", "外", is_core=True), + # 中学校 + SubjectMeta("数学", "数", is_core=True), + SubjectMeta("美術", "美", room_type="美術室", prefers_double=True), + SubjectMeta("保健体育", "保体", room_type="体育館"), + SubjectMeta("技術・家庭", "技家", room_type="技術室", prefers_double=True), + # 高等学校 + SubjectMeta("現代の国語", "現国", is_core=True), + SubjectMeta("言語文化", "言文", is_core=True), + SubjectMeta("論理国語", "論国", is_core=True), + SubjectMeta("文学国語", "文国", is_core=True), + SubjectMeta("古典探究", "古探", is_core=True), + SubjectMeta("地理総合", "地総", is_core=True), + SubjectMeta("歴史総合", "歴総", is_core=True), + SubjectMeta("地理探究", "地探", is_core=True), + SubjectMeta("日本史探究", "日探", is_core=True), + SubjectMeta("世界史探究", "世探", is_core=True), + SubjectMeta("公共", "公共", is_core=True), + SubjectMeta("数学Ⅰ", "数I", is_core=True), + SubjectMeta("数学Ⅱ", "数II", is_core=True), + SubjectMeta("数学Ⅲ", "数III", is_core=True), + SubjectMeta("数学A", "数A", is_core=True), + SubjectMeta("数学B", "数B", is_core=True), + SubjectMeta("化学基礎", "化基", room_type="理科室", prefers_double=True), + SubjectMeta("生物基礎", "生基", room_type="理科室", prefers_double=True), + SubjectMeta("物理基礎", "物基", room_type="理科室", prefers_double=True), + SubjectMeta("化学", "化", room_type="理科室", prefers_double=True), + SubjectMeta("生物", "生", room_type="理科室", prefers_double=True), + SubjectMeta("物理", "物", room_type="理科室", prefers_double=True), + SubjectMeta("体育", "体育", room_type="体育館"), + SubjectMeta("保健", "保健"), + SubjectMeta("音楽Ⅰ", "音I", room_type="音楽室"), + SubjectMeta("英語コミュニケーションⅠ", "英CI", is_core=True), + SubjectMeta("英語コミュニケーションⅡ", "英CII", is_core=True), + SubjectMeta("英語コミュニケーションⅢ", "英CIII", is_core=True), + SubjectMeta("論理・表現Ⅰ", "論表I", is_core=True), + SubjectMeta("論理・表現Ⅱ", "論表II", is_core=True), + SubjectMeta("論理・表現Ⅲ", "論表III", is_core=True), + SubjectMeta("家庭基礎", "家基", room_type="家庭科室", prefers_double=True), + SubjectMeta("情報Ⅰ", "情I", room_type="PC教室", prefers_double=True), + SubjectMeta("総合的な探究の時間", "探究"), + ] +} + +# --------------------------------------------------------------------------- +# 標準授業時数(年間) +# --------------------------------------------------------------------------- + +ELEMENTARY_ANNUAL_HOURS: dict[int, dict[str, int]] = { + 1: {"国語": 306, "算数": 136, "生活": 102, "音楽": 68, "図画工作": 68, "体育": 102, + "特別の教科 道徳": 34, "特別活動": 34}, + 2: {"国語": 315, "算数": 175, "生活": 105, "音楽": 70, "図画工作": 70, "体育": 105, + "特別の教科 道徳": 35, "特別活動": 35}, + 3: {"国語": 245, "社会": 70, "算数": 175, "理科": 90, "音楽": 60, "図画工作": 60, + "体育": 105, "特別の教科 道徳": 35, "特別活動": 35, "総合的な学習の時間": 70, + "外国語活動": 35}, + 4: {"国語": 245, "社会": 90, "算数": 175, "理科": 105, "音楽": 60, "図画工作": 60, + "体育": 105, "特別の教科 道徳": 35, "特別活動": 35, "総合的な学習の時間": 70, + "外国語活動": 35}, + 5: {"国語": 175, "社会": 100, "算数": 175, "理科": 105, "音楽": 50, "図画工作": 50, + "家庭": 60, "体育": 90, "特別の教科 道徳": 35, "特別活動": 35, + "総合的な学習の時間": 70, "外国語": 70}, + 6: {"国語": 175, "社会": 105, "算数": 175, "理科": 105, "音楽": 50, "図画工作": 50, + "家庭": 55, "体育": 90, "特別の教科 道徳": 35, "特別活動": 35, + "総合的な学習の時間": 70, "外国語": 70}, +} +ELEMENTARY_TOTAL_HOURS = {1: 850, 2: 910, 3: 980, 4: 1015, 5: 1015, 6: 1015} +ELEMENTARY_WEEKS = {1: 34, 2: 35, 3: 35, 4: 35, 5: 35, 6: 35} + +JUNIOR_ANNUAL_HOURS: dict[int, dict[str, int]] = { + 1: {"国語": 140, "社会": 105, "数学": 140, "理科": 105, "音楽": 45, "美術": 45, + "保健体育": 105, "技術・家庭": 70, "外国語": 140, "特別の教科 道徳": 35, + "総合的な学習の時間": 50, "特別活動": 35}, + 2: {"国語": 140, "社会": 105, "数学": 105, "理科": 140, "音楽": 35, "美術": 35, + "保健体育": 105, "技術・家庭": 70, "外国語": 140, "特別の教科 道徳": 35, + "総合的な学習の時間": 70, "特別活動": 35}, + 3: {"国語": 105, "社会": 140, "数学": 140, "理科": 140, "音楽": 35, "美術": 35, + "保健体育": 105, "技術・家庭": 35, "外国語": 140, "特別の教科 道徳": 35, + "総合的な学習の時間": 70, "特別活動": 35}, +} +JUNIOR_TOTAL_HOURS = {1: 1015, 2: 1015, 3: 1015} +JUNIOR_WEEKS = {1: 35, 2: 35, 3: 35} + +# 高等学校は単位数=週コマ数。週30単位時間の標準に合わせて必履修中心に構成した。 +HIGH_WEEKLY: dict[int, dict[str, int]] = { + 1: {"現代の国語": 2, "言語文化": 2, "地理総合": 2, "歴史総合": 2, "数学Ⅰ": 3, + "数学A": 2, "化学基礎": 2, "生物基礎": 2, "体育": 3, "保健": 1, "音楽Ⅰ": 2, + "英語コミュニケーションⅠ": 3, "論理・表現Ⅰ": 2, "情報Ⅰ": 2}, + 2: {"論理国語": 4, "古典探究": 3, "公共": 2, "数学Ⅱ": 4, "数学B": 2, "体育": 2, + "保健": 1, "英語コミュニケーションⅡ": 4, "論理・表現Ⅱ": 2, "家庭基礎": 2, + "総合的な探究の時間": 1}, + 3: {"文学国語": 4, "古典探究": 3, "数学Ⅲ": 3, "体育": 3, + "英語コミュニケーションⅢ": 4, "論理・表現Ⅲ": 2, "総合的な探究の時間": 2}, +} + +# 高等学校の選択科目群。学年 -> [(群名, 週コマ数, [科目...])] +# 同一時間帯に並列開講し、生徒がどれか1つを選ぶ。 +HIGH_ELECTIVES: dict[int, list[tuple[str, int, list[str]]]] = { + 1: [], + 2: [("2年 理科選択", 3, ["物理基礎", "化学基礎", "生物基礎"])], + 3: [ + ("3年 地歴選択", 4, ["日本史探究", "世界史探究", "地理探究"]), + ("3年 理科選択", 5, ["物理", "化学", "生物"]), + ], +} + +# --------------------------------------------------------------------------- +# 週コマ数の導出 +# --------------------------------------------------------------------------- + + +def annual_to_weekly(annual: dict[str, int], weeks: int, total_hours: int) -> dict[str, int]: + """年間授業時数を週コマ数に配分する。 + + 単純に四捨五入すると合計が総授業時数と合わなくなるため、最大剰余法 + (各教科の小数部が大きい順に1コマずつ配る)で合計を一致させる(仮定 A-14)。 + """ + target = round(total_hours / weeks) + exact = {name: hours / weeks for name, hours in annual.items()} + base = {name: int(value) for name, value in exact.items()} + remaining = target - sum(base.values()) + order = sorted(exact, key=lambda n: (-(exact[n] - base[n]), n)) + for name in order: + if remaining <= 0: + break + base[name] += 1 + remaining -= 1 + # 0コマになる教科が出ないよう最低1コマを保証する + for name in base: + if base[name] == 0: + base[name] = 1 + return base + + +def weekly_periods_for(school_type: str, grade: int) -> dict[str, int]: + if school_type == ELEMENTARY: + return annual_to_weekly( + ELEMENTARY_ANNUAL_HOURS[grade], + ELEMENTARY_WEEKS[grade], + ELEMENTARY_TOTAL_HOURS[grade], + ) + if school_type == JUNIOR: + return annual_to_weekly( + JUNIOR_ANNUAL_HOURS[grade], JUNIOR_WEEKS[grade], JUNIOR_TOTAL_HOURS[grade] + ) + if school_type == HIGH: + return dict(HIGH_WEEKLY[grade]) + raise ValueError(f"unknown school type: {school_type}") + + +def total_weekly_periods(school_type: str, grade: int) -> int: + """選択科目群を含めた週の総コマ数。""" + total = sum(weekly_periods_for(school_type, grade).values()) + if school_type == HIGH: + total += sum(n for _, n, _ in HIGH_ELECTIVES.get(grade, [])) + return total + + +# --------------------------------------------------------------------------- +# 教員配置の実態 +# --------------------------------------------------------------------------- + +# 小学校高学年の専科指導の優先教科(文部科学省)。値は専科教員1人あたりの担当学級数(仮定 A-16) +ELEMENTARY_SPECIALIST_SUBJECTS = {"外国語": 4, "理科": 4, "算数": 6, "体育": 6} +# 専科指導は高学年から導入されている +ELEMENTARY_SPECIALIST_GRADES = {5, 6} + +# 中学校・高等学校の教科担任1人あたりの担当学級数(仮定 A-16) +JUNIOR_CLASSES_PER_TEACHER = { + "国語": 4, "社会": 5, "数学": 4, "理科": 4, "音楽": 8, "美術": 8, + "保健体育": 6, "技術・家庭": 8, "外国語": 4, +} +HIGH_CLASSES_PER_TEACHER_DEFAULT = 4 +HIGH_CLASSES_PER_TEACHER = { + "体育": 6, "保健": 8, "音楽Ⅰ": 8, "情報Ⅰ": 8, "家庭基礎": 8, + "総合的な探究の時間": 8, +} + +# 学級担任が担当する教科(教科担任制の校種でも担任が受け持つもの) +HOMEROOM_SUBJECTS = {"特別の教科 道徳", "特別活動", "総合的な学習の時間", "総合的な探究の時間"} + +# 特別教室の既定保有数(仮定 A-18) +DEFAULT_ROOMS = { + ELEMENTARY: {"理科室": 1, "音楽室": 1, "図工室": 1, "家庭科室": 1, "体育館": 1}, + JUNIOR: {"理科室": 2, "音楽室": 1, "美術室": 1, "技術室": 1, "体育館": 1}, + HIGH: {"理科室": 3, "音楽室": 1, "家庭科室": 1, "PC教室": 1, "体育館": 2}, +} diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..c874995 --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,36 @@ +"""DB接続。 + +仮定 A-05: マイグレーションは導入せず、起動時に create_all でスキーマを作る。 +""" + +from __future__ import annotations + +import os +from collections.abc import Iterator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from .models import Base + +DATABASE_URL = os.environ.get("TIMETABLE_DATABASE_URL", "sqlite:///./timetable.db") + +engine = create_engine( + DATABASE_URL, + # SQLite は既定で作成スレッド以外からの利用を禁じるが、求解ジョブを別スレッドで + # 走らせるため解除する(仮定 A-06) + connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}, +) +SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + + +def init_db() -> None: + Base.metadata.create_all(engine) + + +def get_db() -> Iterator[Session]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/app/jobs.py b/backend/app/jobs.py new file mode 100644 index 0000000..42b692a --- /dev/null +++ b/backend/app/jobs.py @@ -0,0 +1,194 @@ +"""求解ジョブ。 + +要件書 2.1 の実測どおり、高校規模では求解に25秒かかることがあり、HTTPリクエストの +中で待たせられない。一方で必要なのは「リクエストの外に出すこと」だけなので、 +分散ワーカーは使わずスレッドプールとプロセス内のジョブ表で済ませる(仮定 A-06)。 +""" + +from __future__ import annotations + +import threading +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from typing import Literal + +from . import models as m +from . import services +from .db import SessionLocal +from .solver.engine import DEFAULT_TIME_LIMIT_SEC, LockConflictError, solve + +JobStatus = Literal["queued", "running", "succeeded", "failed", "cancelled"] + + +@dataclass +class SolveJob: + id: str + school_id: int + source_timetable_id: int | None + time_limit_sec: int + status: JobStatus = "queued" + created_at: float = field(default_factory=time.time) + started_at: float | None = None + finished_at: float | None = None + timetable_id: int | None = None + error: str | None = None + violations: list[dict] = field(default_factory=list) + unmet_periods: int | None = None + total_periods: int | None = None + solver_status: str | None = None + timed_out: bool = False + stats: dict = field(default_factory=dict) + + @property + def elapsed_sec(self) -> float: + if self.started_at is None: + return 0.0 + end = self.finished_at or time.time() + return end - self.started_at + + def as_dict(self) -> dict: + return { + "id": self.id, + "school_id": self.school_id, + "status": self.status, + "elapsed_sec": round(self.elapsed_sec, 2), + "timetable_id": self.timetable_id, + "error": self.error, + "violations": self.violations, + "unmet_periods": self.unmet_periods, + "total_periods": self.total_periods, + "solver_status": self.solver_status, + "timed_out": self.timed_out, + "stats": self.stats, + } + + +class JobRegistry: + def __init__(self, max_workers: int = 2) -> None: + self._jobs: dict[str, SolveJob] = {} + self._cancelled: set[str] = set() + self._lock = threading.Lock() + self._pool = ThreadPoolExecutor(max_workers=max_workers) + + def get(self, job_id: str) -> SolveJob | None: + with self._lock: + return self._jobs.get(job_id) + + def list(self) -> list[SolveJob]: + with self._lock: + return sorted(self._jobs.values(), key=lambda j: j.created_at, reverse=True) + + def cancel(self, job_id: str) -> bool: + """キャンセルする。 + + CBC の求解自体は中断できないため、結果を保存しないことで実現する(仮定 A-22)。 + """ + with self._lock: + job = self._jobs.get(job_id) + if job is None or job.status in ("succeeded", "failed", "cancelled"): + return False + self._cancelled.add(job_id) + if job.status == "queued": + job.status = "cancelled" + job.finished_at = time.time() + return True + + def submit( + self, + school_id: int, + source_timetable_id: int | None, + time_limit_sec: int = DEFAULT_TIME_LIMIT_SEC, + name: str | None = None, + ) -> SolveJob: + job = SolveJob( + id=uuid.uuid4().hex[:12], + school_id=school_id, + source_timetable_id=source_timetable_id, + time_limit_sec=time_limit_sec, + ) + with self._lock: + self._jobs[job.id] = job + self._pool.submit(self._run, job, name) + return job + + def _is_cancelled(self, job_id: str) -> bool: + with self._lock: + return job_id in self._cancelled + + def _run(self, job: SolveJob, name: str | None) -> None: + if self._is_cancelled(job.id): + return + job.status = "running" + job.started_at = time.time() + db = SessionLocal() + try: + school = db.get(m.School, job.school_id) + if school is None: + raise ValueError("学校が見つかりません") + source = ( + db.get(m.Timetable, job.source_timetable_id) + if job.source_timetable_id + else None + ) + spec = services.build_spec(db, school, source) + result = solve(spec, time_limit_sec=job.time_limit_sec) + + if self._is_cancelled(job.id): + job.status = "cancelled" + job.finished_at = time.time() + return + + timetable = m.Timetable( + school_id=school.id, + name=name or _default_name(db, school), + parent_id=source.id if source else None, + ) + db.add(timetable) + db.flush() + # 新しい版では、元の版で固定されていた割当のロックを引き継ぐ + if source is not None: + locked = {(a.course_id, a.day, a.period) for a in source.assignments if a.locked} + else: + locked = set() + for pl in result.placements: + db.add( + m.Assignment( + timetable_id=timetable.id, + course_id=pl.course_id, + day=pl.day, + period=pl.period, + locked=(pl.course_id, pl.day, pl.period) in locked, + ) + ) + db.commit() + + job.timetable_id = timetable.id + job.violations = [v.as_dict() for v in result.violations] + job.unmet_periods = result.unmet_periods + job.total_periods = result.total_periods + job.solver_status = result.status + job.timed_out = result.timed_out + job.stats = result.stats + job.status = "succeeded" + except LockConflictError as exc: + db.rollback() + job.status = "failed" + job.error = "固定された割当に矛盾があるため求解できません。固定を外してください。" + job.violations = [v.as_dict() for v in exc.violations] + except Exception as exc: # noqa: BLE001 - ジョブの失敗理由をそのまま伝える + db.rollback() + job.status = "failed" + job.error = str(exc) + finally: + job.finished_at = time.time() + db.close() + + +def _default_name(db, school: m.School) -> str: + count = db.query(m.Timetable).filter(m.Timetable.school_id == school.id).count() + return f"提案 v{count + 1}" + + +registry = JobRegistry() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..b775ac0 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,43 @@ +"""FastAPI アプリケーション。""" + +from __future__ import annotations + +import os + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .api import router +from .db import init_db + +app = FastAPI( + title="時間割自動編成API", + description=( + "日本の小学校・中学校・高等学校の週時間割を整数計画法で自動提案し、" + "人が微調整できるようにするAPI。" + ), + version="1.0.0", +) + +# 仮定 A-23: 開発とE2Eの利便のため CORS を全許可にしている。 +# 公開運用する場合は TIMETABLE_CORS_ORIGINS で明示的に絞ること。 +_origins = os.environ.get("TIMETABLE_CORS_ORIGINS", "*") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"] if _origins == "*" else [o.strip() for o in _origins.split(",")], + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(router) + + +@app.on_event("startup") +def on_startup() -> None: + init_db() + + +@app.get("/health") +def health() -> dict: + return {"status": "ok"} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..244a537 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,218 @@ +"""SQLAlchemy モデル。 + +割当の単位は「学級×教科」ではなく Course(講座)である(仮定 A-02)。 +Course は担当教員1名と受講学級1つ以上を持ち、小中では 1学級=1講座 に縮退する。 +高校の選択科目の同時展開と、特別支援学級の交流及び共同学習は、 +どちらも ParallelGroup(同時展開群)で表現する。 +""" + +from __future__ import annotations + +import datetime as dt + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + String, + Table, + UniqueConstraint, +) +from sqlalchemy.orm import DeclarativeBase, relationship + + +class Base(DeclarativeBase): + pass + + +# 講座と受講学級の多対多。高校の選択科目では1講座を複数学級の生徒が受講する。 +course_class = Table( + "course_class", + Base.metadata, + Column("course_id", ForeignKey("courses.id", ondelete="CASCADE"), primary_key=True), + Column("class_id", ForeignKey("school_classes.id", ondelete="CASCADE"), primary_key=True), +) + +parallel_group_course = Table( + "parallel_group_course", + Base.metadata, + Column("group_id", ForeignKey("parallel_groups.id", ondelete="CASCADE"), primary_key=True), + Column("course_id", ForeignKey("courses.id", ondelete="CASCADE"), primary_key=True), +) + + +class School(Base): + __tablename__ = "schools" + + id = Column(Integer, primary_key=True) + name = Column(String, nullable=False) + # "elementary" | "junior" | "high" + school_type = Column(String, nullable=False) + days = Column(Integer, nullable=False, default=5) + periods_per_day = Column(Integer, nullable=False, default=6) + # 表示用。求解には使わない(仮定 A-08) + period_minutes = Column(Integer, nullable=False, default=50) + + classes = relationship("SchoolClass", back_populates="school", cascade="all, delete-orphan") + teachers = relationship("Teacher", back_populates="school", cascade="all, delete-orphan") + subjects = relationship("Subject", back_populates="school", cascade="all, delete-orphan") + courses = relationship("Course", back_populates="school", cascade="all, delete-orphan") + rooms = relationship("Room", back_populates="school", cascade="all, delete-orphan") + timetables = relationship("Timetable", back_populates="school", cascade="all, delete-orphan") + blocked_slots = relationship("BlockedSlot", cascade="all, delete-orphan") + parallel_groups = relationship("ParallelGroup", cascade="all, delete-orphan") + + +class SchoolClass(Base): + __tablename__ = "school_classes" + + id = Column(Integer, primary_key=True) + school_id = Column(ForeignKey("schools.id", ondelete="CASCADE"), nullable=False) + grade = Column(Integer, nullable=False) + name = Column(String, nullable=False) + # "normal" | "special_needs" + kind = Column(String, nullable=False, default="normal") + + school = relationship("School", back_populates="classes") + + +class Teacher(Base): + __tablename__ = "teachers" + + id = Column(Integer, primary_key=True) + school_id = Column(ForeignKey("schools.id", ondelete="CASCADE"), nullable=False) + name = Column(String, nullable=False) + # "homeroom"(学級担任) | "specialist"(専科) | "subject"(教科担任) | "part_time"(非常勤) + kind = Column(String, nullable=False, default="subject") + # 週の持ちコマ数上限。既定18は令和4年度教員勤務実態調査の平均18.1コマに基づく + max_weekly_periods = Column(Integer, nullable=False, default=18) + + school = relationship("School", back_populates="teachers") + unavailabilities = relationship( + "TeacherUnavailability", back_populates="teacher", cascade="all, delete-orphan" + ) + + +class TeacherUnavailability(Base): + """教員の不在枠。非常勤講師の勤務曜日制限、出張、育休の代替等を表す。""" + + __tablename__ = "teacher_unavailabilities" + __table_args__ = (UniqueConstraint("teacher_id", "day", "period"),) + + id = Column(Integer, primary_key=True) + teacher_id = Column(ForeignKey("teachers.id", ondelete="CASCADE"), nullable=False) + day = Column(Integer, nullable=False) + period = Column(Integer, nullable=False) + + teacher = relationship("Teacher", back_populates="unavailabilities") + + +class Subject(Base): + __tablename__ = "subjects" + + id = Column(Integer, primary_key=True) + school_id = Column(ForeignKey("schools.id", ondelete="CASCADE"), nullable=False) + name = Column(String, nullable=False) + short_name = Column(String, nullable=False) + # 主要教科は午前に寄せる(ソフト制約 S9) + is_core = Column(Boolean, nullable=False, default=False) + # この教科が必要とする特別教室の種別。None は普通教室 + room_type = Column(String, nullable=True) + + school = relationship("School", back_populates="subjects") + + +class Room(Base): + """特別教室。個々の教室を割り当てるのではなく、種別ごとの同時使用数を制限する(仮定 A-10)。""" + + __tablename__ = "rooms" + + id = Column(Integer, primary_key=True) + school_id = Column(ForeignKey("schools.id", ondelete="CASCADE"), nullable=False) + name = Column(String, nullable=False) + room_type = Column(String, nullable=False) + + school = relationship("School", back_populates="rooms") + + +class Course(Base): + """講座。時間割のセルに入る単位。""" + + __tablename__ = "courses" + + id = Column(Integer, primary_key=True) + school_id = Column(ForeignKey("schools.id", ondelete="CASCADE"), nullable=False) + subject_id = Column(ForeignKey("subjects.id", ondelete="CASCADE"), nullable=False) + teacher_id = Column(ForeignKey("teachers.id", ondelete="CASCADE"), nullable=False) + weekly_periods = Column(Integer, nullable=False) + # 1日に同じ講座を置ける上限。週5コマ超の教科は2を既定にする + max_per_day = Column(Integer, nullable=False, default=1) + # 2コマ連続を優先する(実験・実習・体育など。ソフト制約 S8) + prefers_double = Column(Boolean, nullable=False, default=False) + + school = relationship("School", back_populates="courses") + subject = relationship("Subject") + teacher = relationship("Teacher") + classes = relationship("SchoolClass", secondary=course_class) + + +class ParallelGroup(Base): + """同時展開群。群に属する講座は同一のコマに配置される。 + + 高校の選択科目(S4)と、特別支援学級の交流及び共同学習(S5)の両方を表す。 + """ + + __tablename__ = "parallel_groups" + + id = Column(Integer, primary_key=True) + school_id = Column(ForeignKey("schools.id", ondelete="CASCADE"), nullable=False) + name = Column(String, nullable=False) + + courses = relationship("Course", secondary=parallel_group_course) + + +class BlockedSlot(Base): + """配置禁止枠。class_id が None なら全学級に適用する。""" + + __tablename__ = "blocked_slots" + + id = Column(Integer, primary_key=True) + school_id = Column(ForeignKey("schools.id", ondelete="CASCADE"), nullable=False) + class_id = Column(ForeignKey("school_classes.id", ondelete="CASCADE"), nullable=True) + day = Column(Integer, nullable=False) + period = Column(Integer, nullable=False) + reason = Column(String, nullable=False, default="") + + +class Timetable(Base): + """時間割のバージョン。提案→手編集→再提案の履歴を親子関係で保持する。""" + + __tablename__ = "timetables" + + id = Column(Integer, primary_key=True) + school_id = Column(ForeignKey("schools.id", ondelete="CASCADE"), nullable=False) + name = Column(String, nullable=False) + parent_id = Column(ForeignKey("timetables.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=dt.datetime.utcnow, nullable=False) + + school = relationship("School", back_populates="timetables") + assignments = relationship( + "Assignment", back_populates="timetable", cascade="all, delete-orphan" + ) + + +class Assignment(Base): + __tablename__ = "assignments" + + id = Column(Integer, primary_key=True) + timetable_id = Column(ForeignKey("timetables.id", ondelete="CASCADE"), nullable=False) + course_id = Column(ForeignKey("courses.id", ondelete="CASCADE"), nullable=False) + day = Column(Integer, nullable=False) + period = Column(Integer, nullable=False) + # 固定(ロック)。再提案時にこの割当は動かさない + locked = Column(Boolean, nullable=False, default=False) + + timetable = relationship("Timetable", back_populates="assignments") + course = relationship("Course") diff --git a/backend/app/services.py b/backend/app/services.py new file mode 100644 index 0000000..172c245 --- /dev/null +++ b/backend/app/services.py @@ -0,0 +1,379 @@ +"""DBモデルと求解エンジンの間をつなぐサービス層。""" + +from __future__ import annotations + +import math + +from sqlalchemy.orm import Session + +from . import curriculum as cur +from . import models as m +from .solver.spec import ( + CourseSpec, + ParallelGroupSpec, + Placement, + ProblemSpec, + TeacherSpec, +) + +# 仮定 A-19: 小学校の学級担任は1日の大半を自学級で教えるため、持ちコマ上限を週29コマとする。 +# 教科担任・専科は令和4年度教員勤務実態調査の平均18.1コマに合わせて18コマ。 +HOMEROOM_MAX_PERIODS_ELEMENTARY = 29 +DEFAULT_MAX_PERIODS = 18 + +MAX_CLASSES = 36 # 要件書 2.1: サポート規模の上限 + + +class GenerationError(ValueError): + pass + + +# --------------------------------------------------------------------------- +# 学校の生成 +# --------------------------------------------------------------------------- + + +def generate_school( + db: Session, + name: str, + school_type: str, + classes_per_grade: int, + include_special_needs: bool = False, +) -> m.School: + """校種と1学年あたりの学級数から、学校一式(学級・教員・教科・講座)を生成する。 + + 生成物はあくまで出発点であり、利用者が画面から編集することを前提にしている。 + """ + if school_type not in cur.GRADES: + raise GenerationError(f"未知の校種です: {school_type}") + grades = cur.GRADES[school_type] + if classes_per_grade < 1: + raise GenerationError("1学年あたりの学級数は1以上にしてください") + total_classes = classes_per_grade * len(grades) + (1 if include_special_needs else 0) + if total_classes > MAX_CLASSES: + raise GenerationError( + f"学級数が上限({MAX_CLASSES}学級)を超えます: {total_classes}学級" + ) + + school = m.School( + name=name, + school_type=school_type, + days=5, + periods_per_day=cur.PERIODS_PER_DAY[school_type], + period_minutes=cur.PERIOD_MINUTES[school_type], + ) + db.add(school) + db.flush() + + subjects: dict[str, m.Subject] = {} + + def subject_of(subject_name: str) -> m.Subject: + if subject_name not in subjects: + meta = cur.SUBJECT_META[subject_name] + s = m.Subject( + school_id=school.id, + name=meta.name, + short_name=meta.short_name, + is_core=meta.is_core, + room_type=meta.room_type, + ) + db.add(s) + db.flush() + subjects[subject_name] = s + return subjects[subject_name] + + # 学級 + classes: list[m.SchoolClass] = [] + for grade in grades: + for i in range(classes_per_grade): + c = m.SchoolClass( + school_id=school.id, grade=grade, name=f"{grade}年{i + 1}組", kind="normal" + ) + db.add(c) + classes.append(c) + special_class = None + if include_special_needs: + special_class = m.SchoolClass( + school_id=school.id, grade=grades[0], name="特別支援学級", kind="special_needs" + ) + db.add(special_class) + db.flush() + + # 学級担任 + homeroom_max = ( + HOMEROOM_MAX_PERIODS_ELEMENTARY + if school_type == cur.ELEMENTARY + else DEFAULT_MAX_PERIODS + ) + homeroom: dict[int, m.Teacher] = {} + for c in classes + ([special_class] if special_class else []): + # 特別支援学級の担任は校種によらず全教科を担当するため、上限を高く取る + limit = ( + HOMEROOM_MAX_PERIODS_ELEMENTARY if c.kind == "special_needs" else homeroom_max + ) + t = m.Teacher( + school_id=school.id, + name=f"{c.name} 担任", + kind="homeroom", + max_weekly_periods=limit, + ) + db.add(t) + db.flush() + homeroom[c.id] = t + + # 教科担任・専科。担当学級数ごとに1人を割り当てる + subject_teachers: dict[tuple[str, int], m.Teacher] = {} + + def classes_per_teacher(configured: int, weekly_periods: int) -> int: + """担当学級数を持ちコマ上限で頭打ちにする。 + + 例えば算数専科を6学級担当にすると週30コマとなり、上限18コマを必ず超えて + しまう。その状態ではソルバーが必ず S3 違反を出す解しか作れない(仮定 A-26)。 + """ + if weekly_periods <= 0: + return configured + return max(1, min(configured, DEFAULT_MAX_PERIODS // weekly_periods)) + + def teacher_for(subject_name: str, class_index: int, per: int, kind: str) -> m.Teacher: + bucket = class_index // max(1, per) + key = (subject_name, bucket) + if key not in subject_teachers: + label = "専科" if kind == "specialist" else "教諭" + t = m.Teacher( + school_id=school.id, + name=f"{subject_name} {label}{bucket + 1}", + kind=kind, + max_weekly_periods=DEFAULT_MAX_PERIODS, + ) + db.add(t) + db.flush() + subject_teachers[key] = t + return subject_teachers[key] + + def pick_teacher( + subject_name: str, klass: m.SchoolClass, class_index: int, periods: int + ) -> m.Teacher: + if subject_name in cur.HOMEROOM_SUBJECTS: + return homeroom[klass.id] + if school_type == cur.ELEMENTARY: + configured = cur.ELEMENTARY_SPECIALIST_SUBJECTS.get(subject_name) + if configured and klass.grade in cur.ELEMENTARY_SPECIALIST_GRADES: + per = classes_per_teacher(configured, periods) + return teacher_for(subject_name, class_index, per, "specialist") + return homeroom[klass.id] + if school_type == cur.JUNIOR: + configured = cur.JUNIOR_CLASSES_PER_TEACHER.get(subject_name, 4) + else: + configured = cur.HIGH_CLASSES_PER_TEACHER.get( + subject_name, cur.HIGH_CLASSES_PER_TEACHER_DEFAULT + ) + return teacher_for( + subject_name, class_index, classes_per_teacher(configured, periods), "subject" + ) + + def make_course( + subject_name: str, teacher: m.Teacher, klasses: list[m.SchoolClass], periods: int + ) -> m.Course: + meta = cur.SUBJECT_META[subject_name] + prefers_double = meta.prefers_double and periods >= 2 + course = m.Course( + school_id=school.id, + subject_id=subject_of(subject_name).id, + teacher_id=teacher.id, + weekly_periods=periods, + # 週5コマを超える教科は1日2コマまで許容しないと週内に収まらない。 + # 2コマ連続を望む教科(実験・実習)も、1日1コマ制限のままだと + # S8 の連続配置と S2 が矛盾してしまうため2コマまで許容する + max_per_day=2 if (periods > 5 or prefers_double) else 1, + prefers_double=prefers_double, + ) + course.classes = list(klasses) + db.add(course) + db.flush() + return course + + # 通常学級の講座 + course_by_class_subject: dict[tuple[int, str], m.Course] = {} + for idx, klass in enumerate(classes): + weekly = cur.weekly_periods_for(school_type, klass.grade) + for subject_name, periods in weekly.items(): + teacher = pick_teacher(subject_name, klass, idx, periods) + course = make_course(subject_name, teacher, [klass], periods) + course_by_class_subject[(klass.id, subject_name)] = course + + # 高等学校の選択科目群。学級ごとに並列開講する + if school_type == cur.HIGH: + for idx, klass in enumerate(classes): + for group_name, periods, subject_names in cur.HIGH_ELECTIVES.get(klass.grade, []): + group = m.ParallelGroup( + school_id=school.id, name=f"{klass.name} {group_name}" + ) + db.add(group) + db.flush() + members = [] + for subject_name in subject_names: + teacher = teacher_for( + subject_name, + idx, + classes_per_teacher(cur.HIGH_CLASSES_PER_TEACHER_DEFAULT, periods), + "subject", + ) + members.append(make_course(subject_name, teacher, [klass], periods)) + group.courses = members + + # 特別支援学級。第1学年の教育課程を用いる。 + # 交流及び共同学習の教科は、別講座を作って同時展開させるのではなく、 + # 通常学級の講座に特別支援学級を追加する(=同じ授業に参加する)。 + # 実態に即しているうえ、教室も教員も二重に数えなくて済む(仮定 A-28)。 + if special_class is not None: + weekly = cur.weekly_periods_for(school_type, grades[0]) + exchange_subjects = {"体育", "保健体育", "音楽", "図画工作", "美術"} + for subject_name, periods in weekly.items(): + partner = course_by_class_subject.get((classes[0].id, subject_name)) + if subject_name in exchange_subjects and partner is not None: + partner.classes = list(partner.classes) + [special_class] + db.flush() + continue + make_course(subject_name, homeroom[special_class.id], [special_class], periods) + + _create_rooms_for_demand(db, school) + + db.commit() + db.refresh(school) + return school + + +# 特別教室の稼働率の目標。1.0(=需要ぴったり)にすると、その教室を使う授業が +# 全コマ埋まっていないと成立しなくなり、他の制約と両立しなくなる(仮定 A-25) +ROOM_UTILISATION_TARGET = 0.75 + + +def _create_rooms_for_demand(db: Session, school: m.School) -> None: + """特別教室を、その教科の週コマ数の合計(需要)から必要数だけ用意する。 + + 固定値にすると、19学級の小学校で理科室1室のような物理的に配置不能な設定に + なってしまう(理科だけで週36コマ必要なのに枠は週30コマしかない)。 + """ + slots = school.days * school.periods_per_day + demand: dict[str, int] = {} + courses = db.query(m.Course).filter(m.Course.school_id == school.id).all() + for c in courses: + room_type = c.subject.room_type + if room_type: + demand[room_type] = demand.get(room_type, 0) + c.weekly_periods + baseline = cur.DEFAULT_ROOMS[school.school_type] + for room_type in sorted(set(demand) | set(baseline)): + need = demand.get(room_type, 0) + count = max( + baseline.get(room_type, 1), + math.ceil(need / (slots * ROOM_UTILISATION_TARGET)) if need else 1, + ) + for i in range(count): + suffix = f"{i + 1}" if count > 1 else "" + db.add( + m.Room(school_id=school.id, name=f"{room_type}{suffix}", room_type=room_type) + ) + db.flush() + + +# --------------------------------------------------------------------------- +# ProblemSpec の構築 +# --------------------------------------------------------------------------- + + +def build_spec(db: Session, school: m.School, timetable: m.Timetable | None = None) -> ProblemSpec: + """DBの状態から求解・検証の入力を作る。 + + timetable を渡すと、その時間割で固定(ロック)された割当を制約に含める。 + """ + courses = db.query(m.Course).filter(m.Course.school_id == school.id).all() + teachers = db.query(m.Teacher).filter(m.Teacher.school_id == school.id).all() + classes = db.query(m.SchoolClass).filter(m.SchoolClass.school_id == school.id).all() + rooms = db.query(m.Room).filter(m.Room.school_id == school.id).all() + blocked = db.query(m.BlockedSlot).filter(m.BlockedSlot.school_id == school.id).all() + groups = db.query(m.ParallelGroup).filter(m.ParallelGroup.school_id == school.id).all() + + room_capacity: dict[str, int] = {} + for room in rooms: + room_capacity[room.room_type] = room_capacity.get(room.room_type, 0) + 1 + + teacher_specs = { + t.id: TeacherSpec( + id=t.id, + name=t.name, + max_weekly_periods=t.max_weekly_periods, + unavailable=frozenset((u.day, u.period) for u in t.unavailabilities), + ) + for t in teachers + } + + course_specs = {} + for c in courses: + class_names = "・".join(k.name for k in c.classes) + course_specs[c.id] = CourseSpec( + id=c.id, + label=f"{class_names} {c.subject.name}", + subject_id=c.subject_id, + subject_name=c.subject.name, + is_core=bool(c.subject.is_core), + room_type=c.subject.room_type, + teacher_id=c.teacher_id, + class_ids=tuple(k.id for k in c.classes), + weekly_periods=c.weekly_periods, + max_per_day=c.max_per_day, + prefers_double=bool(c.prefers_double), + ) + + locked: set[Placement] = set() + if timetable is not None: + for a in timetable.assignments: + if a.locked: + locked.add(Placement(course_id=a.course_id, day=a.day, period=a.period)) + + return ProblemSpec( + days=school.days, + periods_per_day=school.periods_per_day, + courses=course_specs, + teachers=teacher_specs, + class_names={k.id: k.name for k in classes}, + room_capacity=room_capacity, + blocked=frozenset((b.class_id, b.day, b.period) for b in blocked), + parallel_groups=tuple( + ParallelGroupSpec(id=g.id, name=g.name, course_ids=tuple(c.id for c in g.courses)) + for g in groups + ), + locked=frozenset(locked), + ) + + +def placements_of(timetable: m.Timetable) -> list[Placement]: + return [ + Placement(course_id=a.course_id, day=a.day, period=a.period) + for a in timetable.assignments + ] + + +def replace_assignments( + db: Session, + timetable: m.Timetable, + placements: list[Placement], + keep_locked: bool = True, +) -> None: + """時間割の割当を置き換える。固定された割当のロック状態は引き継ぐ。""" + locked_slots = { + (a.course_id, a.day, a.period) for a in timetable.assignments if a.locked + } + for a in list(timetable.assignments): + db.delete(a) + db.flush() + for pl in placements: + db.add( + m.Assignment( + timetable_id=timetable.id, + course_id=pl.course_id, + day=pl.day, + period=pl.period, + locked=keep_locked and (pl.course_id, pl.day, pl.period) in locked_slots, + ) + ) + db.flush() diff --git a/backend/app/solver/__init__.py b/backend/app/solver/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/solver/constraints.py b/backend/app/solver/constraints.py new file mode 100644 index 0000000..a4a2f69 --- /dev/null +++ b/backend/app/solver/constraints.py @@ -0,0 +1,662 @@ +"""制約の単一情報源。 + +各制約クラスが2つの顔を持つ: + +- ``apply(spec, ctx)`` … 整数計画モデルに制約と目的関数項を追加する(自動提案で使う) +- ``check(spec, placements)`` … 既存の割当を検査して違反を返す(手編集の即時検証で使う) + +両者を同じクラスに置くことで、「画面上はOKなのに再提案すると弾かれる」というズレが +構造的に起きないようにしている(要件書 2-2)。 + +ハード制約は「すべての講座を1つも配置しない解」で必ず充足できる形にしてある。 +したがってモデルは常に実行可能であり、「解が返らない」状態は起こらない。 +不足はソフト制約 S1 の未充足コマ数として必ず定量化される(仮定 A-13)。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Sequence + +import pulp + +from .spec import ( + CourseSpec, + Placement, + ProblemSpec, + Severity, + Violation, +) + +DAY_NAMES = ["月", "火", "水", "木", "金", "土", "日"] + + +def slot_label(day: int, period: int) -> str: + return f"{DAY_NAMES[day % 7]}{period + 1}限" + + +@dataclass +class LpContext: + """LP モデル組み立て用の作業領域。""" + + problem: pulp.LpProblem + # (course_id, day, period) -> 0/1 変数。配置禁止枠には変数を作らない(H4/H5 の表現) + x: dict[tuple[int, int, int], pulp.LpVariable] + # (group_id, day, period) -> 0/1 変数。同時展開群がそのコマを使うか。 + # H1(学級の重複)と S4(同時展開)が共有する。選択科目は学級から見れば + # 1つのセルなので、H1 は群を1つ分としてしか数えてはいけない。 + z: dict[tuple[int, int, int], pulp.LpVariable] = field(default_factory=dict) + objective_terms: list = field(default_factory=list) + + def var(self, course_id: int, day: int, period: int): + return self.x.get((course_id, day, period)) + + def group_var(self, group_id: int, day: int, period: int): + return self.z.get((group_id, day, period)) + + def penalty(self, name: str, weight: float, up_bound: float | None = None): + v = pulp.LpVariable(name, lowBound=0, upBound=up_bound) + self.objective_terms.append(weight * v) + return v + + def reward(self, name: str, weight: float): + # 目的関数で最大化する向きに入るため、x が二値なら連続変数でも + # 最適解では min(x_a, x_b) に一致する。二値にすると探索が重くなるだけ + v = pulp.LpVariable(name, lowBound=0, upBound=1) + self.objective_terms.append(-weight * v) + return v + + def vars_for( + self, course_ids: Sequence[int], slots: Sequence[tuple[int, int]] + ) -> list: + return [ + self.x[(cid, d, p)] + for cid in course_ids + for (d, p) in slots + if (cid, d, p) in self.x + ] + + +class Constraint: + code: str = "" + label: str = "" + severity: Severity = "hard" + weight: float = 0.0 + + def apply(self, spec: ProblemSpec, ctx: LpContext) -> None: # pragma: no cover - 抽象 + raise NotImplementedError + + def check( + self, spec: ProblemSpec, placements: Sequence[Placement] + ) -> list[Violation]: # pragma: no cover - 抽象 + raise NotImplementedError + + +# -------------------------------------------------------------------------- +# ハード制約 +# -------------------------------------------------------------------------- + + +class ClassOccupancy(Constraint): + """H1: 1つの学級の同一コマに2つ以上の講座を置かない。""" + + code = "H1" + label = "学級の重複" + severity = "hard" + + def apply(self, spec: ProblemSpec, ctx: LpContext) -> None: + grouped = spec.group_of_course + for class_id in spec.class_names: + # 同時展開群に属する講座は、学級から見ると1つのセル(選択の時間)なので + # 個々の講座ではなく群インジケータで数える + solo_ids = [ + c.id for c in spec.courses_of_class(class_id) if c.id not in grouped + ] + groups = spec.groups_of_class(class_id) + if not solo_ids and not groups: + continue + for d, p in spec.slots: + cell = ctx.vars_for(solo_ids, [(d, p)]) + cell += [ + ctx.group_var(g.id, d, p) + for g in groups + if ctx.group_var(g.id, d, p) is not None + ] + if len(cell) > 1: + ctx.problem += pulp.lpSum(cell) <= 1, f"H1_c{class_id}_{d}_{p}" + + def check(self, spec: ProblemSpec, placements: Sequence[Placement]) -> list[Violation]: + grouped = spec.group_of_course + seen: dict[tuple[int, int, int], list[Placement]] = {} + for pl in placements: + course = spec.courses.get(pl.course_id) + if not course: + continue + for cid in course.class_ids: + seen.setdefault((cid, pl.day, pl.period), []).append(pl) + out = [] + for (cid, d, p), pls in sorted(seen.items()): + if len(pls) <= 1: + continue + # 学級の1コマを正当に占められるのは次のどちらか: + # * 単独の講座が1つ + # * 同一の同時展開群に属する、互いに異なる講座(選択科目の並列開講) + # 同じ講座が同じコマに2つあるのは、群であっても二重予約なので違反 + units = {grouped.get(x.course_id, ("solo", x.course_id)) for x in pls} + duplicated_course = len({x.course_id for x in pls}) < len(pls) + if len(units) > 1 or duplicated_course: + names = "、".join(spec.courses[x.course_id].label for x in pls) + out.append( + Violation( + code=self.code, + severity="hard", + message=f"{spec.class_names[cid]} の{slot_label(d, p)}に {names} が重複しています", + course_ids=tuple(x.course_id for x in pls), + class_ids=(cid,), + slots=((d, p),), + ) + ) + return out + + +class TeacherOccupancy(Constraint): + """H2: 1人の教員が同一コマに2つ以上の講座を担当しない。""" + + code = "H2" + label = "教員の重複" + severity = "hard" + + def apply(self, spec: ProblemSpec, ctx: LpContext) -> None: + for teacher_id in spec.teachers: + course_ids = [c.id for c in spec.courses_of_teacher(teacher_id)] + if len(course_ids) < 2: + continue + for d, p in spec.slots: + cell = ctx.vars_for(course_ids, [(d, p)]) + if len(cell) > 1: + ctx.problem += pulp.lpSum(cell) <= 1, f"H2_t{teacher_id}_{d}_{p}" + + def check(self, spec: ProblemSpec, placements: Sequence[Placement]) -> list[Violation]: + seen: dict[tuple[int, int, int], list[Placement]] = {} + for pl in placements: + course = spec.courses.get(pl.course_id) + if not course: + continue + seen.setdefault((course.teacher_id, pl.day, pl.period), []).append(pl) + out = [] + for (tid, d, p), pls in sorted(seen.items()): + if len(pls) > 1: + names = "、".join(spec.courses[x.course_id].label for x in pls) + out.append( + Violation( + code=self.code, + severity="hard", + message=( + f"{spec.teachers[tid].name} が{slot_label(d, p)}に " + f"{names} を同時に担当しています" + ), + course_ids=tuple(x.course_id for x in pls), + teacher_ids=(tid,), + slots=((d, p),), + ) + ) + return out + + +class RoomCapacity(Constraint): + """H3: 特別教室の同時使用数が保有数を超えない。""" + + code = "H3" + label = "特別教室の不足" + severity = "hard" + + def apply(self, spec: ProblemSpec, ctx: LpContext) -> None: + for room_type, capacity in spec.room_capacity.items(): + course_ids = [c.id for c in spec.courses.values() if c.room_type == room_type] + if not course_ids: + continue + for d, p in spec.slots: + cell = ctx.vars_for(course_ids, [(d, p)]) + if len(cell) > capacity: + ctx.problem += pulp.lpSum(cell) <= capacity, f"H3_{room_type}_{d}_{p}" + + def check(self, spec: ProblemSpec, placements: Sequence[Placement]) -> list[Violation]: + seen: dict[tuple[str, int, int], list[Placement]] = {} + for pl in placements: + course = spec.courses.get(pl.course_id) + if not course or not course.room_type: + continue + seen.setdefault((course.room_type, pl.day, pl.period), []).append(pl) + out = [] + for (room_type, d, p), pls in sorted(seen.items()): + capacity = spec.room_capacity.get(room_type, 0) + if len(pls) > capacity: + out.append( + Violation( + code=self.code, + severity="hard", + message=( + f"{slot_label(d, p)}に {room_type} を {len(pls)} 講座が使用しますが " + f"{capacity} 室しかありません" + ), + course_ids=tuple(x.course_id for x in pls), + slots=((d, p),), + ) + ) + return out + + +class BlockedSlotConstraint(Constraint): + """H4/H5: 配置禁止枠と教員の不在枠には置かない。 + + LP では該当する (講座, コマ) の変数自体を作らないことで表現しているため、 + apply では何もしない。手編集では置けてしまうので check で検出する。 + """ + + code = "H4" + label = "配置禁止枠" + severity = "hard" + + def apply(self, spec: ProblemSpec, ctx: LpContext) -> None: + return None + + def check(self, spec: ProblemSpec, placements: Sequence[Placement]) -> list[Violation]: + out = [] + for pl in placements: + course = spec.courses.get(pl.course_id) + if not course: + continue + blocked_classes = [ + cid for cid in course.class_ids if spec.is_blocked(cid, pl.day, pl.period) + ] + if blocked_classes: + out.append( + Violation( + code="H4", + severity="hard", + message=f"{course.label} が配置禁止枠({slot_label(pl.day, pl.period)})に置かれています", + course_ids=(course.id,), + class_ids=tuple(blocked_classes), + slots=((pl.day, pl.period),), + ) + ) + teacher = spec.teachers.get(course.teacher_id) + if teacher and (pl.day, pl.period) in teacher.unavailable: + out.append( + Violation( + code="H5", + severity="hard", + message=( + f"{teacher.name} は{slot_label(pl.day, pl.period)}が不在ですが " + f"{course.label} が割り当てられています" + ), + course_ids=(course.id,), + teacher_ids=(teacher.id,), + slots=((pl.day, pl.period),), + ) + ) + return out + + +class LockedAssignment(Constraint): + """H6: 固定された割当は動かさない。""" + + code = "H6" + label = "固定された割当" + severity = "hard" + + def apply(self, spec: ProblemSpec, ctx: LpContext) -> None: + for pl in sorted(spec.locked, key=lambda p: (p.course_id, p.day, p.period)): + var = ctx.var(pl.course_id, pl.day, pl.period) + if var is not None: + ctx.problem += var == 1, f"H6_{pl.course_id}_{pl.day}_{pl.period}" + + def check(self, spec: ProblemSpec, placements: Sequence[Placement]) -> list[Violation]: + present = set(placements) + out = [] + for pl in sorted(spec.locked, key=lambda p: (p.course_id, p.day, p.period)): + if pl not in present: + course = spec.courses.get(pl.course_id) + label = course.label if course else f"講座{pl.course_id}" + out.append( + Violation( + code=self.code, + severity="hard", + message=f"固定された {label}({slot_label(pl.day, pl.period)})が外れています", + course_ids=(pl.course_id,), + slots=((pl.day, pl.period),), + ) + ) + return out + + +# -------------------------------------------------------------------------- +# ソフト制約 +# -------------------------------------------------------------------------- + + +class WeeklyPeriods(Constraint): + """S1: 講座の週コマ数を満たす。最優先のソフト制約。""" + + code = "S1" + label = "週コマ数の未充足" + severity = "soft" + weight = 1000.0 + + def apply(self, spec: ProblemSpec, ctx: LpContext) -> None: + for course in spec.courses.values(): + cell = ctx.vars_for([course.id], spec.allowed_slots(course)) + if not cell: + continue + expr = pulp.lpSum(cell) + short = ctx.penalty(f"S1_short_{course.id}", self.weight, up_bound=course.weekly_periods) + ctx.problem += expr + short >= course.weekly_periods, f"S1_min_{course.id}" + # 超過は許さない(空解で充足可能なのでハードにしても実行可能性を損なわない) + ctx.problem += expr <= course.weekly_periods, f"S1_max_{course.id}" + + def check(self, spec: ProblemSpec, placements: Sequence[Placement]) -> list[Violation]: + counts: dict[int, int] = {} + for pl in placements: + counts[pl.course_id] = counts.get(pl.course_id, 0) + 1 + out = [] + for course in sorted(spec.courses.values(), key=lambda c: c.id): + n = counts.get(course.id, 0) + if n < course.weekly_periods: + out.append( + Violation( + code=self.code, + severity="soft", + message=( + f"{course.label} は週{course.weekly_periods}コマ必要ですが " + f"{n}コマしか配置されていません" + ), + course_ids=(course.id,), + class_ids=course.class_ids, + ) + ) + elif n > course.weekly_periods: + out.append( + Violation( + code=self.code, + severity="soft", + message=( + f"{course.label} は週{course.weekly_periods}コマですが " + f"{n}コマ配置されています" + ), + course_ids=(course.id,), + class_ids=course.class_ids, + ) + ) + return out + + +class MaxPerDay(Constraint): + """S2: 同じ講座を1日に置ける回数の上限。週コマ数の週内分散(S6)も兼ねる。""" + + code = "S2" + label = "同一教科の1日重複" + severity = "soft" + weight = 100.0 + + def apply(self, spec: ProblemSpec, ctx: LpContext) -> None: + for course in spec.courses.values(): + for d in range(spec.days): + slots = [(d, p) for p in range(spec.periods_per_day)] + cell = ctx.vars_for([course.id], slots) + if len(cell) <= course.max_per_day: + continue + over = ctx.penalty(f"S2_over_{course.id}_{d}", self.weight) + ctx.problem += ( + pulp.lpSum(cell) - over <= course.max_per_day, + f"S2_{course.id}_{d}", + ) + + def check(self, spec: ProblemSpec, placements: Sequence[Placement]) -> list[Violation]: + counts: dict[tuple[int, int], int] = {} + for pl in placements: + counts[(pl.course_id, pl.day)] = counts.get((pl.course_id, pl.day), 0) + 1 + out = [] + for (course_id, day), n in sorted(counts.items()): + course = spec.courses.get(course_id) + if course and n > course.max_per_day: + out.append( + Violation( + code=self.code, + severity="soft", + message=( + f"{course.label} が{DAY_NAMES[day % 7]}曜に{n}コマあります" + f"(上限{course.max_per_day}コマ)" + ), + course_ids=(course_id,), + class_ids=course.class_ids, + slots=((day, 0),), + ) + ) + return out + + +class TeacherWeeklyLoad(Constraint): + """S3: 教員の週持ちコマ数の上限。既定18コマは教員勤務実態調査の平均に基づく。""" + + code = "S3" + label = "教員の持ちコマ超過" + severity = "soft" + weight = 50.0 + + def apply(self, spec: ProblemSpec, ctx: LpContext) -> None: + for teacher in spec.teachers.values(): + course_ids = [c.id for c in spec.courses_of_teacher(teacher.id)] + cell = ctx.vars_for(course_ids, spec.slots) + if not cell: + continue + over = ctx.penalty(f"S3_over_{teacher.id}", self.weight) + ctx.problem += ( + pulp.lpSum(cell) - over <= teacher.max_weekly_periods, + f"S3_{teacher.id}", + ) + + def check(self, spec: ProblemSpec, placements: Sequence[Placement]) -> list[Violation]: + load: dict[int, int] = {} + for pl in placements: + course = spec.courses.get(pl.course_id) + if course: + load[course.teacher_id] = load.get(course.teacher_id, 0) + 1 + out = [] + for teacher_id, n in sorted(load.items()): + teacher = spec.teachers[teacher_id] + if n > teacher.max_weekly_periods: + out.append( + Violation( + code=self.code, + severity="soft", + message=( + f"{teacher.name} の持ちコマが週{n}コマで、" + f"上限{teacher.max_weekly_periods}コマを超えています" + ), + teacher_ids=(teacher_id,), + ) + ) + return out + + +class ParallelGroupAlignment(Constraint): + """S4/S5: 同時展開群の講座を同一コマに揃える。 + + 高校の選択科目の並列開講と、特別支援学級の交流及び共同学習の両方に使う。 + """ + + code = "S4" + label = "同時展開のずれ" + severity = "soft" + weight = 500.0 + + def apply(self, spec: ProblemSpec, ctx: LpContext) -> None: + for group in spec.parallel_groups: + member_ids = [cid for cid in group.course_ids if cid in spec.courses] + if len(member_ids) < 2: + continue + for d, p in spec.slots: + z = ctx.group_var(group.id, d, p) + if z is None: + continue + for cid in member_ids: + var = ctx.var(cid, d, p) + if var is None: + # 置けない枠なら、群としてもそのコマは使えない + ctx.problem += z == 0, f"S4_block_{group.id}_{cid}_{d}_{p}" + continue + # 等式にする。H1(学級の重複)は選択科目群を z で1セルとして + # 数えているため、ここを緩めると H1 が実質ソフト化してしまう。 + # 空解(すべて0)で充足できるのでモデルは依然として常に実行可能。 + ctx.problem += var == z, f"S4_eq_{group.id}_{cid}_{d}_{p}" + + def check(self, spec: ProblemSpec, placements: Sequence[Placement]) -> list[Violation]: + by_course: dict[int, set[tuple[int, int]]] = {} + for pl in placements: + by_course.setdefault(pl.course_id, set()).add(pl.slot) + out = [] + for group in spec.parallel_groups: + member_ids = [cid for cid in group.course_ids if cid in spec.courses] + if len(member_ids) < 2: + continue + slot_sets = [by_course.get(cid, set()) for cid in member_ids] + union = set().union(*slot_sets) + intersection = set(union) + for s in slot_sets: + intersection &= s + misaligned = sorted(union - intersection) + if misaligned: + out.append( + Violation( + code=self.code, + severity="soft", + message=( + f"同時展開群「{group.name}」の講座が" + f"{'、'.join(slot_label(d, p) for d, p in misaligned)} で揃っていません" + ), + course_ids=tuple(member_ids), + slots=tuple(misaligned), + ) + ) + return out + + +class DoublePeriod(Constraint): + """S8: 実験・実習・体育などを2コマ連続で配置する。""" + + code = "S8" + label = "2コマ連続の未確保" + severity = "soft" + weight = 20.0 + + def apply(self, spec: ProblemSpec, ctx: LpContext) -> None: + for course in spec.courses.values(): + if not course.prefers_double or course.weekly_periods < 2: + continue + pairs = [] + for d in range(spec.days): + for p in range(spec.periods_per_day - 1): + a = ctx.var(course.id, d, p) + b = ctx.var(course.id, d, p + 1) + if a is None or b is None: + continue + z = ctx.reward(f"S8_pair_{course.id}_{d}_{p}", self.weight) + ctx.problem += z <= a, f"S8_a_{course.id}_{d}_{p}" + ctx.problem += z <= b, f"S8_b_{course.id}_{d}_{p}" + pairs.append(z) + if pairs: + ctx.problem += ( + pulp.lpSum(pairs) <= course.weekly_periods // 2, + f"S8_cap_{course.id}", + ) + + def check(self, spec: ProblemSpec, placements: Sequence[Placement]) -> list[Violation]: + by_course: dict[int, set[tuple[int, int]]] = {} + for pl in placements: + by_course.setdefault(pl.course_id, set()).add(pl.slot) + out = [] + for course in sorted(spec.courses.values(), key=lambda c: c.id): + if not course.prefers_double or course.weekly_periods < 2: + continue + slots = by_course.get(course.id, set()) + has_pair = any((d, p + 1) in slots for (d, p) in slots) + if slots and not has_pair: + out.append( + Violation( + code=self.code, + severity="soft", + message=f"{course.label} は2コマ連続が望ましいですが、連続していません", + course_ids=(course.id,), + class_ids=course.class_ids, + ) + ) + return out + + +class CoreSubjectsInMorning(Constraint): + """S9: 主要教科を午前(1〜4限)に寄せる。5限以降への配置にペナルティを課す。""" + + code = "S9" + label = "主要教科の午後配置" + severity = "soft" + weight = 5.0 + afternoon_from = 4 # 0-indexed。5限目以降を午後とみなす + + def _is_afternoon(self, spec: ProblemSpec, period: int) -> bool: + return period >= min(self.afternoon_from, spec.periods_per_day - 1) + + def apply(self, spec: ProblemSpec, ctx: LpContext) -> None: + terms = [] + for course in spec.courses.values(): + if not course.is_core: + continue + for d, p in spec.slots: + if not self._is_afternoon(spec, p): + continue + var = ctx.var(course.id, d, p) + if var is not None: + terms.append(var) + if terms: + ctx.objective_terms.append(self.weight * pulp.lpSum(terms)) + + def check(self, spec: ProblemSpec, placements: Sequence[Placement]) -> list[Violation]: + out = [] + for pl in sorted(placements, key=lambda p: (p.course_id, p.day, p.period)): + course = spec.courses.get(pl.course_id) + if course and course.is_core and self._is_afternoon(spec, pl.period): + out.append( + Violation( + code=self.code, + severity="soft", + message=f"{course.label} が{slot_label(pl.day, pl.period)}(午後)に配置されています", + course_ids=(course.id,), + class_ids=course.class_ids, + slots=((pl.day, pl.period),), + ) + ) + return out + + +# 適用順は目的関数の重み順ではなく、違反リストの表示順を決める +ALL_CONSTRAINTS: tuple[Constraint, ...] = ( + ClassOccupancy(), + TeacherOccupancy(), + RoomCapacity(), + BlockedSlotConstraint(), + LockedAssignment(), + WeeklyPeriods(), + MaxPerDay(), + TeacherWeeklyLoad(), + ParallelGroupAlignment(), + DoublePeriod(), + CoreSubjectsInMorning(), +) + +CONSTRAINT_INFO = [ + {"code": c.code, "label": c.label, "severity": c.severity, "weight": c.weight} + for c in ALL_CONSTRAINTS +] + + +def course_label(course: CourseSpec, spec: ProblemSpec) -> str: + return course.label diff --git a/backend/app/solver/engine.py b/backend/app/solver/engine.py new file mode 100644 index 0000000..af016d0 --- /dev/null +++ b/backend/app/solver/engine.py @@ -0,0 +1,154 @@ +"""求解エンジンと検証エンジン。 + +どちらも constraints.ALL_CONSTRAINTS を唯一の情報源として使う。 +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Sequence + +import pulp + +from .constraints import ALL_CONSTRAINTS, LpContext +from .spec import Placement, ProblemSpec, Violation + +# 仮定 A-07: 求解の既定タイムアウト。品質は15秒で頭打ちになることを実測したため30秒 +DEFAULT_TIME_LIMIT_SEC = 30 +# 仮定 A-27: 最適性の証明に時間の大半が費やされるため、相対ギャップ2%で打ち切る。 +# 実用上、2%以内の差は「体育が午後に1コマ寄る」程度の違いにしかならない +DEFAULT_GAP_REL = 0.02 + + +class LockConflictError(Exception): + """固定された割当同士が矛盾していて求解できない(仮定 A-11)。""" + + def __init__(self, violations: list[Violation]): + self.violations = violations + super().__init__("固定された割当に矛盾があります") + + +@dataclass +class SolveResult: + placements: list[Placement] + violations: list[Violation] + status: str + objective: float + elapsed_sec: float + unmet_periods: int + total_periods: int + # 時間制限に達して最適性が証明できなかった場合 True + timed_out: bool = False + stats: dict = field(default_factory=dict) + + @property + def filled_periods(self) -> int: + return self.total_periods - self.unmet_periods + + +def validate(spec: ProblemSpec, placements: Sequence[Placement]) -> list[Violation]: + """割当を全制約で検査し、違反を返す。ハード違反を先に並べる。""" + violations: list[Violation] = [] + for constraint in ALL_CONSTRAINTS: + violations.extend(constraint.check(spec, placements)) + violations.sort(key=lambda v: (0 if v.severity == "hard" else 1, v.code)) + return violations + + +def check_locks(spec: ProblemSpec) -> list[Violation]: + """固定された割当だけを取り出してハード違反がないか調べる。""" + locked = sorted(spec.locked, key=lambda p: (p.day, p.period, p.course_id)) + if not locked: + return [] + # H6 は「固定が外れていないか」の検査なので、ここでは対象外にする + violations: list[Violation] = [] + for constraint in ALL_CONSTRAINTS: + if constraint.code == "H6": + continue + violations.extend( + v for v in constraint.check(spec, locked) if v.severity == "hard" + ) + return violations + + +def build_model(spec: ProblemSpec) -> tuple[pulp.LpProblem, LpContext]: + problem = pulp.LpProblem("timetable", pulp.LpMinimize) + x: dict[tuple[int, int, int], pulp.LpVariable] = {} + for course in spec.courses.values(): + # 配置禁止枠・教員不在枠には変数を作らない(ハード制約 H4/H5 の表現) + for d, p in spec.allowed_slots(course): + x[(course.id, d, p)] = pulp.LpVariable( + f"x_{course.id}_{d}_{p}", cat=pulp.LpBinary + ) + # 同時展開群のインジケータ。H1 と S4 が共有するため、制約適用の前に作る + z: dict[tuple[int, int, int], pulp.LpVariable] = {} + for group in spec.parallel_groups: + if len([cid for cid in group.course_ids if cid in spec.courses]) < 2: + continue + for d, p in spec.slots: + z[(group.id, d, p)] = pulp.LpVariable( + f"z_{group.id}_{d}_{p}", cat=pulp.LpBinary + ) + ctx = LpContext(problem=problem, x=x, z=z) + for constraint in ALL_CONSTRAINTS: + constraint.apply(spec, ctx) + problem += pulp.lpSum(ctx.objective_terms) if ctx.objective_terms else 0 + return problem, ctx + + +def solve( + spec: ProblemSpec, + time_limit_sec: int = DEFAULT_TIME_LIMIT_SEC, + gap_rel: float = DEFAULT_GAP_REL, + msg: bool = False, +) -> SolveResult: + """時間割を自動提案する。 + + モデルは常に実行可能なので「解なし」は返らない。時間制限に達した場合は + その時点の最良解を返し、``timed_out`` を True にする。 + """ + lock_violations = check_locks(spec) + if lock_violations: + raise LockConflictError(lock_violations) + + problem, ctx = build_model(spec) + started = time.time() + status_code = problem.solve( + pulp.PULP_CBC_CMD(msg=msg, timeLimit=time_limit_sec, gapRel=gap_rel) + ) + elapsed = time.time() - started + status = pulp.LpStatus[status_code] + # CBC は時間制限で打ち切っても LpStatus は Optimal を返すことがある。 + # 最適性が証明されたかどうかは sol_status で判定する + proven_optimal = problem.sol_status == pulp.LpSolutionOptimal + + placements = [ + Placement(course_id=cid, day=d, period=p) + for (cid, d, p), var in ctx.x.items() + if var.value() is not None and var.value() > 0.5 + ] + placements.sort(key=lambda pl: (pl.day, pl.period, pl.course_id)) + + violations = validate(spec, placements) + total = sum(c.weekly_periods for c in spec.courses.values()) + unmet = max(0, total - len(placements)) + objective = pulp.value(problem.objective) + + return SolveResult( + placements=placements, + violations=violations, + status=status, + objective=float(objective) if objective is not None else 0.0, + elapsed_sec=elapsed, + unmet_periods=unmet, + total_periods=total, + timed_out=not proven_optimal, + stats={ + "gap_rel": gap_rel, + "variables": len(problem.variables()), + "constraints": len(problem.constraints), + "courses": len(spec.courses), + "locked": len(spec.locked), + }, + ) diff --git a/backend/app/solver/spec.py b/backend/app/solver/spec.py new file mode 100644 index 0000000..deb28b3 --- /dev/null +++ b/backend/app/solver/spec.py @@ -0,0 +1,154 @@ +"""求解と検証が共有する問題定義。 + +ソルバーと検証エンジンが別々に制約を解釈すると、「画面上はOKなのに再提案で弾かれる」 +というズレが起きる。それを防ぐため、制約は constraints.py に1回だけ定義し、 +LP への追加(apply)と既存割当の検査(check)の両方を同じクラスが持つ。 +このモジュールはその共通の入力型を定義する。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Iterable, Literal + +Severity = Literal["hard", "soft"] + + +@dataclass(frozen=True) +class Placement: + """1つの割当(この講座を、この曜日・このコマに置く)。""" + + course_id: int + day: int + period: int + + @property + def slot(self) -> tuple[int, int]: + return (self.day, self.period) + + +@dataclass(frozen=True) +class TeacherSpec: + id: int + name: str + max_weekly_periods: int + # 不在枠 (day, period) + unavailable: frozenset[tuple[int, int]] = frozenset() + + +@dataclass(frozen=True) +class CourseSpec: + id: int + label: str + subject_id: int + subject_name: str + is_core: bool + room_type: str | None + teacher_id: int + class_ids: tuple[int, ...] + weekly_periods: int + max_per_day: int = 1 + prefers_double: bool = False + + +@dataclass(frozen=True) +class ParallelGroupSpec: + id: int + name: str + course_ids: tuple[int, ...] + + +@dataclass +class ProblemSpec: + days: int + periods_per_day: int + courses: dict[int, CourseSpec] = field(default_factory=dict) + teachers: dict[int, TeacherSpec] = field(default_factory=dict) + class_names: dict[int, str] = field(default_factory=dict) + # 特別教室の種別ごとの保有数。同一コマにこの数を超えて使えない + room_capacity: dict[str, int] = field(default_factory=dict) + # 配置禁止枠 (class_id, day, period)。class_id が None なら全学級 + blocked: frozenset[tuple[int | None, int, int]] = frozenset() + parallel_groups: tuple[ParallelGroupSpec, ...] = () + # 固定された割当。再提案時に動かさない + locked: frozenset[Placement] = frozenset() + + # ---- 導出プロパティ ---- + + @property + def slots(self) -> list[tuple[int, int]]: + return [(d, p) for d in range(self.days) for p in range(self.periods_per_day)] + + @property + def group_of_course(self) -> dict[int, int]: + """講座 -> 所属する同時展開群。1講座は高々1群に属する(仮定 A-15)。""" + out: dict[int, int] = {} + for g in self.parallel_groups: + for cid in g.course_ids: + out.setdefault(cid, g.id) + return out + + def groups_of_class(self, class_id: int) -> list[ParallelGroupSpec]: + """その学級の講座を1つ以上含む同時展開群。""" + return [ + g + for g in self.parallel_groups + if any( + class_id in self.courses[cid].class_ids + for cid in g.course_ids + if cid in self.courses + ) + ] + + def courses_of_teacher(self, teacher_id: int) -> list[CourseSpec]: + return [c for c in self.courses.values() if c.teacher_id == teacher_id] + + def courses_of_class(self, class_id: int) -> list[CourseSpec]: + return [c for c in self.courses.values() if class_id in c.class_ids] + + def is_blocked(self, class_id: int, day: int, period: int) -> bool: + return (class_id, day, period) in self.blocked or (None, day, period) in self.blocked + + def course_blocked(self, course: CourseSpec, day: int, period: int) -> bool: + """講座が置けない枠か。受講学級のいずれかが禁止枠なら置けない(ハード制約 H4)。""" + if any(self.is_blocked(cid, day, period) for cid in course.class_ids): + return True + teacher = self.teachers.get(course.teacher_id) + # 教員の不在枠(ハード制約 H5) + if teacher and (day, period) in teacher.unavailable: + return True + return False + + def allowed_slots(self, course: CourseSpec) -> list[tuple[int, int]]: + return [s for s in self.slots if not self.course_blocked(course, *s)] + + +@dataclass(frozen=True) +class Violation: + """制約違反。検証エンジンとソルバーの両方が同じ型で違反を返す。""" + + code: str + severity: Severity + message: str + course_ids: tuple[int, ...] = () + class_ids: tuple[int, ...] = () + teacher_ids: tuple[int, ...] = () + slots: tuple[tuple[int, int], ...] = () + + def as_dict(self) -> dict: + return { + "code": self.code, + "severity": self.severity, + "message": self.message, + "course_ids": list(self.course_ids), + "class_ids": list(self.class_ids), + "teacher_ids": list(self.teacher_ids), + "slots": [{"day": d, "period": p} for d, p in self.slots], + } + + +def group_by_slot(placements: Iterable[Placement]) -> dict[tuple[int, int], list[Placement]]: + out: dict[tuple[int, int], list[Placement]] = {} + for pl in placements: + out.setdefault(pl.slot, []).append(pl) + return out diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..e58faa2 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,29 @@ +[project] +name = "timetable-backend" +version = "1.0.0" +description = "日本の小・中・高の週時間割を整数計画法で自動編成するAPI" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "sqlalchemy>=2.0", + "pydantic>=2.7", + "pulp>=2.8", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "httpx>=0.27", "ruff>=0.5"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::UserWarning", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..59ea508 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +sqlalchemy>=2.0 +pydantic>=2.7 +pulp>=2.8 +pytest>=8.0 +httpx>=0.27 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..8938653 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import os +import tempfile + +import pytest + +# テストは常に使い捨てのSQLiteを使う。app.db をimportする前に設定する必要がある +_tmpdir = tempfile.mkdtemp(prefix="timetable-tests-") +os.environ.setdefault("TIMETABLE_DATABASE_URL", f"sqlite:///{_tmpdir}/test.db") + +from fastapi.testclient import TestClient # noqa: E402 + +from app.db import Base, SessionLocal, engine # noqa: E402 +from app.main import app # noqa: E402 + + +@pytest.fixture() +def db(): + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + session = SessionLocal() + try: + yield session + finally: + session.close() + + +@pytest.fixture() +def client(db): + with TestClient(app) as c: + yield c diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..ea4aa7e --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,341 @@ +"""API の統合テスト。提案→手編集→再提案の一連の流れを通す。""" + +from __future__ import annotations + +import time + +import pytest + +POLL_TIMEOUT_SEC = 120 + + +def wait_for_job(client, job_id: str) -> dict: + deadline = time.time() + POLL_TIMEOUT_SEC + while time.time() < deadline: + job = client.get(f"/api/jobs/{job_id}").json() + if job["status"] in ("succeeded", "failed", "cancelled"): + return job + time.sleep(0.2) + raise AssertionError("求解ジョブが時間内に終わりませんでした") + + +def create_school(client, school_type="junior", classes_per_grade=1, special=False) -> dict: + r = client.post( + "/api/schools", + json={ + "name": "テスト校", + "school_type": school_type, + "classes_per_grade": classes_per_grade, + "include_special_needs": special, + }, + ) + assert r.status_code == 201, r.text + return r.json() + + +def two_assignments_of_one_class(tt: dict) -> tuple[dict, dict]: + """同じ学級に属する、別々の講座・別々のコマの割当を2つ返す。""" + courses = {c["id"]: c for c in tt["courses"]} + by_class: dict[int, list[dict]] = {} + for a in tt["assignments"]: + for class_id in courses[a["course_id"]]["class_ids"]: + by_class.setdefault(class_id, []).append(a) + for items in by_class.values(): + for i, first in enumerate(items): + for second in items[i + 1 :]: + if first["course_id"] == second["course_id"]: + continue + if (first["day"], first["period"]) != (second["day"], second["period"]): + return first, second + raise AssertionError("同一学級の割当が2つ見つかりません") + + +def solve_school(client, school_id: int, source: int | None = None, limit: int = 30) -> dict: + r = client.post( + f"/api/schools/{school_id}/solve", + json={"source_timetable_id": source, "time_limit_sec": limit}, + ) + assert r.status_code == 202, r.text + return wait_for_job(client, r.json()["id"]) + + +# --------------------------------------------------------------------------- + + +def test_health_and_meta(client) -> None: + assert client.get("/health").json() == {"status": "ok"} + meta = client.get("/api/meta").json() + assert [s["value"] for s in meta["school_types"]] == ["elementary", "junior", "high"] + assert meta["max_classes"] == 36 + assert meta["day_names"] == ["月", "火", "水", "木", "金"] + # 制約は要件書のハード6件・ソフト10件のうち実装済みのものが並ぶ + codes = {c["code"] for c in meta["constraints"]} + assert {"H1", "H2", "H3", "H4", "H6", "S1", "S2", "S3", "S4", "S8", "S9"} == codes + + +@pytest.mark.parametrize( + "school_type,periods", + [("elementary", 6), ("junior", 6), ("high", 6)], +) +def test_create_school_for_each_type(client, school_type: str, periods: int) -> None: + school = create_school(client, school_type) + assert school["periods_per_day"] == periods + assert school["class_count"] >= 3 + assert school["course_count"] > 0 + + +def test_create_school_rejects_too_many_classes(client) -> None: + r = client.post( + "/api/schools", + json={ + "name": "巨大校", + "school_type": "elementary", + "classes_per_grade": 7, # 7 x 6学年 = 42学級 > 上限36 + "include_special_needs": False, + }, + ) + assert r.status_code == 400 + assert "上限" in r.json()["detail"] + + +def test_solve_produces_timetable_without_hard_violations(client) -> None: + school = create_school(client, "junior", classes_per_grade=2) + job = solve_school(client, school["id"]) + assert job["status"] == "succeeded", job + assert job["unmet_periods"] == 0 + + tt = client.get(f"/api/timetables/{job['timetable_id']}").json() + assert tt["summary"]["hard_violations"] == 0 + assert tt["summary"]["placed_periods"] == tt["summary"]["total_periods"] + assert len(tt["assignments"]) > 0 + assert tt["school"]["school_type_label"] == "中学校" + + +def test_elementary_special_needs_class_shares_exchange_lessons(client) -> None: + """交流及び共同学習の教科は、通常学級と同じ講座に特別支援学級が入る。""" + school = create_school(client, "elementary", classes_per_grade=1, special=True) + job = solve_school(client, school["id"]) + tt = client.get(f"/api/timetables/{job['timetable_id']}").json() + + special = next(k for k in tt["classes"] if k["kind"] == "special_needs") + shared = [c for c in tt["courses"] if special["id"] in c["class_ids"] and len(c["class_ids"]) > 1] + assert shared, "交流及び共同学習の講座がありません" + assert {c["subject_name"] for c in shared} & {"体育", "音楽", "図画工作"} + + +def test_high_school_electives_are_aligned(client) -> None: + """高校の選択科目群が同一コマに並列開講される。""" + school = create_school(client, "high", classes_per_grade=1) + job = solve_school(client, school["id"], limit=60) + tt = client.get(f"/api/timetables/{job['timetable_id']}").json() + + groups = tt["parallel_groups"] + assert groups, "選択科目群が生成されていません" + slots_by_course: dict[int, set] = {} + for a in tt["assignments"]: + slots_by_course.setdefault(a["course_id"], set()).add((a["day"], a["period"])) + for g in groups: + member_slots = [slots_by_course.get(cid, set()) for cid in g["course_ids"]] + assert all(s == member_slots[0] for s in member_slots), g["name"] + + +def test_manual_move_creates_and_clears_violation(client) -> None: + """ドラッグで動かすと違反が出て、戻すと消える(手編集の即時検証)。""" + school = create_school(client, "junior", classes_per_grade=1) + job = solve_school(client, school["id"]) + tt = client.get(f"/api/timetables/{job['timetable_id']}").json() + assert tt["summary"]["hard_violations"] == 0 + + # 同じ学級の別の割当と同じコマへ動かす + first, second = two_assignments_of_one_class(tt) + original = (first["day"], first["period"]) + r = client.patch( + f"/api/timetables/{tt['id']}/assignments/{first['id']}", + json={"day": second["day"], "period": second["period"]}, + ) + assert r.status_code == 200 + moved = r.json() + assert moved["summary"]["hard_violations"] >= 1 + assert any(v["code"] in ("H1", "H2") for v in moved["violations"]) + + # 元に戻すと違反が消える + r = client.patch( + f"/api/timetables/{tt['id']}/assignments/{first['id']}", + json={"day": original[0], "period": original[1]}, + ) + assert r.json()["summary"]["hard_violations"] == 0 + + +def test_lock_prevents_move_and_delete(client) -> None: + school = create_school(client, "junior", classes_per_grade=1) + job = solve_school(client, school["id"]) + tt = client.get(f"/api/timetables/{job['timetable_id']}").json() + a = tt["assignments"][0] + + locked = client.post( + f"/api/timetables/{tt['id']}/assignments/{a['id']}/lock", json={"locked": True} + ).json() + assert locked["summary"]["locked"] == 1 + + r = client.patch( + f"/api/timetables/{tt['id']}/assignments/{a['id']}", json={"day": 4, "period": 5} + ) + assert r.status_code == 409 + r = client.delete(f"/api/timetables/{tt['id']}/assignments/{a['id']}") + assert r.status_code == 409 + + +def test_resolve_keeps_locked_cells(client) -> None: + """固定したコマを保ったまま残りを再提案する(微調整の中核機能)。""" + school = create_school(client, "junior", classes_per_grade=2) + job = solve_school(client, school["id"]) + tt = client.get(f"/api/timetables/{job['timetable_id']}").json() + + targets = tt["assignments"][:5] + for a in targets: + client.post( + f"/api/timetables/{tt['id']}/assignments/{a['id']}/lock", json={"locked": True} + ) + locked_slots = {(a["course_id"], a["day"], a["period"]) for a in targets} + + job2 = solve_school(client, school["id"], source=tt["id"]) + assert job2["status"] == "succeeded", job2 + tt2 = client.get(f"/api/timetables/{job2['timetable_id']}").json() + + new_slots = {(a["course_id"], a["day"], a["period"]) for a in tt2["assignments"]} + assert locked_slots <= new_slots, "固定した割当が再提案で動いてしまいました" + assert tt2["parent_id"] == tt["id"] + assert tt2["summary"]["locked"] == len(targets) + + +def test_resolve_reports_conflicting_locks(client) -> None: + """矛盾する固定を作った状態で再提案すると、理由付きで失敗する。""" + school = create_school(client, "junior", classes_per_grade=1) + job = solve_school(client, school["id"]) + tt = client.get(f"/api/timetables/{job['timetable_id']}").json() + + a, b = two_assignments_of_one_class(tt) + # 同じコマに重ねてから両方を固定する + client.patch( + f"/api/timetables/{tt['id']}/assignments/{a['id']}", + json={"day": b["day"], "period": b["period"]}, + ) + for x in (a, b): + client.post( + f"/api/timetables/{tt['id']}/assignments/{x['id']}/lock", json={"locked": True} + ) + + job2 = solve_school(client, school["id"], source=tt["id"]) + assert job2["status"] == "failed" + assert "固定" in job2["error"] + assert any(v["code"] in ("H1", "H2") for v in job2["violations"]) + + +def test_replace_assignments_supports_undo(client) -> None: + """割当の一括置換。フロントの undo/redo が使う。""" + school = create_school(client, "junior", classes_per_grade=1) + job = solve_school(client, school["id"]) + tt = client.get(f"/api/timetables/{job['timetable_id']}").json() + snapshot = [ + {"course_id": a["course_id"], "day": a["day"], "period": a["period"], "locked": a["locked"]} + for a in tt["assignments"] + ] + + # 全部消してから、スナップショットで復元する + client.put(f"/api/timetables/{tt['id']}/assignments", json={"assignments": []}) + empty = client.get(f"/api/timetables/{tt['id']}").json() + assert empty["summary"]["placed_periods"] == 0 + assert empty["summary"]["unmet_periods"] == empty["summary"]["total_periods"] + + restored = client.put( + f"/api/timetables/{tt['id']}/assignments", json={"assignments": snapshot} + ).json() + assert restored["summary"]["placed_periods"] == tt["summary"]["placed_periods"] + assert restored["summary"]["hard_violations"] == 0 + + +def test_unplaced_courses_are_reported(client) -> None: + school = create_school(client, "junior", classes_per_grade=1) + job = solve_school(client, school["id"]) + tt = client.get(f"/api/timetables/{job['timetable_id']}").json() + assert tt["unplaced"] == [] + + a = tt["assignments"][0] + after = client.delete(f"/api/timetables/{tt['id']}/assignments/{a['id']}").json() + assert after["unplaced"] == [{"course_id": a["course_id"], "remaining": 1}] + + +def test_teacher_unavailability_is_applied_on_resolve(client) -> None: + """教員の不在枠を登録して再提案すると、その枠に授業が入らない。""" + school = create_school(client, "junior", classes_per_grade=1) + job = solve_school(client, school["id"]) + tt = client.get(f"/api/timetables/{job['timetable_id']}").json() + + teacher = next(t for t in tt["teachers"] if t["kind"] == "subject") + unavailable = [{"day": 0, "period": p} for p in range(6)] # 月曜終日不在 + r = client.patch( + f"/api/teachers/{teacher['id']}", json={"unavailable": unavailable} + ) + assert r.status_code == 200 + assert len(r.json()["unavailable"]) == 6 + + job2 = solve_school(client, school["id"]) + tt2 = client.get(f"/api/timetables/{job2['timetable_id']}").json() + their_courses = {c["id"] for c in tt2["courses"] if c["teacher_id"] == teacher["id"]} + assert not [ + a for a in tt2["assignments"] if a["course_id"] in their_courses and a["day"] == 0 + ] + + +def test_csv_export_by_class_and_teacher(client) -> None: + school = create_school(client, "junior", classes_per_grade=1) + job = solve_school(client, school["id"]) + tt_id = job["timetable_id"] + + for by in ("class", "teacher"): + r = client.get(f"/api/timetables/{tt_id}/export.csv?by={by}") + assert r.status_code == 200 + assert "text/csv" in r.headers["content-type"] + body = r.text + assert "対象,曜日,1限" in body + assert "月" in body + assert len(body.splitlines()) > 5 + + +def test_timetable_versions_are_listed(client) -> None: + school = create_school(client, "junior", classes_per_grade=1) + solve_school(client, school["id"]) + solve_school(client, school["id"]) + rows = client.get(f"/api/schools/{school['id']}/timetables").json() + assert len(rows) == 2 + assert rows[0]["name"] == "提案 v2" + + +def test_duplicate_placement_of_same_course_is_rejected(client) -> None: + """同じ講座を同じコマに二重に置く操作は弾く。""" + school = create_school(client, "junior", classes_per_grade=1) + job = solve_school(client, school["id"]) + tt = client.get(f"/api/timetables/{job['timetable_id']}").json() + by_course: dict[int, list[dict]] = {} + for a in tt["assignments"]: + by_course.setdefault(a["course_id"], []).append(a) + same_course = next(v for v in by_course.values() if len(v) >= 2) + a, b = same_course[0], same_course[1] + + r = client.patch( + f"/api/timetables/{tt['id']}/assignments/{a['id']}", + json={"day": b["day"], "period": b["period"]}, + ) + assert r.status_code == 409 + r = client.post( + f"/api/timetables/{tt['id']}/assignments", + json={"course_id": a["course_id"], "day": a["day"], "period": a["period"]}, + ) + assert r.status_code == 409 + + +def test_not_found_paths(client) -> None: + assert client.get("/api/timetables/9999").status_code == 404 + assert client.get("/api/jobs/deadbeef").status_code == 404 + assert client.get("/api/schools/9999/timetables").status_code == 404 + assert client.delete("/api/schools/9999").status_code == 404 diff --git a/backend/tests/test_curriculum.py b/backend/tests/test_curriculum.py new file mode 100644 index 0000000..ad1062a --- /dev/null +++ b/backend/tests/test_curriculum.py @@ -0,0 +1,63 @@ +"""教育課程マスタのテスト。 + +学校教育法施行規則の標準授業時数と、そこから導出した週コマ数が一致することを確かめる。 +""" + +from __future__ import annotations + +import pytest + +from app import curriculum as cur + + +@pytest.mark.parametrize( + "grade,expected", + [(1, 25), (2, 26), (3, 28), (4, 29), (5, 29), (6, 29)], +) +def test_elementary_weekly_periods_match_official_totals(grade: int, expected: int) -> None: + """小学校の週コマ数が年間標準授業時数 ÷ 授業週数に一致する。""" + assert sum(cur.weekly_periods_for(cur.ELEMENTARY, grade).values()) == expected + + +@pytest.mark.parametrize("grade", [1, 2, 3]) +def test_junior_weekly_periods_are_29(grade: int) -> None: + """中学校は全学年 1015時間 ÷ 35週 = 週29コマ。""" + assert sum(cur.weekly_periods_for(cur.JUNIOR, grade).values()) == 29 + + +@pytest.mark.parametrize("grade", [1, 2, 3]) +def test_high_weekly_periods_are_30(grade: int) -> None: + """高等学校は全日制の標準である週30単位時間に収まる。""" + assert cur.total_weekly_periods(cur.HIGH, grade) == 30 + + +def test_annual_totals_match_regulation() -> None: + """マスタに持っている年間授業時数の合計が、施行規則の合計と一致する。""" + for grade, hours in cur.ELEMENTARY_ANNUAL_HOURS.items(): + extra = 0 + if grade >= 3: + extra = 0 # 総合・外国語活動も表に含めてある + assert sum(hours.values()) + extra == cur.ELEMENTARY_TOTAL_HOURS[grade], grade + for grade, hours in cur.JUNIOR_ANNUAL_HOURS.items(): + assert sum(hours.values()) == cur.JUNIOR_TOTAL_HOURS[grade], grade + + +def test_no_subject_gets_zero_periods() -> None: + """どの教科も週0コマにならない(配当されない教科が出ないこと)。""" + for school_type in (cur.ELEMENTARY, cur.JUNIOR, cur.HIGH): + for grade in cur.GRADES[school_type]: + weekly = cur.weekly_periods_for(school_type, grade) + assert all(n >= 1 for n in weekly.values()), (school_type, grade, weekly) + + +def test_every_subject_has_metadata() -> None: + """教育課程に出てくる教科名がすべて SUBJECT_META に定義されている。""" + names = set() + for school_type in (cur.ELEMENTARY, cur.JUNIOR, cur.HIGH): + for grade in cur.GRADES[school_type]: + names |= set(cur.weekly_periods_for(school_type, grade)) + for electives in cur.HIGH_ELECTIVES.values(): + for _, _, subjects in electives: + names |= set(subjects) + missing = names - set(cur.SUBJECT_META) + assert not missing, f"SUBJECT_META に未定義の教科: {missing}" diff --git a/backend/tests/test_solver.py b/backend/tests/test_solver.py new file mode 100644 index 0000000..95c35fd --- /dev/null +++ b/backend/tests/test_solver.py @@ -0,0 +1,248 @@ +"""ソルバーと検証エンジンのテスト。 + +同じ制約定義が「LPへの追加」と「既存割当の検査」の両方で使われていることを、 +両者の結果が食い違わないことで確かめる。 +""" + +from __future__ import annotations + +import pytest + +from app.solver.engine import LockConflictError, solve, validate +from app.solver.spec import ( + CourseSpec, + ParallelGroupSpec, + Placement, + ProblemSpec, + TeacherSpec, +) + + +def make_course(cid: int, label: str, teacher_id: int, class_ids, periods: int, **kw) -> CourseSpec: + return CourseSpec( + id=cid, + label=label, + subject_id=cid, + subject_name=label, + is_core=kw.pop("is_core", False), + room_type=kw.pop("room_type", None), + teacher_id=teacher_id, + class_ids=tuple(class_ids), + weekly_periods=periods, + max_per_day=kw.pop("max_per_day", 1), + prefers_double=kw.pop("prefers_double", False), + ) + + +def simple_spec(**overrides) -> ProblemSpec: + spec = ProblemSpec( + days=5, + periods_per_day=6, + class_names={1: "1年1組", 2: "1年2組"}, + teachers={ + 1: TeacherSpec(1, "国語 教諭1", 18), + 2: TeacherSpec(2, "数学 教諭1", 18), + }, + courses={ + 1: make_course(1, "1年1組 国語", 1, [1], 4), + 2: make_course(2, "1年1組 数学", 2, [1], 4), + 3: make_course(3, "1年2組 国語", 1, [2], 4), + 4: make_course(4, "1年2組 数学", 2, [2], 4), + }, + ) + for key, value in overrides.items(): + setattr(spec, key, value) + return spec + + +def test_solve_fills_all_periods_when_feasible() -> None: + result = solve(simple_spec(), time_limit_sec=20) + assert result.unmet_periods == 0 + assert result.total_periods == 16 + assert [v for v in result.violations if v.severity == "hard"] == [] + + +def test_solve_never_returns_infeasible_even_when_over_constrained() -> None: + """枠が足りなくても解は返り、不足はS1の未充足コマ数として定量化される。""" + spec = simple_spec() + # 週30枠しかないところに1学級で40コマ要求する + spec.courses[1] = make_course(1, "1年1組 国語", 1, [1], 40, max_per_day=6) + result = solve(spec, time_limit_sec=20) + + assert result.status != "Infeasible" + assert result.unmet_periods > 0 + assert any(v.code == "S1" for v in result.violations) + + +def test_teacher_conflict_is_never_produced() -> None: + """同じ教員が2学級を担当していても、同時刻に重ならない。""" + spec = simple_spec() + result = solve(spec, time_limit_sec=20) + by_slot: dict[tuple[int, int, int], int] = {} + for pl in result.placements: + key = (spec.courses[pl.course_id].teacher_id, pl.day, pl.period) + by_slot[key] = by_slot.get(key, 0) + 1 + assert max(by_slot.values()) == 1 + + +def test_blocked_slots_are_respected() -> None: + spec = simple_spec() + spec.blocked = frozenset((None, 4, p) for p in range(6)) # 金曜終日を禁止 + result = solve(spec, time_limit_sec=20) + assert all(pl.day != 4 for pl in result.placements) + assert result.unmet_periods == 0 + + +def test_teacher_unavailability_is_respected() -> None: + spec = simple_spec() + spec.teachers[1] = TeacherSpec(1, "国語 教諭1", 18, frozenset({(0, 0), (0, 1)})) + result = solve(spec, time_limit_sec=20) + kokugo = {1, 3} + assert all( + not (pl.course_id in kokugo and (pl.day, pl.period) in {(0, 0), (0, 1)}) + for pl in result.placements + ) + + +def test_room_capacity_limits_concurrent_use() -> None: + spec = simple_spec() + spec.room_capacity = {"理科室": 1} + spec.courses[5] = make_course(5, "1年1組 理科", 1, [1], 3, room_type="理科室") + spec.courses[6] = make_course(6, "1年2組 理科", 2, [2], 3, room_type="理科室") + result = solve(spec, time_limit_sec=20) + used: dict[tuple[int, int], int] = {} + for pl in result.placements: + if spec.courses[pl.course_id].room_type == "理科室": + used[(pl.day, pl.period)] = used.get((pl.day, pl.period), 0) + 1 + assert not used or max(used.values()) <= 1 + + +def test_locked_assignments_are_kept() -> None: + spec = simple_spec() + spec.locked = frozenset({Placement(1, 0, 0), Placement(2, 1, 2)}) + result = solve(spec, time_limit_sec=20) + assert Placement(1, 0, 0) in result.placements + assert Placement(2, 1, 2) in result.placements + + +def test_conflicting_locks_raise_with_reasons() -> None: + """固定同士が矛盾していたら、理由付きで拒否する(黙って解を返さない)。""" + spec = simple_spec() + # 同じ学級の同じコマに2講座を固定する + spec.locked = frozenset({Placement(1, 0, 0), Placement(2, 0, 0)}) + with pytest.raises(LockConflictError) as excinfo: + solve(spec, time_limit_sec=20) + codes = {v.code for v in excinfo.value.violations} + assert "H1" in codes + + +def test_parallel_group_members_share_slots() -> None: + """高校の選択科目: 群の講座は同一コマに並ぶ。""" + spec = ProblemSpec( + days=5, + periods_per_day=6, + class_names={1: "2年1組"}, + teachers={ + 1: TeacherSpec(1, "物理 教諭", 18), + 2: TeacherSpec(2, "化学 教諭", 18), + 3: TeacherSpec(3, "生物 教諭", 18), + 4: TeacherSpec(4, "国語 教諭", 18), + }, + courses={ + 1: make_course(1, "2年1組 物理基礎", 1, [1], 3), + 2: make_course(2, "2年1組 化学基礎", 2, [1], 3), + 3: make_course(3, "2年1組 生物基礎", 3, [1], 3), + 4: make_course(4, "2年1組 論理国語", 4, [1], 4), + }, + parallel_groups=(ParallelGroupSpec(1, "2年 理科選択", (1, 2, 3)),), + ) + result = solve(spec, time_limit_sec=20) + slots = {cid: set() for cid in (1, 2, 3)} + for pl in result.placements: + if pl.course_id in slots: + slots[pl.course_id].add(pl.slot) + assert slots[1] == slots[2] == slots[3] + assert len(slots[1]) == 3 + # 選択科目は学級から見れば1コマなので、国語4コマと合わせて週7コマ埋まる + assert result.unmet_periods == 0 + + +def test_parallel_group_does_not_trigger_class_conflict() -> None: + """同一学級に並列開講しても H1(学級の重複)にはならない。""" + spec = ProblemSpec( + days=5, + periods_per_day=6, + class_names={1: "2年1組"}, + teachers={1: TeacherSpec(1, "物理", 18), 2: TeacherSpec(2, "化学", 18)}, + courses={ + 1: make_course(1, "2年1組 物理基礎", 1, [1], 2), + 2: make_course(2, "2年1組 化学基礎", 2, [1], 2), + }, + parallel_groups=(ParallelGroupSpec(1, "理科選択", (1, 2)),), + ) + placements = [Placement(1, 0, 0), Placement(2, 0, 0), Placement(1, 1, 0), Placement(2, 1, 0)] + violations = validate(spec, placements) + assert [v for v in violations if v.code == "H1"] == [] + + +def test_validate_detects_manual_class_conflict() -> None: + spec = simple_spec() + violations = validate(spec, [Placement(1, 0, 0), Placement(2, 0, 0)]) + hard = [v for v in violations if v.severity == "hard"] + assert any(v.code == "H1" for v in hard) + assert "1年1組" in hard[0].message + + +def test_validate_detects_teacher_conflict() -> None: + spec = simple_spec() + # 講座1と3は同じ国語教諭 + violations = validate(spec, [Placement(1, 0, 0), Placement(3, 0, 0)]) + assert any(v.code == "H2" for v in violations) + + +def test_validate_detects_teacher_overload() -> None: + spec = simple_spec() + spec.teachers[1] = TeacherSpec(1, "国語 教諭1", 2) + placements = [Placement(1, 0, p) for p in range(4)] + violations = validate(spec, placements) + assert any(v.code == "S3" for v in violations) + + +def test_validate_detects_blocked_slot_after_manual_move() -> None: + spec = simple_spec() + spec.blocked = frozenset({(1, 0, 0)}) + violations = validate(spec, [Placement(1, 0, 0)]) + assert any(v.code == "H4" for v in violations) + + +def test_solver_output_passes_its_own_validator() -> None: + """求解結果を検証エンジンにかけてもハード違反が出ない(両者の定義が一致している)。""" + spec = simple_spec() + spec.courses[5] = make_course(5, "1年1組 理科", 1, [1], 3, room_type="理科室", prefers_double=True, max_per_day=2) + spec.room_capacity = {"理科室": 1} + result = solve(spec, time_limit_sec=20) + revalidated = validate(spec, result.placements) + assert [v.code for v in revalidated if v.severity == "hard"] == [] + assert [v.code for v in result.violations if v.severity == "hard"] == [] + + +def test_same_course_twice_in_one_slot_is_a_conflict() -> None: + """同じ講座を同じコマに2つ置くのは、たとえ同時展開群でも二重予約。""" + spec = simple_spec() + violations = validate(spec, [Placement(1, 0, 0), Placement(1, 0, 0)]) + assert any(v.code == "H1" for v in violations) + + grouped = ProblemSpec( + days=5, + periods_per_day=6, + class_names={1: "2年1組"}, + teachers={1: TeacherSpec(1, "物理", 18), 2: TeacherSpec(2, "化学", 18)}, + courses={ + 1: make_course(1, "2年1組 物理基礎", 1, [1], 2), + 2: make_course(2, "2年1組 化学基礎", 2, [1], 2), + }, + parallel_groups=(ParallelGroupSpec(1, "理科選択", (1, 2)),), + ) + assert any( + v.code == "H1" for v in validate(grouped, [Placement(1, 0, 0), Placement(1, 0, 0)]) + ) diff --git a/docs/assumptions.md b/docs/assumptions.md new file mode 100644 index 0000000..ccc42b9 --- /dev/null +++ b/docs/assumptions.md @@ -0,0 +1,219 @@ +# 仮定記録(Assumption Log) + +「細かく確認せず仮定を置いて開発を進める」方針で実装したため、判断が必要だった箇所を +すべてここに記録する。後から見直す際は「見直し条件」を手がかりにする。 + +記法: **A-nn** = 仮定ID。実装コード側にも `# 仮定 A-nn` の形でコメントを入れている。 + +| ID | 領域 | 置いた仮定 | 根拠 / 代替案 | 見直し条件 | +|---|---|---|---|---| + + + +## A-01〜A-09 スコープと全体構成 + +### A-01 単一校・単一年度・認証なし +- **仮定**: DBには学校を1件だけ持ち、認証・ユーザー管理・年度管理を実装しない。 +- **根拠**: `docs/requirements.md` 2.5節でスコープ外と宣言済み。E2Eを含めた完成までを + 優先するため、横断的関心事は後回しにした。 +- **見直し条件**: 教育委員会単位で複数校を扱う要望が出た時点。School テーブルは + 最初から複数行を持てる形にしてあるため、API のルーティングに `school_id` を + 通す改修で足りる。 + +### A-02 「講座(Course)」を割当の単位とする +- **仮定**: 時間割のセルに入るのは学級×教科ではなく **Course(講座)** とし、 + Course は「担当教員1名・受講学級1つ以上・週コマ数・使用教室種別」を持つ。 + 小中は「1学級=1講座」に縮退する。 +- **根拠**: 要件書 1.4節。高校の選択科目の同時展開と、特別支援学級の交流及び + 共同学習を、同一の仕組みで表現できる。 +- **見直し条件**: 生徒個人単位の履修登録を扱う場合、Course と生徒の関連が必要になる。 + +### A-03 週時間割のみを対象とし、年間時数の管理はしない +- **仮定**: 「週あたり何コマ」までを扱い、年間の授業時数の消化状況や行事による + 振替は扱わない。 +- **根拠**: 要件書 2.5節。年間管理まで含めると学校暦・行事予定のモデルが必要になる。 +- **見直し条件**: 実運用で「今年度の時数が足りているか」を見たいという要望が出た時点。 + +### A-04 バックエンド Python / フロントエンド React + TypeScript +- **仮定**: FastAPI + SQLAlchemy + SQLite + PuLP、フロントは Vite + React + TypeScript + + @dnd-kit。状態管理ライブラリは使わず React の useState/useReducer で足りる規模に留める。 +- **根拠**: ソルバー資産が Python にあること、フロントのスタックは利用者が指定済み。 +- **見直し条件**: 画面が増えて prop drilling が辛くなった時点で Zustand 等を検討。 + +### A-05 DBは単一ファイルのSQLite、マイグレーションはAlembicを使わない +- **仮定**: 起動時に `create_all()` でスキーマを作る。Alembicは導入しない。 +- **根拠**: まだ運用データが存在せず、スキーマ変更は作り直しで足りる段階のため。 + 導入コストをE2E完成に回した。 +- **見直し条件**: 実データを投入して運用を始める時点で必須になる。 + +### A-06 求解ジョブはインプロセスのスレッドで実行する +- **仮定**: Celery/RQ 等は使わず、`ThreadPoolExecutor` で求解し、ジョブ状態は + プロセス内の辞書に持つ。クライアントは2秒間隔でポーリングする。 +- **根拠**: 要件書 2.1節の実測で、必要なのは「25秒かかることがある処理を + HTTPリクエストの外に出すこと」だけであり、分散ワーカーは過剰。 + CBC の求解は subprocess で走るため GIL の影響を受けない。 +- **見直し条件**: 複数プロセスでAPIを動かす(uvicorn --workers 2以上)場合、 + ジョブ状態が共有されないため、DBかRedisに移す必要がある。 + +### A-07 求解には時間制限を設け、時間内の最良解を返す +- **仮定**: 時間内に最適解が出なければ、その時点の暫定解(実行可能解)を返し、 + 最適性が証明できたかどうかを `timed_out` で伝える。 +- **根拠**: 要件書 2.1節の実測で最大24.35秒。「解が出ない」で終わらせないため。 +- **補足**: 当初は60秒としたが、実装後の実測を踏まえて30秒に変更した(**A-27 参照**)。 +- **見直し条件**: 36学級かつ制約を全部有効にした状態で未充足コマが残るなら、 + 時間制限の延長か、変数固定によるウォームスタートを検討する。 + +### A-08 1コマの長さは校種既定(小45分・中高50分)で固定し、UIから変更させない +- **仮定**: 表示上の情報としてのみ持ち、求解には使わない。 +- **根拠**: 求解は「コマ枠」の割当問題であり、1コマが何分かは制約に影響しない。 +- **見直し条件**: 65分授業や短縮授業を扱う場合。 + +### A-09 曜日は月〜金の5日固定、1日のコマ数は学校単位の単一値 +- **仮定**: 土曜授業は扱わず、曜日ごとに異なるコマ数(月曜だけ5時間等)も持たない。 + 1日のコマ数は学校ごとに1つの値(小中6、高6)とし、`periods_per_day` で保持する。 +- **根拠**: 週5日制が原則。曜日別コマ数を入れるとUIとソルバーの両方が複雑になるため、 + まず均一なケースを完成させた。週コマ数が枠数を下回る分は空きコマになる。 +- **見直し条件**: 高校の7時間目設定や、月曜6時間・金曜5時間といった週時程を + 扱う場合。`periods_per_day` を曜日別の配列に変えれば対応できる設計にしてある。 + +## A-10〜A-19 ドメインモデルと生成データ + +### A-10 特別教室は「種別ごとの同時使用数」で扱う +- **仮定**: 個々の教室(理科室1・理科室2)を講座に割り当てるのではなく、 + 「同一コマに理科室を使えるのは2講座まで」という形で制約する。 +- **根拠**: 具体的にどの教室かは運用で決まることが多く、求解に第4の次元を + 持ち込むと変数が数倍になる。制約の本質は同時使用数の上限。 +- **見直し条件**: 教室ごとに設備差がある(生物室と化学室を区別したい)場合。 + +### A-11 矛盾する固定は求解前に拒否する +- **仮定**: 固定(ロック)された割当同士がハード制約に違反していたら、求解せず + 違反理由付きでジョブを失敗させる。勝手に固定を外して解くことはしない。 +- **根拠**: 固定は利用者の明示的な意思なので、黙って無視するのは裏切りになる。 +- **見直し条件**: 「矛盾する固定を自動で最小限だけ外して解く」機能が欲しくなった場合。 + +### A-12 同時展開群の講座は週コマ数を揃える +- **仮定**: 1つの群に属する講座は同じ週コマ数を持つ前提で生成する。 +- **根拠**: 選択科目は同じ時間帯に並ぶため、コマ数が違うと定義できない。 +- **見直し条件**: 週2単位と週3単位の科目を同じ選択群に入れたい場合(実在するが稀)。 + +### A-13 ハード制約は「空解で充足可能」な形にしてモデルの実行可能性を保証する +- **仮定**: H1〜H6 はすべて「1つも配置しない解」で満たせる形にしてある。 + したがってモデルは常に実行可能で、`Infeasible` は返らない。不足は必ず + S1(週コマ数の未充足)として数値で返る。 +- **根拠**: 要件書 1.2節のとおり週30枠に29〜30コマを詰める問題であり、 + 「解が返らない」画面を利用者に見せてはいけない。 +- **見直し条件**: なし(この設計は要件の中核)。 + +### A-14 週コマ数は年間標準授業時数から最大剰余法で導出する +- **仮定**: 「年間時数 ÷ 授業週数」の小数部が大きい教科から順に1コマずつ配り、 + 合計が総授業時数と一致するようにする。 +- **根拠**: 単純な四捨五入では合計が合わない(例: 小3は個別に丸めると28にならない)。 + 導出結果は小1=25、小4〜6=29、中=29、高=30となり、施行規則の総授業時数と一致する。 +- **見直し条件**: 学校独自の時数配当を使う場合は、この導出を使わず直接入力させる。 + +### A-15 1つの講座は高々1つの同時展開群にしか属さない +- **根拠**: 複数の群に属すると、どの群の時間帯に従うべきかが定義できない。 +- **見直し条件**: なし(多重所属は意味的に破綻するため)。 + +### A-16 教員1人あたりの担当学級数 +- **仮定**: 小学校の専科は外国語4・理科4・算数6・体育6学級、中学校は国語4・社会5・ + 数学4・理科4・音楽8・美術8・保健体育6・技術家庭8・外国語4学級、高校は既定4学級。 + ただし A-26 の上限で頭打ちにする。 +- **根拠**: 週の持ちコマ数が18コマ前後に収まるように置いた値。実配置は学校ごとに + 異なるため、あくまで初期データの目安。 +- **見直し条件**: 実際の校務分掌を取り込む場合は画面から編集する。 + +### A-17 学級担任を専任の教員として生成する +- **仮定**: 中学・高校でも「1年1組 担任」という教員を作り、道徳・総合・特別活動を + 担当させる。実際の担任は教科も持つが、生成データ上は分けている。 +- **根拠**: 担任と教科担任の兼務を自動で割り振ると、特定の教員に偏った不自然な + データになる。生成物は編集の出発点という位置づけ。 +- **見直し条件**: 実際の教員名簿を取り込む機能を作る時点で不要になる。 + +### A-18 特別教室の既定保有数 +- **仮定**: 校種ごとに最低保有数を持つ(例: 中学校は理科室2・音楽室1・美術室1・ + 技術室1・体育館1)。実際の数は A-25 の需要計算で増える。 +- **見直し条件**: 学校ごとの実際の保有数を入力させる場合。 + +### A-19 学級担任の持ちコマ上限は週29コマ +- **仮定**: 小学校の学級担任と特別支援学級の担任は週29コマ、教科担任・専科は週18コマ。 +- **根拠**: 小学校の担任は1日の大半を自学級で教えるため、教科担任の平均18.1コマ + (令和4年度教員勤務実態調査)を当てはめると必ず上限超過になる。 +- **見直し条件**: 学級担任の負担上限を運用ルールとして持つ場合。 + +## A-20〜A-28 実装中に判明して置き直した仮定 + +これらは実装・実測の結果として必要になった変更であり、当初の設計から変えた点。 + +### A-20 学校生成は「校種 × 1学年あたりの学級数」だけで行う +- **仮定**: 画面からは校種と学級数だけを指定し、教科・教員・講座・教室は自動生成する。 +- **根拠**: マスタを手入力させると最初の一歩が重すぎて、自動提案まで到達できない。 +- **見直し条件**: CSV取込を実装したら、生成とインポートを選べるようにする。 + +### A-21 再提案は新しいバージョンとして保存する +- **仮定**: 求解のたびに新しい Timetable を作り、元の版を parent_id で指す。 + 上書きはしない。 +- **根拠**: 「提案 → 手で直す → 再提案」を繰り返すため、前の版に戻れる必要がある。 +- **見直し条件**: 版が増えすぎたら、古い版の自動削除が要る。 + +### A-22 求解のキャンセルは「結果を保存しない」で実現する +- **仮定**: CBC の求解プロセス自体は中断できないため、キャンセルされたジョブは + 完了後に結果を破棄する。 +- **根拠**: 求解は最長でも設定した時間制限で終わるため、実害が小さい。 +- **見直し条件**: 時間制限を長く(数分)取る運用にする場合は、CBCをsubprocessとして + 自前で起動して kill できるようにする必要がある。 + +### A-23 CORS は既定で全許可 +- **仮定**: `TIMETABLE_CORS_ORIGINS` が未設定なら全オリジンを許可する。 +- **根拠**: 開発とE2Eの利便性。認証がなく秘匿データも持たない段階のため。 +- **見直し条件**: 公開運用する前に必ず絞る。 + +### A-24 同時展開(S4)はLP上は等式(ハード)として扱う +- **仮定**: ソルバーの中では「群の講座は必ず同一コマ」に固定する。検証エンジンは + 引き続きソフト違反として報告する。 +- **根拠**: H1(学級の重複)は、選択科目群を「学級から見て1コマ」として数えるために + 群インジケータ z を使っている。S4 を緩めると z と実際の配置が乖離し、 + **H1 が実質的にソフト制約になってしまう**(実際に中学校の生成データで + 「1年1組の火2限に国語と音楽が重複」というハード違反が発生した)。 + 空解で充足できるため、モデルが常に実行可能である性質は保たれる。 +- **効果**: 手編集で群を崩すことは引き続き可能で、そのときは S4 違反として警告される。 +- **見直し条件**: なし(H1の正しさが依存しているため)。 + +### A-25 特別教室の数は需要から自動算出する(稼働率75%目標) +- **仮定**: 保有数 = max(既定値, ceil(その教室を使う週コマ数の合計 ÷ (週の枠数 × 0.75)))。 +- **根拠**: 固定値だと大規模校で物理的に配置不能になる。19学級の小学校では理科だけで + 週36コマ必要なのに、週の枠は30コマしかなく、理科室1室では原理的に解けない + (実際に未充足34コマの解しか出なかった)。稼働率を100%と仮定すると、その教室が + 全コマ埋まっていないと成立しなくなり他の制約と両立しないため、75%を目標にした。 +- **見直し条件**: 実際の保有数を入力させる場合はこの計算を使わない。 + +### A-26 教員1人あたりの担当学級数は持ちコマ上限で頭打ちにする +- **仮定**: `担当学級数 = min(設定値, 18 ÷ その教科の週コマ数)`。 +- **根拠**: 算数専科を6学級担当にすると週30コマとなり、上限18コマを必ず超える。 + その状態ではソルバーがどう解いても S3 違反が残る解しか作れなかった。 +- **見直し条件**: 実際の校務分掌を入力する場合は不要。 + +### A-27 求解は相対ギャップ2%・時間制限30秒で打ち切る +- **仮定**: CBC に `gapRel=0.02`、既定 `timeLimit=30秒` を渡す。 +- **根拠**: 実測したところ、解の品質は15秒で頭打ちになり、そこから先の時間は + すべて最適性の証明に使われていた(15秒でも60秒でも違反件数が同一)。 + 最終的な求解時間は小学校19学級で5.3秒、中学校13学級で4.2秒、高校15学級で19.8秒、 + いずれも未充足0コマ・ハード違反0。 +- **見直し条件**: 未充足コマが常に残るようなら時間制限を延ばす。 + +### A-28 交流及び共同学習は「同じ講座に特別支援学級を追加する」形で表現する +- **仮定**: 特別支援学級の体育・音楽・図画工作などは、別講座を作って同時展開群で + 揃えるのではなく、通常学級の講座の受講学級に特別支援学級を加える。 +- **根拠**: 当初は同時展開群で実装したが、それだと同じ授業なのに音楽室を2室分 + 消費してしまい、音楽室1室の中学校で配置不能になった。実態としても + 「特別支援学級の児童生徒が通常学級の授業に参加する」のだから、講座は1つが正しい。 +- **効果**: 教室も教員も二重に数えなくなり、H1・H3 が自動的に正しくなる。 + 要件書の S5 は、同時展開制約ではなくデータ構造で保証される形になった。 +- **見直し条件**: 特別支援学級が独自の指導内容で同時刻に別室で学ぶ形(交流ではなく + 並行実施)を扱う場合は、同時展開群での表現に戻す。 + +### A-29 同一講座を同じコマに二重配置する操作はAPIで拒否する +- **仮定**: 移動・追加の際、同じ講座がすでにそのコマにあれば 409 を返す。 +- **根拠**: 固定された割当の集合は `(講座, 曜日, コマ)` の集合として持っているため、 + 完全な重複を表現できず、矛盾検査をすり抜けてしまう。入口で防ぐのが確実。 +- **見直し条件**: なし。 From 724cc980e17d21b28e7478536ba254610f868833 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 09:22:04 +0000 Subject: [PATCH 3/5] =?UTF-8?q?=E3=83=95=E3=83=AD=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=82=A8=E3=83=B3=E3=83=89=E3=83=BBE2E=E3=83=BBCI=E3=83=BBREAD?= =?UTF-8?q?ME=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=97=E3=80=812019=E5=B9=B4?= =?UTF-8?q?=E3=81=AE=E5=AE=9F=E9=A8=93=E3=82=92archive=E3=81=B8=E7=A7=BB?= =?UTF-8?q?=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React + TypeScript + @dnd-kit で、時間割をドラッグ&ドロップで微調整できる画面を 実装した。クラス別/教員別の切替、違反のハイライト、コマの固定、未配置トレイ、 undo/redo、CSV出力、求解の進捗表示とキャンセルを備える。 - 自動提案はジョブID+1秒ポーリングで待ち、完了したら新しい版として表示する - 手編集のたびにサーバの検証エンジンが違反を返し、ハード違反は赤・ソフトは黄で表示 - 「固定を保って再提案」で、鍵をかけたコマを動かさずに残りを組み直せる - 割当の一括置換APIを undo/redo に使い、操作ごとの逆操作を定義せずに済ませている Playwright の E2E は、バックエンドとフロントエンドを自動起動して 提案 → 編集 → 違反検出 → 固定 → 再提案 → 出力 の一連を通す。 実装中に見つけて直した点: - 学校を選んでから時間割が届くまでの一瞬だけセットアップ画面が表示され、 入力中に画面が入れ替わって操作が失われるレースがあった 2019年の強化学習の実験(env/, keras*.py, chainer0001.py)と線形計画法の ノートブックは archive/ へ移し、経緯を archive/README.md に残した。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DHGVNyssktZ69PWwmY1LCA --- .github/workflows/ci.yml | 84 + README.md | 167 +- archive/README.md | 21 + chainer0001.py => archive/chainer0001.py | 0 {env => archive/env}/__init__.py | 0 {env => archive/env}/case0000.py | 0 {env => archive/env}/case0001v0001.py | 0 {env => archive/env}/case0001v0002.py | 0 keras0000.py => archive/keras0000.py | 0 keras0001.py => archive/keras0001.py | 0 test0001.py => archive/test0001.py | 0 .../timetable_linear_programming.ipynb | 0 backend/app/db.py | 6 + backend/app/main.py | 9 +- docs/assumptions.md | 40 + e2e/.gitignore | 3 + e2e/package-lock.json | 78 + e2e/package.json | 13 + e2e/playwright.config.ts | 67 + e2e/tests/helpers.ts | 106 + e2e/tests/timetable.spec.ts | 243 +++ frontend/.gitignore | 2 + frontend/index.html | 12 + frontend/package-lock.json | 1796 +++++++++++++++++ frontend/package.json | 24 + frontend/src/App.tsx | 516 +++++ frontend/src/api.ts | 103 + frontend/src/components/SetupPanel.tsx | 132 ++ frontend/src/components/TimetableGrid.tsx | 230 +++ frontend/src/components/UnplacedTray.tsx | 70 + frontend/src/components/ViolationPanel.tsx | 51 + frontend/src/main.tsx | 10 + frontend/src/styles.css | 201 ++ frontend/src/types.ts | 156 ++ frontend/src/vite-env.d.ts | 9 + frontend/tsconfig.json | 17 + frontend/vite.config.ts | 17 + 37 files changed, 4175 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 archive/README.md rename chainer0001.py => archive/chainer0001.py (100%) rename {env => archive/env}/__init__.py (100%) rename {env => archive/env}/case0000.py (100%) rename {env => archive/env}/case0001v0001.py (100%) rename {env => archive/env}/case0001v0002.py (100%) rename keras0000.py => archive/keras0000.py (100%) rename keras0001.py => archive/keras0001.py (100%) rename test0001.py => archive/test0001.py (100%) rename timetable_linear_programming.ipynb => archive/timetable_linear_programming.ipynb (100%) create mode 100644 e2e/.gitignore create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/playwright.config.ts create mode 100644 e2e/tests/helpers.ts create mode 100644 e2e/tests/timetable.spec.ts create mode 100644 frontend/.gitignore create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/SetupPanel.tsx create mode 100644 frontend/src/components/TimetableGrid.tsx create mode 100644 frontend/src/components/UnplacedTray.tsx create mode 100644 frontend/src/components/ViolationPanel.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..38a71c2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: ['**'] + pull_request: + +jobs: + backend: + name: バックエンド(pytest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: backend/requirements.txt + - name: 依存をインストール + working-directory: backend + run: pip install -r requirements.txt + - name: テスト + working-directory: backend + run: python -m pytest -q + + frontend: + name: フロントエンド(型チェックとビルド) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: 依存をインストール + working-directory: frontend + run: npm ci + - name: 型チェック + working-directory: frontend + run: npm run typecheck + - name: ビルド + working-directory: frontend + run: npm run build + + e2e: + name: E2E(Playwright) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: backend/requirements.txt + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: | + frontend/package-lock.json + e2e/package-lock.json + - name: バックエンドの依存をインストール + working-directory: backend + run: pip install -r requirements.txt + - name: フロントエンドの依存をインストール + working-directory: frontend + run: npm ci + - name: E2Eの依存をインストール + working-directory: e2e + run: npm ci + - name: ブラウザをインストール + working-directory: e2e + run: npx playwright install --with-deps chromium + - name: E2E + working-directory: e2e + run: npx playwright test + - name: レポートを保存 + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: e2e/playwright-report/ + retention-days: 7 diff --git a/README.md b/README.md index 1001285..4a4d33b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,165 @@ -# 時間割最適化 -強化学習による時間割最適化の実験 +# 時間割自動編成 + +日本の小学校・中学校・高等学校の**週時間割**を整数計画法で自動提案し、 +人が画面上でドラッグ&ドロップで微調整して仕上げるためのWebアプリケーション。 + +- 学習指導要領の標準授業時数から、教科・週コマ数・教員・特別教室を自動生成する +- 制約を満たす時間割を数秒〜数十秒で提案する +- 提案結果を手で直せる。動かした瞬間に制約違反が赤/黄で表示される +- 気に入ったコマを**固定(ロック)**して、残りだけを組み直せる + +![構成](docs/requirements.md) + +--- + +## 構成 ``` -pip install numpy -pip install git+https://github.com/openai/gym -pip install tensorflow -pip install keras-rl +backend/ FastAPI + SQLAlchemy + PuLP(整数計画ソルバー) +frontend/ Vite + React + TypeScript + @dnd-kit +e2e/ Playwright(提案 → 編集 → 再提案 の一連を通す) +docs/ 要件定義(requirements.md)と仮定記録(assumptions.md) +benchmarks/ 実態規模での求解時間の計測スクリプト +archive/ 2019年の強化学習による実験(現在は使用していない) ``` +## 動かす + +前提: Python 3.11 以上、Node.js 20 以上。 + +```bash +# バックエンド +cd backend +pip install -r requirements.txt +python -m uvicorn app.main:app --reload --port 8000 + +# フロントエンド(別ターミナル) +cd frontend +npm install +npm run dev # http://127.0.0.1:5173 +``` + +ブラウザで http://127.0.0.1:5173 を開き、校種と1学年あたりの学級数を選んで +「作成する」→「自動提案」を押す。 + +## テスト + +```bash +cd backend && python -m pytest # ユニット/統合 50件 +cd frontend && npm run typecheck # 型チェック +cd e2e && npm install && npx playwright test # E2E(バックエンドとフロントを自動起動) +``` + +--- + +## 設計上の要点 + +### 1. 週30枠に29〜30コマを詰める問題である + +学習指導要領の標準授業時数を授業週数で割ると、必要な週コマ数はこうなる。 + +| 学年 | 週コマ数 | 週の枠数(5日×6コマ) | +|---|---|---| +| 小1 | 25 | 30 | +| 小4〜6・中1〜3 | **29** | 30 | +| 高(全日制標準) | **30** | 30 | + +空き枠は週0〜1コマしかない。**ほぼ完全充填問題**であり、少しでも条件を足すと +ハード制約のままでは実行不可能になる。そのため次の設計を採っている。 + +### 2. 制約はすべてソフト制約として扱い、「解なし」を返さない + +ハード制約(H1〜H6)は**すべて「1つも配置しない解」で充足できる形**にしてある。 +したがってモデルは常に実行可能で、`Infeasible` は返らない。条件が厳しすぎる場合は +「何コマ足りないか」が S1(週コマ数の未充足)として必ず数値で返る。 + +利用者の画面に「解が見つかりませんでした」とだけ出る状態を作らないための設計。 + +### 3. 制約の定義は1か所にしかない + +`backend/app/solver/constraints.py` の各制約クラスが2つの顔を持つ。 + +```python +class TeacherOccupancy(Constraint): + def apply(self, spec, ctx): # 整数計画モデルに制約を追加する(自動提案) + def check(self, spec, places): # 既存の割当を検査して違反を返す(手編集の検証) +``` + +ソルバーと検証器を別々に書くと「画面上はOKなのに再提案すると弾かれる」というズレが +必ず起きる。同じクラスに両方を持たせることで、構造的にそれを防いでいる。 + +### 4. 割当の単位は「学級×教科」ではなく「講座」 + +高校の選択科目では、1つの学級の生徒が同じ時間帯に物理・化学・生物へ分かれる。 +つまり**学級と受講集団は一致しない**。そこで時間割のセルに入るのは Course(講座)とし、 +講座は「担当教員1名・受講学級1つ以上・週コマ数」を持つ。小中は「1学級=1講座」に縮退する。 + +選択科目群(ParallelGroup)は学級から見れば1コマなので、学級の重複チェック(H1)は +群を1単位として数える。 + +### 5. 求解は非同期、検証は同期 + +実測すると、小・中学校は数秒だが高校は状況により20秒を超える。HTTPリクエストの中で +待たせられないため、求解はジョブID+ポーリングにしている。一方、手編集の検証は +ミリ秒で終わるので同期で返す。 + +--- + +## 実測値 + +`benchmarks/solver_benchmark.py` と、アプリが生成する実データでの計測。 + +| インスタンス | 講座数 | 求解時間 | 未配置 | ハード違反 | +|---|---|---|---|---| +| 小学校 19学級(3学級/学年+特支) | 194 | 5.3秒 | 0 | 0 | +| 中学校 13学級(4学級/学年+特支) | 156 | 4.2秒 | 0 | 0 | +| 高等学校 15学級(5学級/学年) | 205 | 19.8秒 | 0 | 0 | + +解の品質は15秒で頭打ちになり、それ以降の時間はすべて最適性の証明に費やされる。 +そのため相対ギャップ2%・時間制限30秒で打ち切っている。 + +## 実装済みの制約 + +ハード制約(違反しない) + +| コード | 内容 | +|---|---| +| H1 | 1つの学級の同一コマに2つ以上の講座を置かない(選択科目群は1コマと数える) | +| H2 | 1人の教員が同一コマに2つ以上の講座を担当しない | +| H3 | 特別教室の同時使用数が保有数を超えない | +| H4 | 配置禁止枠には置かない | +| H5 | 教員の不在枠には置かない | +| H6 | 固定された割当は動かさない | + +ソフト制約(重み付きで最小化。違反は画面に黄色で表示される) + +| コード | 内容 | 重み | +|---|---|---| +| S1 | 講座の週コマ数を満たす | 1000 | +| S2 | 同じ講座を1日に置ける回数の上限(週内分散も兼ねる) | 100 | +| S3 | 教員の週持ちコマ数の上限(既定18コマ) | 50 | +| S4 | 選択科目群を同一コマに揃える | 500 | +| S8 | 実験・実習・体育を2コマ連続で配置する | 20 | +| S9 | 主要教科を午前(1〜4限)に寄せる | 5 | + +特別支援学級の交流及び共同学習(要件書の S5)は、制約ではなく +「通常学級の講座に特別支援学級を追加する」というデータ構造で保証している。 +教員の空きコマ分散(S7)と担任の自クラス滞在(S10)は未実装。 + +## 前提と仮定 + +「細かく確認せず仮定を置いて進める」方針で実装したため、判断が必要だった箇所は +**すべて `docs/assumptions.md` に A-01〜A-31 として記録**してある。 +各仮定には根拠と「見直し条件」を付けているので、実運用に載せる際はそこから確認できる。 + +実測の結果として当初の設計から変えた点(A-24〜A-29)も、変えた理由とともに残してある。 + +## 経緯 + +このリポジトリは2019年に強化学習(DQN)で時間割を解く実験として始まったが、 +実問題は解けないまま止まっていた。同時期に試された線形計画法のノートブックだけが +最適解に到達していたため、そちらを土台としてWebアプリケーションに作り直した。 +当時のコードは `archive/` に残してある。 + +- 要件定義: [docs/requirements.md](docs/requirements.md) +- 仮定記録: [docs/assumptions.md](docs/assumptions.md) diff --git a/archive/README.md b/archive/README.md new file mode 100644 index 0000000..7c1fb58 --- /dev/null +++ b/archive/README.md @@ -0,0 +1,21 @@ +# アーカイブ(2019年の実験) + +このディレクトリには、2019年7月に行われた初期の実験を保存している。 +現行のアプリケーションでは使用していない。 + +## 強化学習アプローチ(未達成) + +`env/` は OpenAI Gym の環境として時間割問題を定義したもの。`keras0000.py` は +最小ケース(2学級・3教科)、`keras0001.py` と `chainer0001.py` は実問題 +(6学級・16教科)を DQN で解こうとしたが、実問題は解けないまま中断している。 + +依存ライブラリ(旧 gym / keras-rl / Chainer・ChainerRL)はいずれも開発が終了して +おり、そのままでは実行できない。また `_is_done()` の中で `sys.exit(0)` を呼んで +いるため、解が見つかった瞬間にプロセスが終了して学習ループが完了しない不具合が +残っている。 + +## 線形計画法アプローチ(成功) + +`timetable_linear_programming.ipynb` は PuLP による整数計画の実験で、 +5学級×6日×5コマの時間割で最適解に到達している。現行の `backend/app/solver/` +はこのノートブックを出発点としている。 diff --git a/chainer0001.py b/archive/chainer0001.py similarity index 100% rename from chainer0001.py rename to archive/chainer0001.py diff --git a/env/__init__.py b/archive/env/__init__.py similarity index 100% rename from env/__init__.py rename to archive/env/__init__.py diff --git a/env/case0000.py b/archive/env/case0000.py similarity index 100% rename from env/case0000.py rename to archive/env/case0000.py diff --git a/env/case0001v0001.py b/archive/env/case0001v0001.py similarity index 100% rename from env/case0001v0001.py rename to archive/env/case0001v0001.py diff --git a/env/case0001v0002.py b/archive/env/case0001v0002.py similarity index 100% rename from env/case0001v0002.py rename to archive/env/case0001v0002.py diff --git a/keras0000.py b/archive/keras0000.py similarity index 100% rename from keras0000.py rename to archive/keras0000.py diff --git a/keras0001.py b/archive/keras0001.py similarity index 100% rename from keras0001.py rename to archive/keras0001.py diff --git a/test0001.py b/archive/test0001.py similarity index 100% rename from test0001.py rename to archive/test0001.py diff --git a/timetable_linear_programming.ipynb b/archive/timetable_linear_programming.ipynb similarity index 100% rename from timetable_linear_programming.ipynb rename to archive/timetable_linear_programming.ipynb diff --git a/backend/app/db.py b/backend/app/db.py index c874995..f2dc8b4 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -28,6 +28,12 @@ def init_db() -> None: Base.metadata.create_all(engine) +def reset_db() -> None: + """スキーマを作り直す。E2E のように毎回まっさらな状態で始めたいときに使う。""" + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + + def get_db() -> Iterator[Session]: db = SessionLocal() try: diff --git a/backend/app/main.py b/backend/app/main.py index b775ac0..ac5ddd3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware from .api import router -from .db import init_db +from .db import init_db, reset_db app = FastAPI( title="時間割自動編成API", @@ -35,7 +35,12 @@ @app.on_event("startup") def on_startup() -> None: - init_db() + # 仮定 A-31: E2E は毎回まっさらな状態から始めたいので、環境変数で明示された + # ときだけスキーマを作り直す。通常起動では既存データを保持する。 + if os.environ.get("TIMETABLE_E2E_RESET") == "1": + reset_db() + else: + init_db() @app.get("/health") diff --git a/docs/assumptions.md b/docs/assumptions.md index ccc42b9..6a600c6 100644 --- a/docs/assumptions.md +++ b/docs/assumptions.md @@ -217,3 +217,43 @@ - **根拠**: 固定された割当の集合は `(講座, 曜日, コマ)` の集合として持っているため、 完全な重複を表現できず、矛盾検査をすり抜けてしまう。入口で防ぐのが確実。 - **見直し条件**: なし。 + +## A-30〜A-34 フロントエンドとE2E + +### A-30 求解ジョブのポーリング間隔は1秒 +- **仮定**: 要件書では2秒としていたが、実装では1秒にした。 +- **根拠**: 小・中規模校は数秒で解けるため、2秒間隔だと体感が鈍い。ポーリングの + コストは1リクエスト/秒で無視できる。E2Eの所要時間も短くなる。 +- **見直し条件**: 同時利用者が増えてAPIの負荷が問題になる場合。 + +### A-31 E2Eのときだけスキーマを作り直す +- **仮定**: 環境変数 `TIMETABLE_E2E_RESET=1` のときだけ、起動時に + `drop_all` → `create_all` を実行する。通常起動では既存データを保持する。 +- **根拠**: E2Eは毎回まっさらな状態から始めたい一方、開発中の手動確認では + データが消えると困る。 +- **見直し条件**: なし。 + +### A-32 undo/redo はサーバ側の一括置換で実現する +- **仮定**: フロントが操作前の割当スナップショットをスタックに積み、 + 戻すときは `PUT /api/timetables/{id}/assignments` で丸ごと置き換える。 + 差分を持たず、履歴もサーバに保存しない(リロードすると消える)。 +- **根拠**: 1つの時間割は数百コマ程度で、丸ごと置き換えても実用上問題ない。 + 操作ごとの逆操作を定義するより、間違いが起きにくい。 +- **見直し条件**: 複数人が同じ時間割を同時に編集する場合は、丸ごと置換だと + 他人の変更を消してしまうため、差分+競合検出が必要になる。 + +### A-33 読み込み中はセットアップ画面を出さない +- **仮定**: 学校を選んでから時間割が届くまでの間は「読み込み中」を表示し、 + 「学校を作る」画面を出さない。 +- **根拠**: E2Eで実際に問題が起きた。学校選択の直後は一瞬 `timetable === null` + になるため、セットアップ画面が現れ、そこへ入力している最中に時間割が届いて + 画面が入れ替わり、入力が失われた。利用者の操作でも同じことが起こりうる。 +- **見直し条件**: なし(実害のあるちらつきの修正)。 + +### A-34 E2Eはこの環境に用意されたChromiumを直接指す +- **仮定**: `/opt/pw-browsers/chromium` が存在すればそれを `executablePath` に使う。 + 存在しなければ Playwright の既定の解決に任せる。 +- **根拠**: `@playwright/test` のバージョンによって期待するブラウザのリビジョンが + 変わり、この実行環境に焼き込まれているリビジョンと一致しないことがある。 +- **見直し条件**: CI では `npx playwright install chromium` を実行しているため、 + この分岐は使われない。 diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 0000000..dbd64df --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..6e53ba1 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,78 @@ +{ + "name": "timetable-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "timetable-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.49.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..cbd060f --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "timetable-e2e", + "private": true, + "version": "1.0.0", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.49.1" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 0000000..d191316 --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,67 @@ +import { defineConfig, devices } from '@playwright/test' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' + +// この環境には Chromium が焼き込まれている(PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers)。 +// @playwright/test のバージョンによって期待するリビジョンが変わるため、 +// ブラウザが用意されている場合は実体を直接指す。 +const BUNDLED_CHROMIUM = '/opt/pw-browsers/chromium' +const executablePath = fs.existsSync(BUNDLED_CHROMIUM) ? BUNDLED_CHROMIUM : undefined + +// E2E は毎回まっさらなDBで動かす。求解結果が前回の実行に影響されないようにするため。 +const DB_PATH = path.join(os.tmpdir(), `timetable-e2e-${process.env.PW_DB_TAG ?? 'default'}.db`) + +export default defineConfig({ + testDir: './tests', + // 求解ジョブを含むため、既定より長めに取る + timeout: 120_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + // 単一のバックエンドプロセス(=インプロセスのジョブ表とSQLite)を共有するため直列実行 + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL: 'http://127.0.0.1:5173', + // 既定は無制限。要素が現れないまま待ち続けてテスト全体のタイムアウトを + // 食い潰すのを避けるため、操作単位でも上限を設ける + actionTimeout: 15_000, + navigationTimeout: 30_000, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + locale: 'ja-JP', + timezoneId: 'Asia/Tokyo', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], launchOptions: { executablePath } }, + }, + ], + webServer: [ + { + command: 'python3 -m uvicorn app.main:app --host 127.0.0.1 --port 8000', + cwd: path.join(__dirname, '..', 'backend'), + url: 'http://127.0.0.1:8000/health', + reuseExistingServer: false, + timeout: 60_000, + env: { + TIMETABLE_DATABASE_URL: `sqlite:///${DB_PATH}`, + TIMETABLE_E2E_RESET: '1', + }, + stdout: 'pipe', + stderr: 'pipe', + }, + { + command: 'npm run dev -- --host 127.0.0.1 --port 5173', + cwd: path.join(__dirname, '..', 'frontend'), + url: 'http://127.0.0.1:5173', + reuseExistingServer: false, + timeout: 60_000, + stdout: 'pipe', + stderr: 'pipe', + }, + ], +}) diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts new file mode 100644 index 0000000..7693cbc --- /dev/null +++ b/e2e/tests/helpers.ts @@ -0,0 +1,106 @@ +import { expect, type Locator, type Page } from '@playwright/test' + +/** + * dnd-kit の PointerSensor に対するドラッグ&ドロップ。 + * + * PointerSensor は activationConstraint.distance を持つため、押下したあと + * 一定距離動かさないとドラッグが始まらない。マウス移動を複数ステップに分け、 + * 開始直後に小さく動かしてドラッグを確実に発火させている。 + */ +export async function dragTo(page: Page, source: Locator, target: Locator): Promise { + await source.scrollIntoViewIfNeeded() + const from = await source.boundingBox() + if (!from) throw new Error('ドラッグ元の位置を取得できません') + await target.scrollIntoViewIfNeeded() + const to = await target.boundingBox() + if (!to) throw new Error('ドロップ先の位置を取得できません') + + const start = { x: from.x + from.width / 2, y: from.y + from.height / 2 } + const end = { x: to.x + to.width / 2, y: to.y + to.height / 2 } + + await page.mouse.move(start.x, start.y) + await page.mouse.down() + // 活性化距離(4px)を超えるための最初の一歩 + await page.mouse.move(start.x + 8, start.y + 8, { steps: 4 }) + await page.mouse.move(end.x, end.y, { steps: 12 }) + await page.mouse.move(end.x, end.y) + await page.mouse.up() +} + +export interface SchoolOptions { + name: string + type: '小学校' | '中学校' | '高等学校' + classesPerGrade: number + specialNeeds?: boolean +} + +/** + * 学校を作成する。作成後はセットアップ画面のまま(まだ時間割はない)。 + * + * 既に時間割が表示されている場合(前のテストで作った学校が自動選択されている等)は、 + * 先に「新しい学校を作る」を押してセットアップ画面へ戻す。読み込み中に画面が + * 入れ替わると入力が失われるため、表示が落ち着くまで待ってから操作する。 + */ +export async function createSchool(page: Page, options: SchoolOptions): Promise { + await expect(page.getByTestId('school-loading')).toHaveCount(0) + await expect( + page.getByTestId('setup-panel').or(page.getByTestId('btn-new-school')), + ).toBeVisible() + if ((await page.getByTestId('setup-panel').count()) === 0) { + await page.getByTestId('btn-new-school').click() + } + await expect(page.getByTestId('setup-panel')).toBeVisible() + await page.getByTestId('input-school-name').fill(options.name) + await page.getByTestId('select-school-type').selectOption({ label: options.type }) + await page.getByTestId('input-classes-per-grade').fill(String(options.classesPerGrade)) + if (options.specialNeeds) { + await page.getByTestId('input-special-needs').check() + } + await page.getByTestId('btn-create-school').click() + await expect(page.getByTestId('notice-banner')).toContainText(options.name) +} + +/** 自動提案を実行し、時間割が表示されるまで待つ。 */ +export async function solve(page: Page, testId: 'btn-solve' | 'btn-resolve' = 'btn-solve'): Promise { + await page.getByTestId(testId).click() + await expect(page.getByTestId('notice-banner')).toContainText('提案しました', { timeout: 90_000 }) + await expect(page.getByTestId('timetable-grid')).toBeVisible() +} + +/** 表示中のグリッドにある最初のコマ(チップ)を返す。 */ +export function firstChip(page: Page): Locator { + return page.getByTestId('timetable-grid').locator('.chip').first() +} + +/** グリッド上のチップの割当IDを返す。 */ +export async function assignmentIdOf(chip: Locator): Promise { + const testId = await chip.getAttribute('data-testid') + if (!testId) throw new Error('チップの data-testid がありません') + return testId.replace('chip-', '') +} + +/** 指定した学級の時間割で、空いているコマを1つ探す。 */ +export async function findEmptyCell(page: Page): Promise { + const grid = page.getByTestId('timetable-grid') + const cells = grid.locator('[data-testid^="cell-"]') + const count = await cells.count() + for (let i = 0; i < count; i++) { + const cell = cells.nth(i) + if ((await cell.locator('.chip').count()) === 0) return cell + } + throw new Error('空いているコマが見つかりません') +} + +/** 指定した学級の時間割で、チップが入っているコマを2つ探す(同一学級・別コマ)。 */ +export async function findTwoOccupiedCells(page: Page): Promise<[Locator, Locator]> { + const grid = page.getByTestId('timetable-grid') + const cells = grid.locator('[data-testid^="cell-"]') + const count = await cells.count() + const occupied: Locator[] = [] + for (let i = 0; i < count && occupied.length < 2; i++) { + const cell = cells.nth(i) + if ((await cell.locator('.chip').count()) === 1) occupied.push(cell) + } + if (occupied.length < 2) throw new Error('埋まっているコマが2つ見つかりません') + return [occupied[0], occupied[1]] +} diff --git a/e2e/tests/timetable.spec.ts b/e2e/tests/timetable.spec.ts new file mode 100644 index 0000000..0e30349 --- /dev/null +++ b/e2e/tests/timetable.spec.ts @@ -0,0 +1,243 @@ +import { expect, test } from '@playwright/test' +import { + assignmentIdOf, + createSchool, + dragTo, + findEmptyCell, + findTwoOccupiedCells, + firstChip, + solve, +} from './helpers' + +test.describe('時間割の自動提案と微調整', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/') + await expect(page.getByRole('heading', { name: '時間割自動編成' })).toBeVisible() + }) + + test('学校を作って自動提案すると、ハード違反なしで全コマが埋まる', async ({ page }) => { + await createSchool(page, { name: 'E2E中学校', type: '中学校', classesPerGrade: 1 }) + await solve(page) + + await expect(page.getByTestId('hard-count')).toHaveText('0') + await expect(page.getByTestId('unmet-count')).toHaveText('0') + + const placed = await page.getByTestId('placed-count').textContent() + expect(Number(placed)).toBeGreaterThan(0) + + // クラス別ビューに、その学級の週コマ数ぶんのチップが並ぶ + const chips = page.getByTestId('timetable-grid').locator('.chip') + await expect.poll(async () => chips.count()).toBeGreaterThanOrEqual(25) + }) + + test('コマをドラッグして重ねると違反が出て、元に戻すと消える', async ({ page }) => { + await createSchool(page, { name: 'E2E中学校2', type: '中学校', classesPerGrade: 1 }) + await solve(page) + await expect(page.getByTestId('hard-count')).toHaveText('0') + + const [source, target] = await findTwoOccupiedCells(page) + await dragTo(page, source.locator('.chip'), target) + + // 同じ学級の同じコマに2つ入るので H1 違反になる + await expect(page.getByTestId('hard-count')).not.toHaveText('0') + await expect(page.getByTestId('violation-H1').first()).toContainText('重複しています') + await expect(target.locator('.chip')).toHaveCount(2) + await expect(target).toHaveAttribute('data-violation', 'hard') + + // 元に戻す + await page.getByTestId('btn-undo').click() + await expect(page.getByTestId('hard-count')).toHaveText('0') + await expect(target.locator('.chip')).toHaveCount(1) + + // やり直す + await page.getByTestId('btn-redo').click() + await expect(page.getByTestId('hard-count')).not.toHaveText('0') + + // もう一度戻して綺麗な状態に + await page.getByTestId('btn-undo').click() + await expect(page.getByTestId('hard-count')).toHaveText('0') + }) + + test('コマを未配置トレイへ外し、別のコマへ戻せる', async ({ page }) => { + await createSchool(page, { name: 'E2E中学校3', type: '中学校', classesPerGrade: 1 }) + await solve(page) + await expect(page.getByTestId('tray-empty')).toBeVisible() + + const chip = firstChip(page) + const courseId = await chip.getAttribute('data-course-id') + await dragTo(page, chip, page.getByTestId('unplaced-tray')) + + // トレイに未配置として現れ、未配置コマ数が1になる + const unplaced = page.getByTestId(`unplaced-${courseId}`) + await expect(unplaced).toBeVisible() + await expect(page.getByTestId('unmet-count')).toHaveText('1') + + // 空いているコマへ戻す + const empty = await findEmptyCell(page) + await dragTo(page, unplaced, empty) + await expect(page.getByTestId('unmet-count')).toHaveText('0') + await expect(page.getByTestId('tray-empty')).toBeVisible() + await expect(page.getByTestId('hard-count')).toHaveText('0') + }) + + test('固定したコマは再提案しても動かない', async ({ page }) => { + await createSchool(page, { name: 'E2E中学校4', type: '中学校', classesPerGrade: 2 }) + await solve(page) + + // 先頭の3コマを固定する + const grid = page.getByTestId('timetable-grid') + const lockedCells: { day: string; period: string; course: string }[] = [] + for (let i = 0; i < 3; i++) { + const chip = grid.locator('.chip[data-locked="false"]').first() + const id = await assignmentIdOf(chip) + const course = await chip.getAttribute('data-course-id') + const cell = chip.locator('xpath=ancestor::div[starts-with(@data-testid,"cell-")]') + const cellId = (await cell.getAttribute('data-testid'))!.split('-') + lockedCells.push({ day: cellId[1], period: cellId[2], course: course! }) + await page.getByTestId(`lock-${id}`).click() + await expect(page.getByTestId('locked-count')).toHaveText(String(i + 1)) + } + + const versionsBefore = await page.getByTestId('select-version').locator('option').count() + + await solve(page, 'btn-resolve') + + // 新しい版ができ、固定は引き継がれている + await expect(page.getByTestId('select-version').locator('option')).toHaveCount( + versionsBefore + 1, + ) + await expect(page.getByTestId('locked-count')).toHaveText('3') + await expect(page.getByTestId('hard-count')).toHaveText('0') + + // 固定したコマが同じ位置に残っている + for (const locked of lockedCells) { + const cell = page.getByTestId(`cell-${locked.day}-${locked.period}`) + await expect(cell.locator(`.chip[data-course-id="${locked.course}"]`)).toHaveCount(1) + } + }) + + test('固定したコマはドラッグできない', async ({ page }) => { + await createSchool(page, { name: 'E2E中学校5', type: '中学校', classesPerGrade: 1 }) + await solve(page) + + const [source, target] = await findTwoOccupiedCells(page) + const chip = source.locator('.chip') + const id = await assignmentIdOf(chip) + await page.getByTestId(`lock-${id}`).click() + await expect(page.getByTestId('locked-count')).toHaveText('1') + + await dragTo(page, chip, target) + + // 動いていない(元のコマに残っている) + await expect(source.locator(`[data-testid="chip-${id}"]`)).toHaveCount(1) + await expect(page.getByTestId('hard-count')).toHaveText('0') + }) + + test('クラス別と教員別を切り替えられる', async ({ page }) => { + await createSchool(page, { name: 'E2E中学校6', type: '中学校', classesPerGrade: 1 }) + await solve(page) + + const classOptions = await page.getByTestId('select-entity').locator('option').allTextContents() + expect(classOptions).toContain('1年1組') + + await page.getByTestId('btn-view-teacher').click() + const teacherOptions = await page + .getByTestId('select-entity') + .locator('option') + .allTextContents() + expect(teacherOptions.length).toBeGreaterThan(0) + expect(teacherOptions.some((t) => t.includes('教諭') || t.includes('担任'))).toBe(true) + + // 教員別ビューでも、その教員のコマだけが並ぶ + await expect(page.getByTestId('timetable-grid').locator('.chip').first()).toBeVisible() + + await page.getByTestId('btn-view-class').click() + await expect(page.getByTestId('select-entity')).toHaveValue(/\d+/) + }) + + test('違反をクリックすると該当する対象へ移動する', async ({ page }) => { + await createSchool(page, { name: 'E2E中学校7', type: '中学校', classesPerGrade: 1 }) + await solve(page) + + // 別の学級に切り替えてから違反を作る + const [source, target] = await findTwoOccupiedCells(page) + await dragTo(page, source.locator('.chip'), target) + await expect(page.getByTestId('hard-count')).not.toHaveText('0') + + const beforeEntity = await page.getByTestId('select-entity').inputValue() + // 教員別ビューへ移ってから違反行をクリックすると、クラス別へ戻ってくる + await page.getByTestId('btn-view-teacher').click() + await page.getByTestId('violation-H1').first().click() + await expect(page.getByTestId('btn-view-class')).toHaveClass(/active/) + await expect(page.getByTestId('select-entity')).toHaveValue(beforeEntity) + }) + + test('CSVを出力できる(クラス別・教員別)', async ({ page }) => { + await createSchool(page, { name: 'E2E中学校8', type: '中学校', classesPerGrade: 1 }) + await solve(page) + + for (const testId of ['link-export-class', 'link-export-teacher']) { + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByTestId(testId).click(), + ]) + expect(download.suggestedFilename()).toMatch(/\.csv$/) + const stream = await download.createReadStream() + const chunks: Buffer[] = [] + for await (const chunk of stream) chunks.push(Buffer.from(chunk)) + const text = Buffer.concat(chunks).toString('utf8') + expect(text).toContain('対象,曜日,1限') + expect(text).toContain('月') + } + }) + + test('小学校を作ると特別支援学級の交流授業が同じ講座になる', async ({ page }) => { + await createSchool(page, { + name: 'E2E小学校', + type: '小学校', + classesPerGrade: 1, + specialNeeds: true, + }) + await solve(page) + + await expect(page.getByTestId('hard-count')).toHaveText('0') + + // 特別支援学級のビューに切り替えると、体育などが通常学級と同じ講座で並ぶ + await page.getByTestId('select-entity').selectOption({ label: '特別支援学級' }) + const chips = page.getByTestId('timetable-grid').locator('.chip') + await expect.poll(async () => chips.count()).toBeGreaterThan(10) + }) + + test('高校の選択科目は同じコマに並列開講される', async ({ page }) => { + await createSchool(page, { name: 'E2E高校', type: '高等学校', classesPerGrade: 1 }) + await solve(page) + + await expect(page.getByTestId('hard-count')).toHaveText('0') + + // 3年生は地歴選択と理科選択があるため、1つのコマに複数のチップが並ぶ + await page.getByTestId('select-entity').selectOption({ label: '3年1組' }) + const grid = page.getByTestId('timetable-grid') + const cells = grid.locator('[data-testid^="cell-"]') + let multi = 0 + for (let i = 0; i < (await cells.count()); i++) { + if ((await cells.nth(i).locator('.chip').count()) > 1) multi++ + } + expect(multi).toBeGreaterThan(0) + }) + + test('学校を切り替えても状態が保たれる', async ({ page }) => { + await createSchool(page, { name: 'E2E校A', type: '中学校', classesPerGrade: 1 }) + await solve(page) + await expect(page.getByTestId('hard-count')).toHaveText('0') + + await page.getByTestId('btn-new-school').click() + await createSchool(page, { name: 'E2E校B', type: '小学校', classesPerGrade: 1 }) + await solve(page) + await expect(page.getByTestId('hard-count')).toHaveText('0') + + // 学校セレクタから元の学校に戻すと、その学校の時間割が復元される + await page.getByTestId('select-school').selectOption({ label: 'E2E校A(中学校)' }) + await expect(page.getByTestId('timetable-grid')).toBeVisible() + await expect(page.getByTestId('select-entity').locator('option').first()).toHaveText('1年1組') + }) +}) diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..421adb0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + 時間割自動編成 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9a4ed35 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1796 @@ +{ + "name": "timetable-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "timetable-frontend", + "version": "1.0.0", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5e45e9b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "timetable-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^5.4.11" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..5751eba --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,516 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' +import type { DragEndEvent } from '@dnd-kit/core' +import { api, ApiError } from './api' +import { SetupPanel } from './components/SetupPanel' +import { TimetableGrid, computeHighlight } from './components/TimetableGrid' +import type { ViewMode } from './components/TimetableGrid' +import { UnplacedTray } from './components/UnplacedTray' +import { ViolationPanel } from './components/ViolationPanel' +import type { + Assignment, + AssignmentSnapshot, + Meta, + SchoolSummary, + SolveJob, + Timetable, + TimetableRow, + Violation, +} from './types' + +// 仮定 A-30: 求解ジョブのポーリング間隔。要件では2秒としていたが、小・中規模校は +// 数秒で解けるため、体感とE2Eの所要時間を優先して1秒にした。 +const POLL_INTERVAL_MS = 1000 + +function snapshotOf(timetable: Timetable): AssignmentSnapshot[] { + return timetable.assignments.map((a) => ({ + course_id: a.course_id, + day: a.day, + period: a.period, + locked: a.locked, + })) +} + +export default function App() { + const [meta, setMeta] = useState(null) + const [schools, setSchools] = useState([]) + const [schoolId, setSchoolId] = useState(null) + const [versions, setVersions] = useState([]) + const [timetable, setTimetable] = useState(null) + const [view, setView] = useState('class') + const [entityId, setEntityId] = useState(null) + const [job, setJob] = useState(null) + const [busy, setBusy] = useState(false) + // 学校を選んでから、その時間割が届くまでの間。ここで「学校を作る」画面を + // 出してしまうと、読み込み完了時に画面が入れ替わって操作が失われる + const [schoolLoading, setSchoolLoading] = useState(false) + const [error, setError] = useState(null) + const [notice, setNotice] = useState(null) + const [past, setPast] = useState([]) + const [future, setFuture] = useState([]) + const pollTimer = useRef(null) + + // ---- 初期ロード ---- + useEffect(() => { + void (async () => { + try { + const [m, s] = await Promise.all([api.meta(), api.listSchools()]) + setMeta(m) + setSchools(s) + if (s.length > 0) { + setSchoolLoading(true) + setSchoolId(s[0].id) + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } + })() + }, []) + + const loadVersions = useCallback(async (id: number) => { + const rows = await api.listTimetables(id) + setVersions(rows) + return rows + }, []) + + useEffect(() => { + if (schoolId === null) { + setSchoolLoading(false) + return + } + void (async () => { + try { + const rows = await loadVersions(schoolId) + if (rows.length > 0) { + const tt = await api.getTimetable(rows[0].id) + applyTimetable(tt, true) + } else { + setTimetable(null) + } + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSchoolLoading(false) + } + })() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [schoolId]) + + const applyTimetable = (tt: Timetable, resetHistory = false) => { + setTimetable(tt) + if (resetHistory) { + setPast([]) + setFuture([]) + } + setEntityId((current) => { + const ids = + view === 'class' ? tt.classes.map((c) => c.id) : tt.teachers.map((t) => t.id) + return current !== null && ids.includes(current) ? current : (ids[0] ?? null) + }) + } + + // 表示対象(クラス/教員)が変わったら、そのビューの先頭を選び直す + useEffect(() => { + if (!timetable) return + const ids = view === 'class' ? timetable.classes.map((c) => c.id) : timetable.teachers.map((t) => t.id) + setEntityId((current) => (current !== null && ids.includes(current) ? current : (ids[0] ?? null))) + }, [view, timetable]) + + // ---- 編集操作(undo 用にスナップショットを積んでから実行する) ---- + const mutate = useCallback( + async (action: (tt: Timetable) => Promise) => { + if (!timetable) return + const before = snapshotOf(timetable) + setError(null) + try { + const next = await action(timetable) + setPast((p) => [...p, before]) + setFuture([]) + applyTimetable(next) + } catch (e) { + setError(e instanceof ApiError ? e.message : e instanceof Error ? e.message : String(e)) + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [timetable, view], + ) + + const undo = async () => { + if (!timetable || past.length === 0) return + const target = past[past.length - 1] + const current = snapshotOf(timetable) + const next = await api.replaceAssignments(timetable.id, target) + setPast((p) => p.slice(0, -1)) + setFuture((f) => [current, ...f]) + applyTimetable(next) + } + + const redo = async () => { + if (!timetable || future.length === 0) return + const target = future[0] + const current = snapshotOf(timetable) + const next = await api.replaceAssignments(timetable.id, target) + setFuture((f) => f.slice(1)) + setPast((p) => [...p, current]) + applyTimetable(next) + } + + // ---- 求解ジョブ ---- + const stopPolling = () => { + if (pollTimer.current !== null) { + window.clearInterval(pollTimer.current) + pollTimer.current = null + } + } + useEffect(() => stopPolling, []) + + const startSolve = async (keepLocks: boolean) => { + if (schoolId === null) return + setError(null) + setNotice(null) + setBusy(true) + try { + const started = await api.startSolve(schoolId, { + source_timetable_id: keepLocks && timetable ? timetable.id : null, + }) + setJob(started) + stopPolling() + pollTimer.current = window.setInterval(async () => { + try { + const current = await api.getJob(started.id) + setJob(current) + if (current.status === 'succeeded' && current.timetable_id !== null) { + stopPolling() + setBusy(false) + const tt = await api.getTimetable(current.timetable_id) + applyTimetable(tt, true) + await loadVersions(schoolId) + setNotice( + `提案しました(${current.elapsed_sec.toFixed(1)}秒 / 未配置${current.unmet_periods ?? 0}コマ` + + `${current.timed_out ? ' / 時間制限に達したため暫定解' : ''})`, + ) + } else if (current.status === 'failed') { + stopPolling() + setBusy(false) + setError(current.error ?? '求解に失敗しました') + } else if (current.status === 'cancelled') { + stopPolling() + setBusy(false) + setNotice('求解をキャンセルしました') + } + } catch (e) { + stopPolling() + setBusy(false) + setError(e instanceof Error ? e.message : String(e)) + } + }, POLL_INTERVAL_MS) + } catch (e) { + setBusy(false) + setError(e instanceof Error ? e.message : String(e)) + } + } + + const cancelSolve = async () => { + if (!job) return + await api.cancelJob(job.id) + } + + const createSchool = async (payload: { + name: string + school_type: string + classes_per_grade: number + include_special_needs: boolean + }) => { + setBusy(true) + setError(null) + try { + const created = await api.createSchool(payload) + setSchools(await api.listSchools()) + setSchoolLoading(true) + setSchoolId(created.id) + setTimetable(null) + setNotice(`${created.name} を作成しました(${created.class_count}学級 / 講座${created.course_count})`) + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setBusy(false) + } + } + + // ---- ドラッグ&ドロップ ---- + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })) + + const onDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || !timetable) return + const from = active.data.current as + | { type: 'assignment'; assignment: Assignment } + | { type: 'course'; courseId: number } + | undefined + const to = over.data.current as + | { type: 'cell'; day: number; period: number } + | { type: 'tray' } + | undefined + if (!from || !to) return + + if (from.type === 'assignment' && to.type === 'cell') { + if (from.assignment.day === to.day && from.assignment.period === to.period) return + void mutate((tt) => + api.moveAssignment(tt.id, from.assignment.id, { day: to.day, period: to.period }), + ) + } else if (from.type === 'assignment' && to.type === 'tray') { + void mutate((tt) => api.deleteAssignment(tt.id, from.assignment.id)) + } else if (from.type === 'course' && to.type === 'cell') { + void mutate((tt) => + api.addAssignment(tt.id, { course_id: from.courseId, day: to.day, period: to.period }), + ) + } + } + + const toggleLock = (assignment: Assignment) => { + void mutate((tt) => api.setLock(tt.id, assignment.id, !assignment.locked)) + } + + const focusViolation = (v: Violation) => { + if (!timetable) return + const courseById = new Map(timetable.courses.map((c) => [c.id, c])) + if (v.class_ids.length > 0) { + setView('class') + setEntityId(v.class_ids[0]) + return + } + if (v.teacher_ids.length > 0) { + setView('teacher') + setEntityId(v.teacher_ids[0]) + return + } + const course = v.course_ids.map((id) => courseById.get(id)).find(Boolean) + if (course) { + setView('class') + setEntityId(course.class_ids[0]) + } + } + + const highlight = useMemo( + () => + timetable && entityId !== null + ? computeHighlight(timetable, view, entityId) + : { slots: new Set(), severity: new Map() }, + [timetable, view, entityId], + ) + + const entities = useMemo(() => { + if (!timetable) return [] + return view === 'class' + ? timetable.classes.map((c) => ({ id: c.id, label: c.name })) + : timetable.teachers.map((t) => ({ id: t.id, label: t.name })) + }, [timetable, view]) + + if (!meta) { + return ( +
+ 読み込み中… +
+ ) + } + + return ( +
+
+

時間割自動編成

+
+ + +
+ {versions.length > 0 && ( +
+ + +
+ )} +
+ {busy && job && ( +
+ + + 求解中 {job.elapsed_sec.toFixed(0)}秒 + + +
+ )} + + +
+ +
+
+ {error && ( +
+ {error} +
+ )} + {notice && !error && ( +
+ {notice} +
+ )} + + {schoolLoading ? ( +
+ 時間割を読み込んでいます… +
+ ) : !timetable ? ( + + ) : ( + +
+
+
+ + +
+ +
+ + +
+ +
+
+ {timetable.summary.placed_periods} + / {timetable.summary.total_periods} コマ配置 +
+
+ {timetable.summary.locked} + コマ固定 +
+
+ + {entityId !== null && ( + + )} +
+ +
+ {entityId !== null && ( + + )} + + )} +
+ +
+ {timetable && } + {timetable && ( + + )} + {timetable && ( +
+

別の学校

+ +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..78decc8 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,103 @@ +import type { + AssignmentSnapshot, + Meta, + SchoolSummary, + SolveJob, + Timetable, + TimetableRow, +} from './types' + +const BASE = import.meta.env.VITE_API_BASE ?? '' + +export class ApiError extends Error { + status: number + constructor(status: number, message: string) { + super(message) + this.status = status + } +} + +async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + ...init, + headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, + }) + if (!res.ok) { + let detail = `${res.status} ${res.statusText}` + try { + const body = await res.json() + if (body?.detail) detail = typeof body.detail === 'string' ? body.detail : JSON.stringify(body.detail) + } catch { + // JSON でないレスポンスはそのまま + } + throw new ApiError(res.status, detail) + } + if (res.status === 204) return undefined as T + return (await res.json()) as T +} + +export const api = { + meta: () => request('/api/meta'), + + listSchools: () => request('/api/schools'), + + createSchool: (payload: { + name: string + school_type: string + classes_per_grade: number + include_special_needs: boolean + }) => request('/api/schools', { method: 'POST', body: JSON.stringify(payload) }), + + deleteSchool: (schoolId: number) => + request(`/api/schools/${schoolId}`, { method: 'DELETE' }), + + listTimetables: (schoolId: number) => + request(`/api/schools/${schoolId}/timetables`), + + startSolve: ( + schoolId: number, + payload: { source_timetable_id?: number | null; time_limit_sec?: number; name?: string }, + ) => + request(`/api/schools/${schoolId}/solve`, { + method: 'POST', + body: JSON.stringify(payload), + }), + + getJob: (jobId: string) => request(`/api/jobs/${jobId}`), + + cancelJob: (jobId: string) => request(`/api/jobs/${jobId}/cancel`, { method: 'POST' }), + + getTimetable: (id: number) => request(`/api/timetables/${id}`), + + addAssignment: (timetableId: number, payload: { course_id: number; day: number; period: number }) => + request(`/api/timetables/${timetableId}/assignments`, { + method: 'POST', + body: JSON.stringify(payload), + }), + + moveAssignment: (timetableId: number, assignmentId: number, payload: { day: number; period: number }) => + request(`/api/timetables/${timetableId}/assignments/${assignmentId}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }), + + deleteAssignment: (timetableId: number, assignmentId: number) => + request(`/api/timetables/${timetableId}/assignments/${assignmentId}`, { + method: 'DELETE', + }), + + setLock: (timetableId: number, assignmentId: number, locked: boolean) => + request(`/api/timetables/${timetableId}/assignments/${assignmentId}/lock`, { + method: 'POST', + body: JSON.stringify({ locked }), + }), + + replaceAssignments: (timetableId: number, assignments: AssignmentSnapshot[]) => + request(`/api/timetables/${timetableId}/assignments`, { + method: 'PUT', + body: JSON.stringify({ assignments }), + }), + + exportUrl: (timetableId: number, by: 'class' | 'teacher') => + `${BASE}/api/timetables/${timetableId}/export.csv?by=${by}`, +} diff --git a/frontend/src/components/SetupPanel.tsx b/frontend/src/components/SetupPanel.tsx new file mode 100644 index 0000000..c19686d --- /dev/null +++ b/frontend/src/components/SetupPanel.tsx @@ -0,0 +1,132 @@ +import { useState } from 'react' +import type { Meta, SchoolSummary } from '../types' + +interface Props { + meta: Meta + schools: SchoolSummary[] + onCreate: (payload: { + name: string + school_type: string + classes_per_grade: number + include_special_needs: boolean + }) => Promise + busy: boolean +} + +/** + * 学校の作成。校種と1学年あたりの学級数だけを指定すれば、教科・教員・講座・ + * 特別教室は標準授業時数から自動生成される(仮定 A-20)。 + */ +export function SetupPanel({ meta, schools, onCreate, busy }: Props) { + const [name, setName] = useState('サンプル中学校') + const [schoolType, setSchoolType] = useState('junior') + const [classesPerGrade, setClassesPerGrade] = useState(2) + const [special, setSpecial] = useState(false) + + const selected = meta.school_types.find((s) => s.value === schoolType)! + const gradeCount = selected.grades.length + const totalClasses = classesPerGrade * gradeCount + (special ? 1 : 0) + const overLimit = totalClasses > meta.max_classes + + return ( +
+

学校を作る

+

+ 校種と学級数を選ぶと、学習指導要領の標準授業時数から教科・週コマ数・教員・ + 特別教室を自動生成します。生成後に画面から調整できます。 +

+ +
+
+ + setName(e.target.value)} + /> +
+
+ + +
+
+ + setClassesPerGrade(Number(e.target.value))} + /> +
+ + +
+ +

+ {selected.label} / 全{totalClasses}学級 / 1コマ{selected.period_minutes}分 / 1日 + {selected.periods_per_day}コマ + {overLimit && `(上限${meta.max_classes}学級を超えています)`} +

+ +

この校種の週コマ数

+
+ {selected.grades.map((g) => ( +
+ {g.total} {g.grade}年 +
+ ))} +
+ + {schools.length > 0 && ( + <> +

作成済みの学校

+
    + {schools.map((s) => ( +
  • + {s.name}({s.school_type_label} / {s.class_count}学級 / 講座{s.course_count}) +
  • + ))} +
+ + )} +
+ ) +} diff --git a/frontend/src/components/TimetableGrid.tsx b/frontend/src/components/TimetableGrid.tsx new file mode 100644 index 0000000..a637893 --- /dev/null +++ b/frontend/src/components/TimetableGrid.tsx @@ -0,0 +1,230 @@ +import { useDraggable, useDroppable } from '@dnd-kit/core' +import type { Assignment, Course, Timetable, Violation } from '../types' + +export type ViewMode = 'class' | 'teacher' + +interface Props { + timetable: Timetable + view: ViewMode + entityId: number + onToggleLock: (assignment: Assignment) => void + highlight: { slots: Set; severity: Map } +} + +export function slotKey(day: number, period: number): string { + return `${day}:${period}` +} + +/** 違反から「どのコマを光らせるか」を決める。表示中の対象に関係するものだけ拾う。 */ +export function computeHighlight( + timetable: Timetable, + view: ViewMode, + entityId: number, +): { slots: Set; severity: Map } { + const slots = new Set() + const severity = new Map() + const courseById = new Map(timetable.courses.map((c) => [c.id, c])) + + const relevant = (v: Violation): boolean => { + if (view === 'class') { + if (v.class_ids.includes(entityId)) return true + return v.course_ids.some((id) => courseById.get(id)?.class_ids.includes(entityId)) + } + if (v.teacher_ids.includes(entityId)) return true + return v.course_ids.some((id) => courseById.get(id)?.teacher_id === entityId) + } + + for (const v of timetable.violations) { + if (!relevant(v)) continue + const explicit = v.slots.map((s) => slotKey(s.day, s.period)) + // コマが明示されていない違反(週コマ数の不足など)は、その講座が置かれている + // コマをすべて光らせる + const derived = + explicit.length > 0 + ? explicit + : timetable.assignments + .filter((a) => v.course_ids.includes(a.course_id)) + .map((a) => slotKey(a.day, a.period)) + for (const key of derived) { + slots.add(key) + if (v.severity === 'hard' || severity.get(key) !== 'hard') { + severity.set(key, v.severity) + } + } + } + return { slots, severity } +} + +function Chip({ + assignment, + course, + view, + classNames, + isElective, + onToggleLock, +}: { + assignment: Assignment + course: Course + view: ViewMode + classNames: string + isElective: boolean + onToggleLock: (a: Assignment) => void +}) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `assign:${assignment.id}`, + data: { type: 'assignment', assignment }, + disabled: assignment.locked, + }) + + return ( +
+
+ {course.short_name} + +
+
{view === 'class' ? course.teacher_name : classNames}
+
+ ) +} + +function Cell({ + day, + period, + children, + blocked, + severity, +}: { + day: number + period: number + children: React.ReactNode + blocked: boolean + severity: 'hard' | 'soft' | undefined +}) { + const { setNodeRef, isOver } = useDroppable({ + id: `cell:${day}:${period}`, + data: { type: 'cell', day, period }, + }) + return ( + +
+ {children} +
+ + ) +} + +export function TimetableGrid({ timetable, view, entityId, onToggleLock, highlight }: Props) { + const { school } = timetable + const courseById = new Map(timetable.courses.map((c) => [c.id, c])) + const classById = new Map(timetable.classes.map((c) => [c.id, c])) + + const belongs = (course: Course): boolean => + view === 'class' ? course.class_ids.includes(entityId) : course.teacher_id === entityId + + const cells = new Map() + for (const a of timetable.assignments) { + const course = courseById.get(a.course_id) + if (!course || !belongs(course)) continue + const key = slotKey(a.day, a.period) + const list = cells.get(key) ?? [] + list.push(a) + cells.set(key, list) + } + + const blockedSet = new Set( + timetable.blocked_slots + .filter((b) => b.class_id === null || (view === 'class' && b.class_id === entityId)) + .map((b) => slotKey(b.day, b.period)), + ) + const teacher = view === 'teacher' ? timetable.teachers.find((t) => t.id === entityId) : undefined + for (const u of teacher?.unavailable ?? []) blockedSet.add(slotKey(u.day, u.period)) + + return ( + + + + + ))} + + + + {Array.from({ length: school.periods_per_day }, (_, period) => ( + + + {Array.from({ length: school.days }, (_, day) => { + const key = slotKey(day, period) + return ( + + {(cells.get(key) ?? []).map((a) => { + const course = courseById.get(a.course_id)! + return ( + classById.get(id)?.name ?? '') + .join('・')} + onToggleLock={onToggleLock} + /> + ) + })} + + ) + })} + + ))} + +
+ {school.day_names.map((name, day) => ( + {name}
{period + 1}限
+ ) +} diff --git a/frontend/src/components/UnplacedTray.tsx b/frontend/src/components/UnplacedTray.tsx new file mode 100644 index 0000000..f72b9d8 --- /dev/null +++ b/frontend/src/components/UnplacedTray.tsx @@ -0,0 +1,70 @@ +import { useDraggable, useDroppable } from '@dnd-kit/core' +import type { Course, Timetable } from '../types' +import type { ViewMode } from './TimetableGrid' + +interface Props { + timetable: Timetable + view: ViewMode + entityId: number +} + +function UnplacedChip({ course, remaining }: { course: Course; remaining: number }) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: `course:${course.id}`, + data: { type: 'course', courseId: course.id }, + }) + return ( +
+
+ {course.short_name} 残{remaining} +
+
{course.teacher_name}
+
+ ) +} + +/** + * 未配置の講座置き場。ここからコマへドラッグして配置し、コマからここへ戻すと外れる。 + */ +export function UnplacedTray({ timetable, view, entityId }: Props) { + const { setNodeRef, isOver } = useDroppable({ id: 'tray', data: { type: 'tray' } }) + const courseById = new Map(timetable.courses.map((c) => [c.id, c])) + + const items = timetable.unplaced + .map((u) => ({ course: courseById.get(u.course_id)!, remaining: u.remaining })) + .filter(({ course }) => + view === 'class' ? course.class_ids.includes(entityId) : course.teacher_id === entityId, + ) + + return ( +
+

未配置のコマ

+
+ {items.length === 0 ? ( +
+ 表示中の対象に未配置のコマはありません +
+ ) : ( + items.map(({ course, remaining }) => ( + + )) + )} +
+

+ コマをここへドラッグすると配置を外せます。 +

+
+ ) +} diff --git a/frontend/src/components/ViolationPanel.tsx b/frontend/src/components/ViolationPanel.tsx new file mode 100644 index 0000000..fc70994 --- /dev/null +++ b/frontend/src/components/ViolationPanel.tsx @@ -0,0 +1,51 @@ +import type { Timetable, Violation } from '../types' + +interface Props { + timetable: Timetable + onSelect: (violation: Violation) => void +} + +/** + * 違反一覧。ハード違反(赤)を先に、ソフト違反(黄)を後に並べる。 + * 行をクリックすると、その違反に関係する対象へ表示を切り替える。 + */ +export function ViolationPanel({ timetable, onSelect }: Props) { + const { summary, violations } = timetable + return ( +
+

制約チェック

+
+
+ {summary.hard_violations} ハード違反 +
+
+ {summary.soft_violations} ソフト違反 +
+
+ {summary.unmet_periods} 未配置コマ +
+
+ + {violations.length === 0 ? ( +

+ 違反はありません。 +

+ ) : ( +
    + {violations.map((v, i) => ( +
  • onSelect(v)} + > + {v.code} + {v.message} +
  • + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..c31d4c6 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import './styles.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..ad3aae1 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,201 @@ +:root { + --bg: #f6f7f9; + --panel: #ffffff; + --border: #d8dce3; + --text: #1c2530; + --muted: #667085; + --accent: #2563eb; + --accent-weak: #dbeafe; + --hard: #dc2626; + --hard-weak: #fee2e2; + --soft: #b45309; + --soft-weak: #fef3c7; + --ok: #15803d; + --locked: #7c3aed; + --locked-weak: #ede9fe; + color-scheme: light; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: system-ui, -apple-system, 'Hiragino Kaku Gothic ProN', 'Noto Sans JP', Meiryo, sans-serif; + background: var(--bg); + color: var(--text); + font-size: 14px; +} + +button { + font: inherit; + border: 1px solid var(--border); + background: var(--panel); + border-radius: 6px; + padding: 6px 12px; + cursor: pointer; +} +button:hover:not(:disabled) { border-color: var(--accent); } +button:disabled { opacity: 0.45; cursor: not-allowed; } +button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } +button.primary:hover:not(:disabled) { background: #1d4ed8; } +button.danger { color: var(--hard); } + +select, input { + font: inherit; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel); + color: var(--text); +} + +.app { display: flex; flex-direction: column; min-height: 100vh; } + +header.appbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + padding: 10px 16px; + background: var(--panel); + border-bottom: 1px solid var(--border); +} +header.appbar h1 { font-size: 16px; margin: 0 12px 0 0; } +.spacer { flex: 1; } + +.layout { display: flex; gap: 16px; padding: 16px; align-items: flex-start; flex-wrap: wrap; } +.main { flex: 1 1 620px; min-width: 520px; } +.side { flex: 0 0 340px; display: flex; flex-direction: column; gap: 12px; } + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px; +} +.panel h2 { font-size: 14px; margin: 0 0 10px; } +.panel h3 { font-size: 13px; margin: 14px 0 6px; color: var(--muted); } + +.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.field { display: flex; flex-direction: column; gap: 4px; } +.field label { font-size: 12px; color: var(--muted); } + +.stats { display: flex; gap: 14px; flex-wrap: wrap; font-size: 13px; } +.stat b { font-size: 16px; } +.stat.hard b { color: var(--hard); } +.stat.soft b { color: var(--soft); } +.stat.ok b { color: var(--ok); } + +/* ------- 時間割グリッド ------- */ +table.grid { border-collapse: collapse; width: 100%; table-layout: fixed; } +table.grid th, table.grid td { + border: 1px solid var(--border); + padding: 0; + vertical-align: top; +} +table.grid th { + background: #eef1f5; + font-size: 12px; + padding: 6px 4px; + font-weight: 600; +} +table.grid th.period-head { width: 52px; } + +.cell { + min-height: 62px; + padding: 3px; + display: flex; + flex-direction: column; + gap: 3px; + transition: background 0.12s; +} +.cell.droppable-over { background: var(--accent-weak); outline: 2px solid var(--accent); outline-offset: -2px; } +.cell.blocked { background: repeating-linear-gradient(45deg, #f1f2f4, #f1f2f4 5px, #e6e8ec 5px, #e6e8ec 10px); } +.cell.violation-hard { background: var(--hard-weak); } +.cell.violation-soft { background: var(--soft-weak); } + +.chip { + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: 5px; + background: #fff; + padding: 4px 5px; + cursor: grab; + font-size: 12px; + line-height: 1.35; + user-select: none; +} +.chip:active { cursor: grabbing; } +.chip.core { border-left-color: var(--accent); } +.chip.non-core { border-left-color: #94a3b8; } +.chip.locked { border-left-color: var(--locked); background: var(--locked-weak); cursor: not-allowed; } +.chip.elective { border-left-style: dashed; } +.chip.dragging { opacity: 0.35; } +.chip .chip-title { font-weight: 600; display: flex; align-items: center; gap: 4px; } +.chip .chip-sub { color: var(--muted); font-size: 11px; } +.chip .lock-btn { + border: none; + background: transparent; + padding: 0 2px; + font-size: 12px; + cursor: pointer; + line-height: 1; +} + +.tray { display: flex; flex-wrap: wrap; gap: 6px; min-height: 54px; padding: 6px; border: 1px dashed var(--border); border-radius: 6px; } +.tray.droppable-over { background: var(--hard-weak); border-color: var(--hard); } +.tray .chip { min-width: 92px; } +.tray-empty { color: var(--muted); font-size: 12px; padding: 6px; } + +/* ------- 違反 ------- */ +.violations { list-style: none; margin: 0; padding: 0; max-height: 340px; overflow-y: auto; } +.violations li { + padding: 7px 8px; + border-left: 3px solid transparent; + border-bottom: 1px solid var(--border); + font-size: 12px; + cursor: pointer; +} +.violations li:hover { background: #f2f5f9; } +.violations li.hard { border-left-color: var(--hard); } +.violations li.soft { border-left-color: var(--soft); } +.violations li .code { + display: inline-block; + font-size: 10px; + padding: 1px 5px; + border-radius: 3px; + margin-right: 6px; + background: #eef1f5; + color: var(--muted); +} +.violations li.hard .code { background: var(--hard-weak); color: var(--hard); } +.violations li.soft .code { background: var(--soft-weak); color: var(--soft); } + +.banner { + padding: 9px 12px; + border-radius: 6px; + font-size: 13px; + margin-bottom: 12px; +} +.banner.error { background: var(--hard-weak); color: var(--hard); border: 1px solid var(--hard); } +.banner.info { background: var(--accent-weak); color: #1e40af; border: 1px solid var(--accent); } +.banner.success { background: #dcfce7; color: var(--ok); border: 1px solid var(--ok); } + +.progress { + display: flex; + align-items: center; + gap: 8px; +} +.spinner { + width: 14px; height: 14px; + border: 2px solid var(--accent-weak); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.muted { color: var(--muted); } +.tabs { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 10px; } +.tabs button.active { background: var(--accent); color: #fff; border-color: var(--accent); } +.empty-state { text-align: center; padding: 40px 20px; color: var(--muted); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..846fd6c --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,156 @@ +export type Severity = 'hard' | 'soft' + +export interface Slot { + day: number + period: number +} + +export interface School { + id: number + name: string + school_type: string + school_type_label: string + days: number + periods_per_day: number + period_minutes: number + day_names: string[] +} + +export interface SchoolSummary extends School { + class_count: number + course_count: number + timetable_count: number +} + +export interface SchoolClass { + id: number + name: string + grade: number + kind: 'normal' | 'special_needs' +} + +export interface Teacher { + id: number + name: string + kind: string + max_weekly_periods: number + unavailable: Slot[] +} + +export interface Course { + id: number + subject_id: number + subject_name: string + short_name: string + is_core: boolean + room_type: string | null + teacher_id: number + teacher_name: string + class_ids: number[] + weekly_periods: number + max_per_day: number + prefers_double: boolean + group_id: number | null + placed: number +} + +export interface Assignment { + id: number + course_id: number + day: number + period: number + locked: boolean +} + +export interface Violation { + code: string + severity: Severity + message: string + course_ids: number[] + class_ids: number[] + teacher_ids: number[] + slots: Slot[] +} + +export interface Summary { + total_periods: number + placed_periods: number + unmet_periods: number + hard_violations: number + soft_violations: number + locked: number +} + +export interface ParallelGroup { + id: number + name: string + course_ids: number[] +} + +export interface Timetable { + id: number + name: string + parent_id: number | null + created_at: string + school: School + classes: SchoolClass[] + teachers: Teacher[] + courses: Course[] + parallel_groups: ParallelGroup[] + blocked_slots: (Slot & { class_id: number | null; reason: string })[] + assignments: Assignment[] + violations: Violation[] + unplaced: { course_id: number; remaining: number }[] + summary: Summary +} + +export interface TimetableRow { + id: number + name: string + parent_id: number | null + created_at: string + assignment_count: number +} + +export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled' + +export interface SolveJob { + id: string + school_id: number + status: JobStatus + elapsed_sec: number + timetable_id: number | null + error: string | null + violations: Violation[] + unmet_periods: number | null + total_periods: number | null + solver_status: string | null + timed_out: boolean + stats: Record +} + +export interface Meta { + school_types: { + value: string + label: string + period_minutes: number + periods_per_day: number + grades: { + grade: number + weekly: Record + electives: { name: string; periods: number; subjects: string[] }[] + total: number + }[] + }[] + constraints: { code: string; label: string; severity: Severity; weight: number }[] + max_classes: number + day_names: string[] +} + +/** undo/redo のスナップショット。API の一括置換に渡せる形で持つ。 */ +export interface AssignmentSnapshot { + course_id: number + day: number + period: number + locked: boolean +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..94a5d3a --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..5e65145 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..751c136 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + // 開発時は同一オリジンでAPIを叩けるようプロキシする + proxy: { + '/api': { target: process.env.VITE_API_TARGET ?? 'http://127.0.0.1:8000', changeOrigin: true }, + '/health': { target: process.env.VITE_API_TARGET ?? 'http://127.0.0.1:8000', changeOrigin: true }, + }, + }, + preview: { + port: 5173, + }, +}) From 8d5bb1769bef1f081688d71853fd366caff5d306 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 09:24:51 +0000 Subject: [PATCH 4/5] =?UTF-8?q?E2E=E3=81=AE=E3=83=89=E3=83=A9=E3=83=83?= =?UTF-8?q?=E3=82=B0=E6=93=8D=E4=BD=9C=E3=82=92=E5=AE=89=E5=AE=9A=E3=81=95?= =?UTF-8?q?=E3=81=9B=E3=80=8111=E4=BB=B6=E3=81=99=E3=81=B9=E3=81=A6?= =?UTF-8?q?=E3=82=92=E9=80=9A=E9=81=8E=E3=81=95=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ドラッグのヘルパーが、ドラッグ元の座標を取得したあとにドロップ先を スクロールしていたため、押下位置がずれて要素の外を掴んでいた。 必要なスクロールを先に済ませてから両方の位置を読むように直し、 画面外を掴もうとした場合は明示的に失敗させるようにした。 あわせてE2Eのビューポートを縦1400pxにし、時間割グリッドと未配置トレイが スクロールなしで同時に収まるようにした。 E2E 11件、バックエンド50件、フロントエンドの型チェックとビルドすべて通過。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DHGVNyssktZ69PWwmY1LCA --- README.md | 3 ++- docs/assumptions.md | 10 ++++++++++ e2e/playwright.config.ts | 8 +++++++- e2e/tests/helpers.ts | 21 +++++++++++++++++++-- 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4a4d33b..278fef8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ - 提案結果を手で直せる。動かした瞬間に制約違反が赤/黄で表示される - 気に入ったコマを**固定(ロック)**して、残りだけを組み直せる -![構成](docs/requirements.md) +詳しい背景と根拠は [要件定義](docs/requirements.md)、実装時に置いた判断は +[仮定記録](docs/assumptions.md) にまとめてある。 --- diff --git a/docs/assumptions.md b/docs/assumptions.md index 6a600c6..19be003 100644 --- a/docs/assumptions.md +++ b/docs/assumptions.md @@ -257,3 +257,13 @@ 変わり、この実行環境に焼き込まれているリビジョンと一致しないことがある。 - **見直し条件**: CI では `npx playwright install chromium` を実行しているため、 この分岐は使われない。 + +### A-35 E2Eのビューポートを縦1400pxにする +- **仮定**: Playwright のビューポートを 1440×1400 にし、時間割グリッドと未配置トレイが + スクロールなしで同時に画面へ収まるようにする。 +- **根拠**: ドラッグの途中でスクロールが挟まると座標がずれ、掴み損ねる。実際に + 「コマをトレイへ外す」テストが、トレイを表示するためのスクロールでドラッグ元の + 位置がずれて失敗した。ヘルパー側でも、必要なスクロールを済ませてから両方の + 位置を読むように直したうえで、画面外を掴もうとしたら明示的に失敗させている。 +- **見直し条件**: 実利用者の画面ではスクロールが起きるため、ドラッグ中の自動 + スクロール(dnd-kit の AutoScroll)の挙動は別途確認が必要。 diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index d191316..cf9cf54 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -37,7 +37,13 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'], launchOptions: { executablePath } }, + use: { + ...devices['Desktop Chrome'], + // 時間割グリッドと未配置トレイを同時に画面へ収める。ドラッグ中に + // スクロールが挟まると座標がずれて掴み損ねるため、縦を広く取る + viewport: { width: 1440, height: 1400 }, + launchOptions: { executablePath }, + }, }, ], webServer: [ diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 7693cbc..e3ca177 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -8,13 +8,30 @@ import { expect, type Locator, type Page } from '@playwright/test' * 開始直後に小さく動かしてドラッグを確実に発火させている。 */ export async function dragTo(page: Page, source: Locator, target: Locator): Promise { + // スクロールは座標を変えるので、必要なスクロールを済ませてから両方の位置を読む。 + // 先にドラッグ元の位置を取ってからドロップ先をスクロールすると、押下位置が + // ずれて要素の外を掴んでしまい、ドラッグが始まらない。 + await target.scrollIntoViewIfNeeded() await source.scrollIntoViewIfNeeded() const from = await source.boundingBox() - if (!from) throw new Error('ドラッグ元の位置を取得できません') - await target.scrollIntoViewIfNeeded() const to = await target.boundingBox() + if (!from) throw new Error('ドラッグ元の位置を取得できません') if (!to) throw new Error('ドロップ先の位置を取得できません') + const viewport = page.viewportSize() + if (viewport) { + for (const [name, box] of [ + ['ドラッグ元', from], + ['ドロップ先', to], + ] as const) { + if (box.y < 0 || box.y + box.height > viewport.height) { + throw new Error( + `${name}が画面外にあります(ビューポートを広げるか、対象を近づけてください)`, + ) + } + } + } + const start = { x: from.x + from.width / 2, y: from.y + from.height / 2 } const end = { x: to.x + to.width / 2, y: to.y + to.height / 2 } From cf60da246b49c829a2ee910372080c5555a9ab6b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 09:25:16 +0000 Subject: [PATCH 5/5] =?UTF-8?q?=E3=83=AA=E3=83=9D=E3=82=B8=E3=83=88?= =?UTF-8?q?=E3=83=AA=E7=9B=B4=E4=B8=8B=E3=81=AB=20.gitignore=20=E3=82=92?= =?UTF-8?q?=E7=BD=AE=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各ディレクトリの .gitignore に加えて、生成物(SQLite、pytestキャッシュ、 Playwrightの実行結果)をリポジトリ全体で無視する。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DHGVNyssktZ69PWwmY1LCA --- .gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fafaf54 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.db +.pytest_cache/ +node_modules/ +dist/ +test-results/ +playwright-report/