DEVELOPER DOCS

開發交棒計畫 — Milestone 與任務卡

原始檔 docs/tasks.md · 在 GitHub 上檢視

版本:v1.0(2026-07-07) 前置閱讀:architecture.md(需求、資料模型、引擎設計、技術棧皆以該文件為準) 使用方式:開發 AI(Opus 4.8 / Sonnet 5)每次領取一張任務卡,實作 → 依「驗收標準」自我驗證 → 回報 → 經使用者驗收後才進下一張。 任務卡狀態標記:[ ] 未開始 / [~] 進行中 / [x] 已驗收。開發者完成後請直接更新本檔的核取方塊。


專案目錄結構(M0 建立,全案遵循)

Course_Scheduling_System/
      ├── docker-compose.yml          # 正式部署用(6 容器:web/api/worker/worker-ops/postgres/redis)
      ├── docker-compose.dev.yml      # 開發用(熱重載)
      ├── .env.example                # 僅需改:管理員密碼、校名、SMTP(選填)
      ├── Caddyfile
      ├── docs/                       # 本規劃文件 + 使用者文件
      │   ├── architecture.md
      │   ├── tasks.md
      │   └── deploy/                 # 中文部署圖文教學
      ├── backend/
      │   ├── pyproject.toml          # uv 管理;ruff + pytest 設定
      │   ├── alembic/                # 資料庫遷移
      │   ├── app/
      │   │   ├── core/               # 設定、DB session、auth、安全
      │   │   ├── models/             # SQLAlchemy models(對應 architecture.md §2.2)
      │   │   ├── schemas/            # Pydantic schemas
      │   │   ├── api/                # routers(依資源分檔:teachers.py、timetables.py …)
      │   │   ├── services/           # 商業邏輯(衝突檢查、代課推薦、匯入匯出…)
      │   │   ├── solver/             # OR-Tools CP-SAT 排課引擎(獨立、不 import app 其他層)
      │   │   │   ├── model_builder.py    # 約束建模
      │   │   │   ├── conflict_explainer.py # 無解衝突定位
      │   │   │   └── preflight.py        # 排課前置檢查
      │   │   ├── workers/            # RQ 任務(排課、寄信、備份)
      │   │   └── main.py
      │   └── tests/
      │       ├── fixtures/           # 三套學制驗證資料集(見測試策略)
      │       ├── unit/
      │       └── solver/             # 引擎正確性測試
      ├── frontend/
      │   ├── package.json            # pnpm;Vue 3 + TS + Vite + Pinia + Naive UI
      │   ├── src/
      │   │   ├── api/                # API client(openapi-typescript 產生型別)
      │   │   ├── stores/
      │   │   ├── components/
      │   │   │   └── timetable/      # TimetableGrid 拖拉課表元件(核心)
      │   │   ├── views/              # 依資訊架構分頁(見 architecture.md §5.1)
      │   │   └── router/
      │   └── e2e/                    # Playwright
      └── .github/workflows/ci.yml    # lint + test + 雙架構 image build
      

Milestone 總覽

Milestone 目標 完成的可見成果
M0 專案骨架 可跑起來的空殼 docker compose up -d 後可登入看到空儀表板
M1 基礎資料 建置學校資料 設定精靈走完,教師/班級/科目/場地齊備
M2 手動排課 拖拉排課可用 手動排完一張班級課表並發布,教師可查詢
M3 自動排課 引擎上線 一鍵自動排課出草稿,無解時給人話報告
M4 調代課 學期中日常運作 請假→代課→通知→確認 全流程
M5 報表與上線 對外可發行 匯出/備份/部署文件完成,可發布 v1.0
M6 v1.1 加固 排課季前的體驗與韌性 ops 佇列拆分、部分排課不炸鍋等五項,發布 v1.1(範圍見 docs/roadmap.md)

M0 專案骨架

M0-1 Repo 初始化與 Docker Compose 骨架

  • 描述:建立上述目錄結構;docker-compose.yml(5 容器)與 docker-compose.dev.yml;FastAPI hello endpoint(GET /api/health);Vue 3 空專案由 Caddy 服務;Alembic 初始化;.env.example
  • 模組:根目錄、backend/app/main.pyfrontend/Caddyfile
  • 驗收標準: 1. 全新機器上 cp .env.example .env && docker compose up -d 後,瀏覽器開 http://localhost 見前端頁面,/api/health{"status":"ok"} 2. docker compose ps 五容器皆 healthy 3. dev compose 支援前後端熱重載
  • 測試方式:CI 內 docker compose up + curl 煙霧測試

M0-2 帳號、登入與 RBAC

  • 描述:user/user_role model 與遷移;bcrypt + session cookie 登入;RBAC 依賴注入(admin/scheduler/director/teacher);首次啟動以 .env 建立 admin;首次登入強制改密碼;登入頁 UI。
  • 模組:app/core/auth.pyapp/api/auth.pyapp/models/user.pyfrontend/src/views/Login.vue
  • 驗收標準: 1. admin 可登入/登出;錯誤密碼 5 次鎖定 15 分鐘 2. 未登入呼叫受保護 API 回 401;teacher 角色呼叫 scheduler API 回 403 3. 首次登入被導向改密碼頁,改完才能進系統
  • 測試方式:pytest(auth 流程 8+ 案例)+ Playwright 登入 E2E

M0-3 CI 與程式品質基線

  • 描述:GitHub Actions:ruff + mypy + pytest / eslint + vitest / Playwright / 雙架構(amd64+arm64)image build push。pre-commit 設定。
  • 驗收標準:PR 觸發全部檢查;main 分支 push 產出 image;README 掛 CI badge
  • 測試方式:開測試 PR 驗證

M1 基礎資料管理

M1-0 地基補強(M0 架構健檢產出,先做完才進 M1-1)

  • 描述:健檢發現三項「現在改便宜、日後改痛」的地基問題: 1. Session 撤銷:session token 由 {"uid"} 改為 {"uid", "pv": password_hash[-12:]},get_current_user 驗證 pv 與現行密碼一致,不符回 401(改密碼即失效所有舊 session); 2. naming_convention:Base.metadata 加入標準約束命名慣例(ix/uq/ck/fk/pk),確保跨資料庫遷移可靠; 3. 時區政策落地(architecture.md D6):.env.example 與 config 增 TZ=Asia/Taipei 設定;compose 傳入容器。
  • 順手項:CI 增加「PostgreSQL service container 跑 alembic upgrade head」遷移驗證步驟;前端 client.ts 加 401 全域處理(清 store → 導向登入)。
  • 模組:app/core/{security,auth,db,config}.pytests/.github/workflows/ci.ymlfrontend/src/api/client.ts
  • 驗收標準: 1. 登入後改密碼,以「舊 cookie」呼叫 /api/auth/me 回 401(新增 pytest 案例) 2. Base.metadata.naming_convention 已定義,alembic upgrade head 在 PostgreSQL 全新資料庫成功(CI 驗證) 3. session 過期或被撤銷後,前端任何 API 操作自動導回登入頁 4. 既有 15 個 auth 測試不退步
  • 測試方式:pytest + CI 遷移 job

M1-1 學期與節次表

  • 描述:semester/period_table/period CRUD(model+API+UI);節次表視覺化編輯器(表格點選標記節次類型:一般課/午休/導師時間/固定用途);五種學制範本資料(JSON seed,含預設節次表與科目清單);同學期多套節次表(完全中學)。
  • 模組:app/models/{semester,period}.pyapp/api/semesters.pyfrontend/src/views/settings/PeriodTable.vue
  • 驗收標準: 1. 可建立 115 學年第 1 學期,選「國小範本」自動帶入 40 分節次表(含週三下午空) 2. 可將週五第 7 節改為「導師時間」,排課時段檢查 API 即反映 3. 可為同學期建第二套節次表並指派給不同班級群
  • 測試方式:pytest CRUD + 範本載入;Vitest 元件測試

M1-2 教師、班級、科目、場地 CRUD

  • 描述:四實體完整 CRUD(對應 architecture.md §2.2 欄位);教師任教科目多選、行政職減課、業界師資標記;班級的學制標籤與群科(技高);場地類型與容量;清單頁支援搜尋/排序。
  • 模組:app/models/app/api/frontend/src/views/basedata/
  • 驗收標準: 1. 四實體皆可增刪改查,刪除有引用檢查(被配課引用的教師不可刪,提示改為「離職」狀態) 2. 教師頁可設定「不可排時段」與「偏好時段」(週×節 點選格子) 3. 技高班級可填群科;國小班級可指定導師
  • 測試方式:pytest(含引用完整性案例)

M1-3 Excel 匯入

  • 描述:教師、班級、科目三種 Excel 範本(系統內可下載,含填寫說明列與範例列);上傳→逐列驗證→錯誤清單(「第 N 列:XX 原因」)→全對才交易式入庫;匯入教師時可勾選「同時建立帳號」(預設密碼規則+強制首登改密)。
  • 模組:app/services/importer.pyfrontend/src/views/basedata/Import.vue
  • 驗收標準: 1. 下載範本→填 30 位教師→上傳→全數入庫且帳號建立 2. 故意填錯(科目不存在、重複姓名+身分末四碼)→回報確切列號與原因,資料庫零寫入 3. 範本欄位有中文說明列,匯入時自動略過
  • 測試方式:pytest 用 fixtures 內的正確/錯誤 Excel 檔

M1-4 設定精靈

  • 描述:首次登入(無任何學期資料時)自動進入五步驟精靈(architecture.md §5.2);每步可略過/回上一步;完成後導向配課管理;之後可從系統管理重新啟動精靈。
  • 模組:frontend/src/views/wizard/app/api/wizard.py(進度狀態)
  • 驗收標準: 1. 全新系統登入即入精靈;五步走完後儀表板顯示資料摘要(N 位教師、N 個班級) 2. 中途關瀏覽器,再登入從上次步驟繼續 3. 使用者測試:不看文件 30 分鐘內完成國中範本建置(以 fixtures 資料模擬)
  • 測試方式:Playwright E2E 全精靈流程

M1-5 開新學期複製精靈

  • 描述:從既有學期複製教師/班級/科目/場地/節次表到新學期(可勾選項目);班級年級自動 +1(可關閉),畢業年級提示移除。
  • 驗收標準:複製後兩學期資料獨立(改 A 學期教師不影響 B);年級進位正確
  • 測試方式:pytest

M1-6 混合學制支援:班級 ↔ 節次表指派(M2 前必做,架構健檢 2026-07-09 產出)

  • 領域背景(使用者提供):台灣完全中學通常全校統一 50 分/節(國中部配合高中部作息),故多數學校只需一套節次表;真正需要多套表的是附設國小部、K-12 實驗學校、進修部/夜間部。設計原則:預設路徑零負擔,彈性只在需要時浮現。
  • 描述:班級尚無「所屬節次表」關聯,M2 衝突檢查/排課引擎無從得知每班合法時段。實作: 1. class_units.period_table_id(nullable FK,空=學期預設節次表)+ 遷移; 2. 班級表單增「節次表」下拉——僅於該學期有 ≥2 套節次表時顯示(單一表學校完全看不到此欄位);Excel 班級範本增「節次表」欄(選填,以名稱對應); 3. 提供 helper resolve_period_table(class_unit)(指定表 → 回退學期預設表),無論單表或多表學校,M2 起所有時段邏輯一律走此函式; 4. 刪除節次表時檢查是否被班級引用(引用中則擋)。
  • 模組:app/models/basedata.pyapp/api/basedata.pyapp/services/importer.pyfrontend/src/views/basedata/ClassesTab.vue
  • 驗收標準: 1. 完全中學情境:同學期兩套節次表(國中 45 分/高中 50 分),301 班指到國中表、501 班指到高中表,各自 available-slots 正確 2. 未指派節次表的班級回退學期預設表 3. 被班級引用的節次表刪除時回 409 4. Excel 匯入班級可指定節次表名稱,名稱不存在時報列號錯誤
  • 測試方式:pytest(完全中學 fixture 情境)

M2 配課與手動排課

M2-0 教師帳號綁定與聯絡資訊(M1 健檢 2026-07-09 產出,M2-1 前必做)

  • 背景:user.py docstring 承諾的 User↔Teacher 綁定在 M1 未實作(匯入建帳號僅存 display_name,無外鍵)。此綁定是 M2-5「教師查本人課表」、M4 全部(請假自登、代課確認、通知收件人)的前提。另使用者需求:教師需有聯絡欄位以利調代課通知。
  • 描述: 1. teachers.user_id(nullable FK → users,ondelete=SET NULL,同學期唯一 uq(semester_id, user_id));Excel 匯入「同時建立帳號」時自動綁定;教師表單(admin/scheduler)可選擇綁定既有帳號; 2. teachers.email / teachers.phone / teachers.line_id(皆 nullable String)——聯絡資訊掛教師(學期快照),因外聘/業界師資可能無系統帳號; 3. Excel 教師範本增 Email/手機/LINE ID 三選填欄;教師表單增欄位; 4. semester_copy 複製 user_id 與三個聯絡欄位(綁定跨學期延續); 5. helper current_teacher(db, user, semester_id):由登入者解析其在指定學期的教師主檔(M2-5/M4 共用)。
  • 模組:app/models/basedata.pyapp/services/{importer,semester_copy}.pyapp/api/basedata.pyfrontend/src/views/basedata/TeachersTab.vue
  • 驗收標準: 1. 匯入 30 位教師勾「建立帳號」→ 每筆 teachers.user_id 正確綁定新帳號 2. 開新學期複製後,新學期教師仍綁定同一帳號、聯絡資訊完整 3. 同一帳號在同學期綁第二位教師 → 409 4. Email 格式錯誤時表單與匯入均回報錯誤
  • 測試方式:pytest(綁定/複製/唯一性);既有匯入測試不退步

M2-1 配課管理

  • 描述:scheduling_unit/course_assignment/assignment_teacher/block_rule model 與 CRUD;配課建立 UI(班級選科目→指定教師→週節數→連堂→場地需求);跑班群組建立(選多班級組成 group,群組內建多筆配課);教師鐘點即時統計側欄(配課數 vs 基本鐘點,超/不足變色);Excel 批次匯入配課。
  • 模組:app/models/assignment.pyapp/api/assignments.pyfrontend/src/views/scheduling/Assignments.vue
  • 驗收標準: 1. 可建立「301 班 × 國文 × 王師 × 每週 5 節」與「高二多元選修跑班群組(3 班 5 組)」 2. 可建立「機械科實習 × 2 位協同教師 × 每週 6 節含 3 連堂×2」 3. 王師配 22 節、基本鐘點 20 → 側欄顯示「+2 超鐘點」紅字 4. 班級週配課總節數 > 可排節次數(經 resolve_period_table+regular_slots)時警告 5. 跑班群組成員班級的節次表不一致 → 建立被拒(architecture.md D7 第 4 點)
  • 測試方式:pytest(含跑班/協同/連堂三種結構)

M2-2 TimetableGrid 課表元件

  • 描述:前端核心元件:CSS Grid 週課表,依節次表渲染(含反灰不排課時段);格位卡片(科目/教師/場地/鎖定圖示);HTML5 拖拉(從未排清單拖入、格間移動、拖出移除);視覺狀態(可放綠框/衝突紅框+原因浮窗);響應式(平板可用,手機唯讀)。純展示+事件元件,不含商業邏輯
  • 模組:frontend/src/components/timetable/
  • 驗收標準: 1. Storybook(或示範頁)展示:國小 40 分節次表與技高 50 分節次表各一張 2. 拖曳過程觸發 check 事件、放下觸發 drop 事件,由父層決定結果 3. Vitest 元件測試涵蓋渲染/拖放事件/鎖定顯示
  • 測試方式:Vitest + 示範頁人工檢視

M2-3 衝突檢查服務與手動排課 API

  • 描述:timetable/schedule_entry model;衝突檢查服務(H1–H10 硬約束的單格檢查版,architecture.md §3.2);教師/場地衝突在跨節次表時以牆鐘時間區間重疊判定(architecture.md D7,同表退化為 period_no 相等);API:建立草稿、格位增刪改、POST /timetables/{id}/check-conflict(<100ms)、鎖定/解鎖;跑班群組拖一格連動全組。
  • 模組:app/services/conflict_checker.pyapp/api/timetables.py
  • 驗收標準: 1. 王師已在週一第一節有課,再排他班同時段 → 回衝突「教師王師 週一第一節 已有 302 班數學」 (時段一律以節次表中的名稱呈現:早自習/午休/第一節,不可用內部 period_no 索引) 2. 連堂課拖至跨午休位置 → 拒絕並說明 3. 跑班群組某組拖到新時段,全組連動;任一組衝突則整組拒絕 4. check-conflict 在 60 班資料量下 p95 < 100ms 5. 跨節次表衝突:王師在國小部(40 分/節)週一第 4 節 10:30–11:10 有課,再排他至高中部(50 分/節)週一第 3 節 10:10–11:00 → 回報衝突(牆鐘時間重疊)
  • 測試方式:pytest 覆蓋 H1–H10 每項至少 2 案例(過/不過);效能測試腳本

M2-4 排課工作台整合

  • 描述:整合 M2-2 元件與 M2-3 API 成完整工作台(architecture.md §5.1 線框):左側未排課務清單(含剩餘節數)、三視角切換(班級/教師/場地)、草稿自動儲存、復原/重做(前端 command stack)。
  • 模組:frontend/src/views/scheduling/Workbench.vue
  • 驗收標準: 1. 以國中 fixtures 手動排完一個班整週課表,未排清單歸零 2. 三視角資料一致(班級視角排的課,教師視角立即可見) 3. Ctrl+Z 可復原最近 20 步
  • 測試方式:Playwright E2E「排完一班」情境

M2-5 版本管理與發布

  • 描述:多草稿並存(複製/改名/刪除);發布(draft→published,同學期舊 published 轉 archived);發布前完整性檢查(未排完課務列警告,可強制發布);全員課表查詢頁(班級/教師/場地,唯讀,手機可用);audit_log 記錄發布。
  • 驗收標準: 1. 兩份草稿可並存互不影響;發布 B 後,查詢頁顯示 B,A 仍可編輯 2. 有 3 節未排時發布 → 出現警告清單,確認後仍可發布 3. teacher 角色登入手機瀏覽器可查本人課表
  • 測試方式:pytest 狀態轉換 + Playwright

M3 自動排課

M3-0 三套學制驗證資料集(M2 健檢 2026-07-10 產出,M3-1 前必做)

  • 背景:測試策略總則承諾的三套 fixtures(標註「M1 期間建立,全案共用」)實際從未建立——backend/tests/fixtures/ 目錄不存在,M1–M2 測試皆為各檔就地造小數據。M3-1/2/3/5 與 M5-4 的驗收全部以「三套 fixtures」為前提;不先補齊,每張 M3 卡會各自造資料、解的品質彼此不可比。
  • 描述:以 Python builder 函式(非靜態 JSON,直接用 models 寫入測試 session)實作三套資料集: 1. elementary_small:國小 6 班(包班+科任、週三下午空、導師時間); 2. junior_high_mid:國中 12 班(領域課程+彈性課程+兼行政減課教師); 3. vocational_high:技高 15 班 3 科(3 連堂實習+實習工場+業界師資 unavailable 時段+跑班群組); 4. 各附煙霧測試證明資料自洽:teacher_loads 無超鐘點、class_loads 不超可排節數、跑班群組同節次表。
  • 模組:backend/tests/fixtures/{__init__,elementary,junior_high,vocational}.py
  • 驗收標準: 1. 三套 builder 可在乾淨測試 DB 建出完整學期(節次表/教師/班級/科目/場地/配課/連堂/時段規則) 2. 煙霧測試通過(資料自洽,可被 CP-SAT 排出全解) 3. 既有 135 個後端測試不退步
  • 測試方式:pytest

M3-1 Solver 資料層與 pre-flight 檢查

  • 描述:solver/ 模組骨架:從 DB 讀取學期資料轉為純 dataclass 問題描述(solver 不碰 SQLAlchemy;DB→dataclass 轉換層放 app/services/solver_data.py,因 loader 必須 import models,放 solver/ 內會違反驗收 3);pre-flight 必要條件檢查(教師配課數≤可排格數、場地供需、班級節數,architecture.md §3.4)+ 班級人數>場地容量警告(D8);檢查報告 API。
  • 補遺(M2 健檢 2026-07-10):schedule_entries.room_id(nullable FK,空=沿用配課的 room_id)+ 遷移——§2.2 承諾格位帶場地但 M2 未實作;solver 對「指定場地類型而未綁定場地」的配課需逐格指派場地,結果無處可存(M4 教室異動也需要)。conflict_checker _build_occupancy 與課表序列化改以 coalesce(entry.room_id, assignment.room_id) 取場地。
  • pre-flight「教師可排格數」定義:單一節次表(絕大多數學校)=一般課格數 − unavailable 格數;跨表任教的教師以牆鐘區間聯集去重計數(D7 重疊矩陣)。
  • 模組:app/solver/preflight.pyapp/solver/problem.pyapp/services/solver_data.py
  • 驗收標準: 1. 三套 fixtures(M3-0)皆可轉出問題描述且 pre-flight 通過 2. 人為製造「王師 22 節但可排 20 格」→ 報告明確指出教師、數字 3. solver 模組 import 不到 app.api/app.models(以 import-linter 或測試保證) 4. 手動排課將格位放到與配課不同的場地後,check-conflict 以格位場地判定佔用
  • 測試方式:pytest

M3-2 CP-SAT 核心建模(硬約束)

  • 描述:實作 H1–H10 硬約束建模(architecture.md §3.2);連堂以區間建模;跑班同步;鎖定格位;場地互斥(D8);教師/場地跨節次表以 D7 重疊矩陣建模;求解取出結果轉 schedule_entry 列表(含逐格 room_id)。
  • 補遺(M2 健檢 2026-07-10): 1. ortools 依賴此卡才加入 pyproject(M3-1 不需要,保持 pre-flight 輕量);注意 wheel 體積(~50MB)與 arm64 wheel 可用性,重建映像驗證; 2. H10 精確定義以獨立 validator 為準:同班同科目每日「單節」數 ≤ 上限,連堂(block_rule 產生)的節數不計入;M2-3 手動 conflict_checker 目前把既有連堂 span 也計入每日計數,與此不一致,此卡順手對齊(改法:佔用索引的 subj_count 排除 span>1 或掛 block_rule 的格位)。
  • 模組:app/solver/model_builder.py
  • 驗收標準: 1. 三套 fixtures 各自可解,且逐項驗證解零硬約束違反(以獨立 validator 檢查,不信任 solver 自己) 2. 技高 fixture 的 3 連堂課全部連續且不跨午休;實習工場同時段不超容量 3. 鎖定 5 格後重解,該 5 格位置不變 4. 12 班國中 fixture 在 CI 機器 60 秒內解出
  • 測試方式:tests/solver/ + validator.py(獨立驗證器,亦供日後回歸)

M3-3 軟約束與目標函數

  • 描述:實作 S1–S8 軟約束(architecture.md §3.2)加權目標;權重設定存 DB(constraint_config,含 H10 每日上限值),UI 於 v2 才做,先用預設值;解出後產出「軟約束達成度報告」(各項得分/滿分、未達成明細)。補遺:subjects.is_major(主科標記,S5 用)+ 遷移 + 科目表單勾選——現行 Subject 無此欄位。
  • 驗收標準: 1. 同 fixture 開/關 S2(同科分散)比較:開啟後同班同科目同日 ≥2 節的數量顯著下降 2. 教師 avoid 時段在有替代方案時被避開 3. 報告列出「王師週四第7節被排課(偏好未達成)」等人話明細
  • 測試方式:pytest 比較性測試(斷言方向性,不斷言絕對分數)

M3-4 Worker 整合與進度回報

  • 描述:排課任務走 RQ:POST /timetables/{id}/auto-schedule 入佇列;CP-SAT callback 每 5 秒寫進度(已找到解的目標值、經過時間)至 Redis;前端進度頁(polling)含「提前結束取目前最佳解」與「取消」;timeout 預設 10 分鐘可設定;結果寫回為新草稿。輸入輸出流定義(M2 健檢 2026-07-10):以來源草稿為輸入;locked 格位作為固定約束(H9)複製至結果草稿並保持鎖定;未鎖定的既有格位以 CP-SAT hint(AddHint)餵入以提高解的穩定性(重排時盡量少動);結果草稿命名「{來源名} 自排結果」,來源草稿不動。
  • 模組:app/workers/solve_job.pyfrontend/src/views/scheduling/AutoSchedule.vue
  • 驗收標準: 1. 啟動排課後 UI 顯示進度;點「提前結束」拿到當前最佳解草稿 2. 排課期間 Web 其他功能不受影響(worker 隔離) 3. worker 容器被 kill 後任務標記失敗,UI 有明確錯誤而非永久轉圈
  • 測試方式:pytest(RQ 假佇列)+ Playwright 長流程

M3-5 無解衝突定位(conflict explainer)

  • 描述:衝突定位(architecture.md §3.4):無解時指出是哪幾條硬約束湊在一起,轉譯為教務語言建議;「部分排課」模式(使用者勾選可放寬的約束類別,將其轉為高權重軟約束,未排入課務列清單)。
  • 模組:app/solver/conflict_explainer.py
  • 驗收標準: 1. ✅ 製造「音樂教室需求 30 節 > 實際可用 28 節」的 fixture → 報告指出場地與數字 2. ✅ 製造教師時段矛盾 → 報告指出該教師(兩位協同教師都點名) 3. ✅ 部分排課模式:同 fixture 產出 97.8%(88/90)排入的草稿 + 未排清單
  • 測試方式:pytest(3 種人造無解情境 + 部分排課 + pre-flight 短路);Playwright ×2;真實 PostgreSQL + RQ 實測

補遺(實作後) - 改用刪除法,不用 assumption / unsat core。原設計是每類硬約束掛 assumption literal 取 unsat core,實測不可行:enforcement literal 讓 presolve 認不出鴿籠結構,同一份資料純硬約束 0.8 秒證完,掛 assumption 後 60 秒證不完(換過三種編碼皆然)。改成「把一組約束整個關掉、重建乾淨模型重解」,整套定位約 2~3 秒,且每條結論都被一次真實求解驗證過。 - 旋鈕只取教學組長改得動的東西:H4 教師不可排時段、H3 場地互斥、H10 每日科目上限、H9 鎖定格位。H1/H2 沒有旋鈕可轉,其成因 pre-flight 已算得出來。 - H1/H2/H3 永不可放寬——那是物理不是政策(API 收到會回 400)。 - 逾時且零解時也跑定位:帶軟約束目標函數的 CP-SAT 常證不出 INFEASIBLE,一律以純硬約束探測一次,才分得出「不可能」與「只是慢」。這是實機驗證才發現的——單元測試裡的小問題秒證無解,看不到這個坑。 - 部分排課的懲罰量級:未排入(10000) ≫ 放寬的約束(1000) ≫ 軟約束(1~8);因此其 objective 與一般模式尺度不同,UI 顯示「未排 N 節」而非目標值。 - 部分排課的 pre-flight 只擋結構性錯誤(連堂放不進、群組節數不一致、某類型場地掛零)。 - 部分排課結果一律再過 validator:除了被放寬的那類,其餘硬約束零違反。


M4 調代課

M4-1 請假登記與受影響節次展開

  • 描述:leave_request/affected_period model;教師自登/組長代登 UI;依 published 課表展開受影響節次(半天/多天/跨週假);銷假(級聯取消處置並通知,architecture.md §5.3 狀態機)。
  • 驗收標準: 1. 王師請週三整天假 → 自動列出週三 5 節受影響課 2. 請假 3 天跨週末 → 只展開上課日節次 3. 銷假後已指派代課的教師收到取消通知
  • 測試方式:pytest(日期邊界:週末、學期起訖外拒絕)

補遺(實作後) - affected_period 是快照,不是 join(這是 M4 的地基決策):展開當下把配課/教師/班級/場地/節次名稱/起訖時間一併寫死。理由與 D4 一致——課表可以重新發布,但「王師 11/12 第三節原本要上 301 班國文」是既成事實,不該隨課表改版漂移,更不該讓一筆已指派的代課隔天指向另一門課。溯源指標(schedule_entry_id/course_assignment_id)課表刪除時 SET NULL,快照欄位仍在。真機驗過:刪掉已發布課表後,受影響節次原封不動。 - 只看已發布課表:草稿隨時會變,拿草稿找代課老師沒意義。課表未發布時假單照樣成立,只是展開 0 節。 - 上課日由節次表決定,不寫死週一~週五:六日制學校的週六有課由 num_weekdays 判定(週末跳過 = isoweekday() > num_weekdays)。 - 半天假以牆鐘時間區間重疊判定;節次表沒填起訖時間時保守列入(寧可多列一節讓組長刪,也不要漏掉一節變成沒老師的教室)。多日假只有頭尾兩天受時間限制,中間整天。 - 銷假級聯:已完成的節次不動(課上過了,鐘點照算);已指派代課的轉為已取消並合併通知代課教師(一人多節一封信);當事人另收銷假通知。這條在 M4-1 就做掉,不留到 M4-3。 - 通知只落地、不寄送:notifications.notify() 是 M4-3 NotificationChannel 的寫入點;寫入永遠成功,寄送(站內鈴鐺/Email)可失敗可重試,不綁同一交易。 - RBAC:教師自登只能登自己、只看自己;組長/主任可代登代銷看全校。教師端請假頁手機可用(路由守衛新增 leaves 為教師可進頁面)。 - 前端:日期一律附星期(「2026-11-11(週三)」),否則看不出跨六天為何只有一天有課;已取消狀態改用淡色文字,不與「待處理」搶眼(Naive 的 tag type="default" 在此仍沿用前次主題色,故不用 tag)。日期用本機格式不用 toISOString(UTC 會讓台灣凌晨倒退一天)。

M4-2 調代課處理工作台與推薦引擎

  • 描述:逐節處理 UI(代課/調課/併班/自習/不處理);代課推薦服務:硬性過濾(該時段空堂、當日未請假)→ 排序(同科目 > 當日已在校 > 本月代課鐘點少),每位候選附排序理由;調課(swap)驗證(architecture.md §5.3);指派即生效(不設邀請/婉拒流程,2026-07-09 使用者定案:組長實務上已事先口頭徵得同意,通知僅為正式告知+確認收到)。
  • 模組:app/services/substitution_recommender.pyfrontend/src/views/substitution/
  • 驗收標準: 1. 推薦清單第一名必為空堂+同科;已滿 6 節者排序靠後 2. swap 後任一方衝突 → 拒絕並說明是誰在哪一節衝突 3. 該時段全校無人空堂 → 顯示「無可代教師」並建議併班/自習
  • 測試方式:pytest 推薦排序表格測試(10+ 情境)

補遺(實作後) - 「週格 vs 特定日期」的落差用獨立的 availability.py 收斂(Fable 5 M3 審查點名的 M4 最大架構工作):可用性判斷疊三層——週課表有沒有課(D7 牆鐘重疊)、當天自己有沒有請假、當天有沒有被指派代別班。今日看板(M4-4)與代課推薦共用這一層。 - 當日請假必須讀假單本身的日期/時間窗,不是展開的 affected_period——這是實作時測試抓到的真 bug:老師請整天假、但某節恰好是他的空堂時,affected_period 不涵蓋那一格(它只在有課的節次才存在),若照它判斷會把一位不在校的老師找來代課。改為直接比對 leave_request 的 start/end 日期時間(與 leaves.expand 同一套半天窗語意)。 - 推薦排序:同科目 > 當天已在校 > 本月代課鐘點少 > 姓名(穩定)。每位候選附人話理由(「同科目教師 · 當天已在校 · 本月已代 2 節」),不是黑箱分數。 - swap(調課)驗四件事:乙在甲那節無課、swap_entry 確是乙的課、甲在補課那節無課也沒請假、補課日星期與該節課相符。任一撞課指名道姓拒絕。swap 交換的節次以快照保存(swap_date/period_name/class_names/subject_name),課表改版不影響已成立的調課。 - 鐘點政策:代課計、併班/自習/不處理不計(可覆寫),供 M4-5 月結。substitution 是處置真相來源,affected_period.handler_teacher_id/status 為冗餘指標。 - 指派即生效:建立處置 → 節次轉『已確認』+ 記處理教師 → 通知處理教師(站內落地,M4-3 才寄送)。撤回處置退回待處理並通知取消。無邀請/婉拒(2026-07-09 定案)。

M4-3 通知系統

  • 描述:notification model;通知寄送走 NotificationChannel 介面(architecture.md §5.3,MVP 實作站內+Email 兩個 channel,v2 增 webhook/LINE adapter);收件人解析經 teachers.user_id(站內)與 teachers.email(Email,M2-0 欄位);站內通知(鈴鐺、未讀數、輪詢);Email 寄送走 RQ(SMTP 設定於系統管理,未設定則僅站內通知並提示);通知模板(代課指派/取消、課表發布);教師「確認收到」頁(手機可用,一鍵確認);組長看板顯示各筆確認狀態,未確認者可一鍵再次提醒。
  • 驗收標準: 1. 指派代課後,教師站內+Email 雙通知,點連結直達確認頁,一鍵「確認收到」 2. 組長於看板可見確認/未確認狀態;對未確認者按「再次提醒」重發通知 3. SMTP 未設定時系統正常運作(僅站內通知)
  • 測試方式:pytest(mailhog 容器攔信)+ Playwright 手機視窗尺寸

補遺(實作後) - NotificationChannel 分層:notifications.notify() 建立站內通知列(永遠送達)後,逐一經 CHANNELS(InAppChannel no-op + EmailChannel)派送;v2 加 webhook/LINE 只需再實作一個 channel 並 append。 - Email 的交易語意:EmailChannel 不直接 enqueue,而是把信放進 session.info 的寄件匣;SQLAlchemy 的 after_commit 事件才排入 RQ,after_rollback 則丟棄——交易回滾就不會寄出一封對應到不存在通知的信(雙寫問題的正解)。已測 rollback 不寄、commit 才寄。 - 站內永遠可用,Email 是加分:SMTP 未設定時 email.send 回 False、email_job 只記 log,整個調代課流程照常。這是驗收③,實機在 mailhog 上驗過雙通道。 - SMTP 設定存 app_settings(全域 key/value,非學期範圍);密碼留空 = 不變更,回傳不含明文。管理員專屬。POST /settings/smtp/test 當場寄測試信回報結果(不走 RQ)。 - 確認收到 = 通知層已讀確認,不影響課務(指派即生效,2026-07-09 定案)。教師鈴鐺(輪詢 20s + 未讀數 badge)、組長看板(確認狀態 + 對未確認者「再次提醒」重發,已確認則 409)。 - 開發用 mailhog:docker-compose 加 mailhog(profile dev,不影響正式部署);docker compose --profile dev up 才啟動,Web UI :8025。 - E2E 教訓:共用 e2e_teacher 帳號 + 發布課表的測試會用「最近學期」預設互相污染;測試中途失敗會跳過收尾清理,故改用 test.afterEach 兜底刪除學期。另 Naive 的 message toast 與 tag 同字串會觸發 strict-mode(getByText 命中兩個),toast 文案要與 tag 區隔。

M4-4 今日看板與調代課日誌

  • 描述:儀表板「今日調代課看板」(今日全部異動:誰代誰的課、教室異動);當日調代課通知單列印(A4,傳統公告格式);歷史查詢(依教師/日期/假別篩選)。
  • 驗收標準: 1. 看板即時反映今日已確認處置;無異動顯示「今日無調代課」 2. 列印版面 A4 一頁內,含節次/班級/原教師/代課教師
  • 測試方式:Playwright 快照

補遺(實作後) - 看板/日誌不新增真相,只攤平:substitution_log.py 把「受影響節次 + 處置」join 成一列列可讀紀錄,今日看板與歷史查詢共用同一 LogEntry。真相仍在 affected_period(快照)與 substitution(處置決定)。 - 「今日」以學校時區判定(config.tz,預設 Asia/Taipei),不是 UTC——台灣凌晨的 UTC 仍是前一天(D6)。前端深連結可帶 ?date=&semester_id= 指定任一天,未帶則後端以 school_today() 為準。 - 看板含待處理節次,好讓組長一眼看出還有幾節沒排代課;排除已銷假(cancelled)的節次(那天沒有異動)。列印通知單則只列已安排的處置(公告只公告已定案的)。 - 歷史查詢的 teacher_id 同時比對缺課當事人與接手代課者——查一位教師,他缺的課與他代的課都算相關(以冗餘的 affected_period.handler_teacher_id 命中接手方)。 - A4 列印頁是獨立路由 /daily-board/print(不套側邊欄版面),window.open 新分頁開啟;@media print 隱藏工具列、設 @page A4。校名取自 config.school_name,隨看板回應帶出(免另設定)。 - 踩雷:date/start_time 欄位名遮蔽 datetime 型別——dataclass/pydantic 內欄位命名為 date 後,同類別後續以 date 標註型別會被 mypy 視為「用變數當型別」而報錯。以模組別名 _Date = date 標註型別解決。

M4-5 代課鐘點統計

  • 描述:月結統計:依教師彙total(代課節數、計費節數——併班/自習不計、假別、經費來源標記);Excel 匯出;教師個人可查本人明細。
  • 驗收標準: 1. fixture 一個月 20 筆處置 → 統計數字與手算一致(含不計費項排除) 2. 匯出 Excel 欄位:教師/日期/節次/班級/科目/原教師/假別/計費
  • 測試方式:pytest 計算正確性(邊界:跨月假單拆月計)

補遺(實作後) - 兩個數字:代課節數(所有接手處置:代課/調課/併班)vs 計費節數(counts_toward_hours 為真者)。自習/不處理沒有處理教師,不計入任何人;併班有接手者但預設不計費(可覆寫)。「併班/自習不計」指的是計費,不是代課節數。 - 跨月假單自動拆月:以每一個 affected_period 自己的日期分月,不是以假單分月。王師請 1/30~2/2,1 月的節次進 1 月報表、2 月的進 2 月,無需特別處理。 - 銷假的節次不計但已完成的保留:leaves.cancel 把未完成節次轉 cancelled(那堂課沒上)、保留 completed(課上過了鐘點照算);統計以 affected_period.status != cancelled 過濾,不看假單狀態(才不會漏掉部分銷假的已完成節次)。 - RBAC:組長/主任看全校並匯出 Excel(/substitution-stats + /export);教師只能查自己(/substitution-stats/mine,以 current_teacher 綁定解析,無綁定回空報表)。前端同一頁依角色分流:管理者有教師篩選+匯出鈕,教師版隱藏。教師頁加入路由守衛白名單。 - Excel 兩張表:彙總(教師/代課節數/計費節數)+ 明細(教師/日期/節次/班級/科目/原教師/假別/處置/計費/經費來源);沿用 importer 的 openpyxl Workbook + FastAPI Response(Content-Disposition attachment)。前端以 window.open 帶 cookie 觸發下載。 - 深連結:統計頁與看板頁一樣支援 ?year=&month=&semester_id=,便於分享與測試。

M4 里程碑複審(Fable 5,2026-07-11)與修正

  • 條件 A(已修)——「已完成」不落盤,改讀取時推導:§5.3 的「已確認→已完成:上課日結束自動轉換」原本無任何程式寫入 completed,導致兩道完整性保護失效(銷假會抹掉已上過課的鐘點、可事後改派已上完的代課)。改為 app/core/clock.pyis_past_slot(date,end_time)(以 config.tz 判定):leaves.cancel 對已上過的節次不轉 cancelled、substitutions.assign/clear 對已上過的節次回 409;顯示層 leaves.effective_status() 把 resolved+已過推導為 completed。M5-2 的 RQ scheduler 上線後可再補夜間 sweep 落盤,但正確性不依賴排程。
  • 條件 B(已修)——swap 補課判定漏比對教師:availability._already_covering 的 swap 分支原本只比對 swap_date+period_no,任一筆調課成立後補課日該節次會誤判全校已佔用。改為 join AffectedPeriod→LeaveRequestteacher_id(補課方=該調課請假的當事人)+ status=registered + 節次未取消。
  • 條件 C(已修)——公平計數含幽靈代課:_monthly_sub_counts 未排除已銷假節次,銷假後那筆代課仍計入「本月已代 N 節」,與 M4-5 統計口徑不一。加 status != cancelled。公平計數維持以計費節數(counts_toward_hours)計,與顯示的「本月已代 N 節」一致。
  • 條件 E(已處理):(1) 看板與統計口徑差已記於補遺(唯一分歧=銷假假單中已完成的節次:不上看板但計鐘點,合理);(2) swap 補課可用性退化為 period_no 比對,跨節次表學校有 D7 精度損失,列 v1.x;(3) notifications 的 after_commit enqueue 失敗改為 logger.warning 留痕,不再無聲吞掉。
  • 條件 D(排入 M5-0):學期中重新發布課表後,未來日期的 affected_period 仍指向舊格位——見 M5-0。

M5 報表、備份與發行

M5-0 發行前置(Fable 5 建議,M5-1 前必做)

  • 描述:一次備妥 M5 各卡的共用基礎設施,避免每張卡各自處理環境。 1. PDF/字型基礎:worker 映像安裝 WeasyPrint 系統依賴(Pango/Cairo/gdk-pixbuf)與中文內嵌字型(Noto Sans TC / Noto Serif TC),供 M5-1 PDF、M5-2 之後的報表共用。字型與重量級原生依賴只裝在 worker(匯出走背景任務),api 映像維持精簡。 2. RQ scheduler 骨架:立起定時任務排程器(M5-2 每日備份、條件 A 選配夜間 sweep 都掛這裡);docker-compose 加 scheduler 服務,先跑一個 heartbeat/no-op 週期任務驗證存活。 3. 效能 fixture:以 M3-0 的學制 builder 長出 60 班規模資料集,供 M5-1「60 班批次 < 60 秒」與 M5-4 壓測共用;先確認 builder 能產生該規模。 4. 條件 D:重新發布重展開受影響節次:leaves.expand 只在登記當下依當時 published 課表展開;學期中重新發布課表後,今日之後的 pending/resolved 受影響節次仍指向舊格位(代課老師被派去上已移走的課)。M5-0 先做最小防護——publish 時偵測該學期「今日之後」的受影響節次數 > 0 就於回應與 UI 加警告;完整重跑 expand+diff+通知列為後續增強。
  • 驗收標準: 1. worker 容器內 python -c "import weasyprint" 成功,且以內嵌字型渲染中文 PDF 無 tofu(目視一張測試頁) 2. scheduler 服務啟動後,週期任務有觸發紀錄(log) 3. 60 班 fixture builder 產出資料,基本查詢正常
  • 測試方式:容器內 smoke test(WeasyPrint 匯入 + 中文 PDF)、scheduler 存活紀錄、fixture builder pytest

補遺(實作後) - 多階段 Dockerfile(base / worker):api 用 base(精簡),worker 用 worker(額外裝 Pango/Cairo/gdk-pixbuf + fonts-noto-cjk + poppler-utils + pip .[export] 的 WeasyPrint)。compose 與 CI 皆以 target: 指定;CI 另推 -worker 映像。app/services/pdf.pyrender_pdf 延遲匯入 weasyprint,api 匯入不會失敗(匯出一律走 worker 背景任務)。實機驗證:worker 容器內 weasyprint 69.0 匯入成功,渲染繁中 PDF→PNG 目視無 tofu(排課/調代課/王小明/國文/甲乙丙丁…/藝術與人文 皆清晰)。 - 排程器骨架:worker 已 with_scheduler=True;不加獨立容器(單校部署少一個行程),改用「執行時排下一次」的自我續期心跳(固定 job_id,重啟不堆疊),ensure_scheduled() 於 worker 啟動時排入。M5-2 每日備份、條件 A 選配夜間 sweep 都掛此模式。實機驗證:ScheduledJobRegistry 含 scheduler-heartbeat,手動觸發 job 狀態 FINISHED 且自我重排成功。 - 60 班效能 fixture:tests/fixtures/scale.pybuild_large_school(num_classes=60),以貪婪「最少負載且不超 base」指派教師(不保證可完全排課,量為主);pytest 驗 60 班 660 配課、無教師超鐘點。 - 條件 D 最小防護(已做):timetable_publish.stale_future_affected_count 算「今日之後、依先前課表展開」的待處理/已指派受影響節次;發布回應加 stale_affected,前端發布成功後 >0 則跳警告 toast(請至今日看板/調代課紀錄重新檢視)。完整解(重跑 expand+diff+通知)仍列後續增強。

M5-1 課表匯出

M5-1 課表匯出

  • 描述:班級/教師/場地課表匯出 Excel(openpyxl)、PDF(WeasyPrint,A4 直式含校名/學期/列印日)、PNG;全校總表 Excel;批次匯出(全部班級一鍵 zip)。
  • 驗收標準: 1. 三種格式與畫面課表內容一致;PDF 中文無亂碼(內嵌字型) 2. 60 班批次匯出 < 60 秒
  • 測試方式:pytest 內容比對(Excel 讀回驗證)+ 人工檢視 PDF 版面

補遺(實作後) - 共用格線模型:timetable_export.py 把已發布課表(D4 快照)攤成 Grid(節次列 × 星期欄,連堂以 span 合併),三種對象(班級=科目/教師/教室、教師=科目/班級/教室、場地=科目/班級/教師)與三種格式共用,確保內容一致(驗收①)。 - Excel 在 api、PDF/PNG 在 worker:openpyxl 輕量,班級/教師/場地/全校總表/批次 zip 皆 api 同步產生。PDF 需 WeasyPrint(系統依賴+中文字型只在 worker,見 M5-0),故 PDF/PNG 由 api 以 queue.render_export 阻塞式派到 worker 渲染再取回(RQ result);PNG = WeasyPrint 出 PDF 後 poppler pdftoppm 轉單頁。 - 全校總表 vs 批次:總表=一個 Excel 每班一分頁;批次=每班各一 Excel 打包 zip。單一課表匯出開放所有登入者(課表本就全校可查),總表/批次限教學組長以上。 - 中文檔名:Content-Disposition 用 RFC 5987 filename*=UTF-8'',前端以 fetch blob 下載並解出檔名(順帶處理 4xx/5xx 與載入狀態)。 - 驗收②:60 班批次為 CPU-bound(60 個 openpyxl workbook),與資料庫無關,pytest 用 build_large_school 實測 < 60 秒。驗收①:E2E 下載班級 PNG(走 worker WeasyPrint→pdftoppm)存檔目視:標題/校名/學期/列印日、節次×星期格線、早自習/午休淡色、週三第一節顯示國文/王老師,繁中無 tofu。

M5-2 備份與還原

  • 描述:每日 02:00 自動 pg_dump(保留 30 份,RQ scheduler);管理 UI:立即備份/下載/上傳還原(還原前自動先備份現狀+二次確認);還原後強制全員重新登入。
  • 驗收標準: 1. 備份→改資料→還原→資料回到備份點 2. 上傳非法檔案被拒絕且系統無損 3. 自動備份保留數正確輪替
  • 測試方式:pytest + docker 整合測試

補遺(實作後) - pg 工具版本:基底映像已是 Debian trixie(非 bookworm),其 main 內含 postgresql-client 17;pg_dump 17 可備份 postgres:16 伺服器(client ≥ server 允許)。原想從 PGDG 裝 client 16,但 PGDG 的 libpq5 18 在 trixie 有相依衝突,故直接用發行版的 client。pg 工具只裝在 worker 映像(與 M5-1 的 WeasyPrint 同層),api 維持精簡。 - 跨版本還原的可忽略錯誤:pg_dump 17 的備份含 SET transaction_timeout(v17 GUC),pg_restore 進 v16 伺服器會噴一個可忽略錯誤、exit code=1。依 pg_restore 慣例(0=全成功、1=完成但有忽略錯誤、>1=失敗)放行 exit 1 並記 log,資料仍正確還原(實測 revert OK)。 - api/worker 分工:清單/下載/上傳由 api 直接讀寫共掛的 backups volume;pg_dump/pg_restore 派到 worker(queue.run_backup/run_restore 阻塞式)。每日備份掛 M5-0 的排程器(schedule_daily_backup 於 backup_hour 排 enqueue_at,執行後自我續期;固定 job_id 重啟不堆疊)。 - 強制全員重新登入:session 是無狀態簽章 cookie,還原資料庫不會使其失效。改在 Redis 記一個「最小有效簽發時間」(session_epoch),還原後設為現在;get_current_user 拒絕簽發早於此的 session。Redis 只是被還原的 PostgreSQL 之外的存放點。auth 端有 5 秒行程內快取(fail-open),故強制登出有 ≤5 秒傳播延遲(可接受)。 - 還原後補寫稽核的坑:還原會 pg_terminate_backend 中止所有其他連線(含本請求的 DB 連線),且還原本身會覆蓋整個資料庫——所以還原前寫的稽核會被蓋掉、還原後用舊連線寫會失敗。正解:還原後 engine.dispose() 再開新連線,把稽核寫進還原後的資料庫。 - 非法上傳(驗收②):save_uploaded 先驗 PGDMP 魔數,非法直接拒絕、檔案不落地、不碰資料庫;還原既有備份前也再驗一次檔頭。 - 實機驗證(docker 整合):worker 內 pg_dump 17.10;備份→插入學期→還原→筆數回到備份點 ✅;輪替 3→keep=1→1 ✅;api POST /backups(RQ 阻塞)→201、POST restore→200(自動 presafe 備份)、/auth/me 由 200 轉 401(強制登出,~5s)、重登 200 ✅;非法上傳 400 且無檔案落地(pytest)。

M5-3 部署文件與發行工程

  • 描述:docs/deploy/ 中文圖文:Docker 安裝(Win/Linux/NAS)、三步驟安裝、升級、備份策略、VPS+HTTPS 選配、常見問題;README(中文為主+英文摘要);CHANGELOG;GitHub Release 流程(tag→CI 出雙架構 image);LICENSE(MIT);CONTRIBUTING.md。
  • 驗收標準: 1. 依文件在乾淨 VM 從零安裝成功(實測) 2. docker compose pull && up -d 從前一版升級,資料完整、遷移自動執行
  • 測試方式:乾淨環境實測(記錄於 PR)

補遺(實作後) - 同一份 compose,兩種部署:為讓驗收②的 docker compose pull 有意義,docker-compose.yml 的 web/api/worker 三服務同時掛 image:(GHCR)與 build:——clone 原始碼者 up -d 仍在本機建置(行為不變),只需檔案者 pull && up -d 拉官方映像。映像版本由 .envIMAGE_TAG(預設 latest,建議正式部署釘選版本號)決定。 - CI 補版本標籤:原 images job 只推 :latest:sha,IMAGE_TAG=v1.0.0 會拉不到映像。三個映像各補推 :${github.ref_name}(main push=main、版本標籤=v1.0.0),版本釘選才真的成立;版本標籤仍為唯一觸發雙架構(amd64+arm64)的條件。 - HTTPS 選配做成一個設定:Caddyfile 站台位址寫死 :80 且烘進映像,拉映像的學校改不到。改為 {$SITE_ADDRESS::80} 環境變數(預設 :80 內網 HTTP;於 .envSITE_ADDRESS=網域名 即自動申請/續期 Let's Encrypt 憑證)。compose 補 443 埠映射與 caddydata volume(憑證持久化,避免重啟觸發速率限制)。實測:預設(無網域)重建 web 後 /api/health 與首頁皆 200、web healthy,內網 HTTP 部署未受影響;docker compose config 在有/無 SITE_ADDRESS 兩路徑皆正確解析。 - 文件產出:docs/deploy/(index/install/upgrade/backup/https/faq 六篇中文,含 Win/Linux/Synology/QNAP 安裝、異地備援、回滾與 schema 變更提醒、VPS 對外埠與資安)、改寫 README.md(英文摘要+功能總覽+雙部署快速開始+文件索引)、新增 CHANGELOG.md(Keep a Changelog,彙整 M0–M5)、CONTRIBUTING.md(開發環境/品質門檻/任務卡制/發布新版本流程)。LICENSE(MIT)M0 已具備。 - 驗收①「乾淨 VM 實測」的界線:compose 解析、web 重建與預設 HTTP 服務已在本機 Docker 驗過;真正的「全新 VM 從零 pull 安裝」需待版本標籤推上 GHCR 後才可端到端跑(目前尚無 release tag),此步驟留給實際發布時(或使用者)在乾淨環境驗收並記錄於 PR。

M5-4 E2E 總驗收與效能

  • 描述:Playwright 全流程情境:精靈建置→匯入→配課→自動排課→發布→請假→代課→月統計;效能驗收;無障礙基本檢查(鍵盤可操作、對比度)。
  • 驗收標準: 1. 三套 fixtures 全流程 E2E 綠燈 2. 60 班規模:頁面載入 p95 < 2s、check-conflict p95 < 100ms、自動排課 < 10 分鐘 3. 以 4GB RAM 容器限制跑全流程不 OOM
  • 測試方式:CI E2E + 壓測腳本

補遺(實作後) - 驗收①分兩面落實:(a) 後端 tests/test_full_flow.py三套學制 fixtures(國小/國中/技高)各跑完整管線——求解 → validator 驗零硬違反 → 發布快照 → 挑一位有課教師請整天假 → 依已發布課表展開受影響節次 → 用推薦挑空堂教師指派代課 → 月結統計數字對上;證明下游鏈路能吃真實求解結果並在三種學制一致成立(M3 只證到「解得出」)。(b) 前端 full-journey.spec.ts 一個學期連續走完自動排課(真實 solver worker)→ 版本發布 → 課表查詢 → 請假+代課(API)→ 月結(UI),截圖目視:自排「已找到 16 個解、產生草稿A 自排結果」、月結頁「國文師1 代 國文師2 事假 1 節、計費 1 節」。個別旅程細節仍由既有 26 支 spec 深入覆蓋。 - 踩雷:全流程測試的兩個時間性地雷。(1) 求解要用 hard-only config(SolverConfig.hard_only()),否則掛軟約束目標函數的 CP-SAT 會為了逼近最佳跑到 max_seconds 天花板——三套 fixtures 各跑 120 秒共 6 分鐘;改 hard-only 後全部 15 秒。(2) 學期起訖必須設在今日之後:代課處置會用 clock.is_past_slot 拒絕已結束的節次,起初用 2025 的日期整批被判為「已結束」而指派失敗。 - 驗收②(check-conflict p95<100ms):tests/test_perf_scale.pybuild_large_school(60)(660 配課)塞入 >1500 格合法佔用,量 30 次單格衝突檢查 p95。頁面載入 p95<2s:perf-page-load.spec.ts 對灌了 60 班的執行中全棧量測。方法學校正:最初用重複 page.goto 全頁重載量到配課頁 p95≈2.9s——那是每次重新下載/解析 ~1.4MB SPA bundle 的成本(CPU 競爭下放大),不是使用者實際的換頁延遲。改量應用內導覽(bundle 已暖):配課頁/課表查詢 p95≈85–89ms;冷啟首載(含 bundle)另記 1069ms,兩者皆 <2s。1.4MB bundle 的冷啟成本仍列 Backlog(按元件 import 縮小)。自排<10 分:60 班求解耗時不放進 CI 單元測試(以免每次跑十分鐘),由 M3 的 junior_high <60s 建模測試 + 全流程實測共同保證。 - 驗收③(4GB 不 OOM):新增 docker-compose.limits.yml(疊在正式 compose 上,mem_limit 合計 3.2GB:worker 1.5G/api 768M/postgres 512M/redis 256M/web 128M,留 ~0.8G 餘裕)。以此上限重建全棧跑完整旅程(含真實 solver),docker stats 峰值 api 132M/768M、postgres 36M/512M、worker 於小校求解下寬裕;全五容器 OOMKilled=falseRestarts=0。此檔亦作為 4GB 主機/NAS 部署參考。 - 無障礙基本檢查:a11y.spec.ts 三案——(1) 僅鍵盤(Tab/輸入/Enter)完成登入直達儀表板;(2) 連續 Tab 焦點可達可互動元素;(3) 以 WCAG 相對亮度公式量對比:內文 ≥4.5:1(1.4.3 正常文字)。誠實揭露(Fable 5 M5 複審 H):主要按鈕白字 on 主題綠(#18a058)≈ 3.4:1,只達 1.4.11 非文字元件的 3:1 底線,未達 AA 文字標準 4.5:1;測試僅以 ≥3:1 為最低防線並註明此限制,主題色調整列 Backlog(v1.x)。 - 品質門檻:後端 ruff/mypy 乾淨、pytest 371 passed(+4);前端 eslint/vue-tsc build/vitest 11 綠;Playwright 全套(既有 26 + 新增 a11y 3 / 全旅程 1 / 頁面載入 1)。

M5 里程碑複審(Fable 5,2026-07-11)與修正

M5 完成後由 Fable 5 做獨立技術審查,判決「有條件可發行」:核心(備份資料路徑、匯出正確性、排程時區)健全,裂縫集中在佇列互踩與發行流程未演練。以下 A/B/D/E/F 已修(各附回歸測試 tests/test_m5_hardening.py),H 誠實化,C 待使用者決定公開時機一起走。

  • A(已修)——單一佇列阻塞式派工互踩,逾時的還原仍會晚點偷跑:排課佔住單一 worker 時,還原排在後面;api 逾時回失敗,worker 空下來後卻仍執行還原→資料庫被無預警覆蓋。修:queue._run_blocking/render_export 逾時後 job.cancel()(不留在佇列裡等著跑);queue.solver_busy() 偵測排課進行/排隊中,backups._restore 在還原前檢查,進行中回 409「排課進行中,請待排課完成後再還原」。分出 ops 佇列 + 第二 worker 行程為更完整解,但會引入行程/容器/記憶體(4GB 預算)複雜度,列 Backlog 專卡處理;A 的資料安全洞已由 cancel + 409 封死。
  • B(已修)——每日備份鏈一次失敗即永久靜默斷裂:daily_backup_job 先備份再排下一次,任一次失敗→例外→下次永不排入→自動備份無聲停止。修:daily_backup_job 改 try/finally 先在 finally 排下一次;scheduler.heartbeat 同改 try/finally,並順帶自癒——每小時檢查 DAILY_BACKUP_JOB_ID 不在 ScheduledJobRegistry 就補排。
  • D(已修)——:latest 對 ARM 用戶是地雷:main push 只建 amd64 卻覆蓋 :latest,ARM NAS(IMAGE_TAG=latest)pull 到無 arm64 manifest 會起不來。修:CI images job 以 channel 區分——main push 推 :main,:latest 只在版本標籤(雙架構)時更新。
  • E(已修)——pg_restore exit 1 容忍面太寬、警告不進 UI:exit 1 涵蓋任何被忽略錯誤,某張表 COPY 失敗也是 exit 1,只憑 returncode 會把資料缺漏報成成功。修:backup._classify_restore_stderr 白名單化——只容忍「設定參數不認得」(跨版本 GUC),其餘 pg_restore: error 一律視為失敗(presafe 在,可回退);可忽略警告經 RestoreResult.warnings 上接,前端 System.vue 以對話框顯示給管理員(不再只留 log)。
  • F(已修)——session_epoch 落盤耐久性:Redis 預設 RDB 條件下這個單一 SET 可能一小時未落盤,還原後 Redis 崩潰→epoch 遺失→舊 cookie 復活。修:force_logout_all 設 key 後補 bgsave()(盡力而為,失敗不擋)。
  • H(已誠實化)——a11y 對比主張過度:主色按鈕實為文字(適用 4.5:1)卻套了 3:1 非文字門檻。已在 a11y.spec.ts 與上方補遺如實標註「達 3:1、未達 AA 文字 4.5:1」;主題色調整列 Backlog。
  • C(待處理,發行阻擋-流程,非程式):repo 私有→raw.githubusercontent 匿名 404、GHCR 映像 private→docker compose pull 被拒、IMAGE_TAG=v1.0.0 依賴的版本標籤從未推過(雙架構 arm64 build 一次都沒跑),驗收①「乾淨 VM 從零 pull 安裝」實質未驗。處置:發行前 repo/GHCR 轉 public + 先打 v1.0.0-rc1 演練(驗雙架構 build + 乾淨環境 pull 安裝)再打 v1.0.0留待使用者決定公開時機一起走。
  • v1.x 可延(Fable 5 判定合理,不阻擋發行):G(還原溯源 append-only log、stale 提示持久化;presafe 檔名時戳現已足夠)、以及既有 Backlog。

修正後品質門檻:pytest 392 passed(+21,含 test_m5_hardening 12)、ruff/mypy 乾淨;前端 eslint/vue-tsc build/vitest 綠;Playwright 全套迴歸;docker 實測 A(排課中還原被 409 擋)、B(備份失敗鏈仍存活)。M5 里程碑完成,有條件可發行(待清 C)。

最終發行前總體檢(Fable 5,2026-07-12)與修正

公開發行 v1.0.0 前,Fable 5 做全系統(非逐卡)總體檢。核心健全:逐一核對每個 API 端點的 RBAC——管理類全由 viewer/editor/admin_only 守住,教師類全部限縮本人(_get_leave_get_own_notificationsubstitution-stats/mine 忽略用戶端 teacher_id),跨學期寫入有 semester_id 校驗,無 IDOR 洞;無 SQL 注入面(全 ORM)、無 v-html/XSS;正式 compose 只對外 80/443;.env 不進映像;15 個遷移 downgrade 全部有實作(可逆)。判「有條件可發行」——裂縫集中在「交給非資訊背景教師自架時的安全預設」。發行阻擋 A/B/C + F 已修(回歸測試 tests/test_config_hardening.py 9 個): - A(SECRET_KEY 預設無防呆):config.py_harden 驗證器——secret_key 落在不安全值集合(dev-insecure-change-me/please-change-this-.../空)即以 secrets.token_hex(32) 取代並 logger.warning。避免以公開金鑰簽署 session。 - B(HTTPS 部署 cookie 未帶 Secure):site_address 為真實網域且未顯式設 cookie_secure → 自動 True;docker-compose.ymlSITE_ADDRESS 也傳給 api;.env.exampleCOOKIE_SECURE 說明。 - C(無請求體上限→單校 OOM):Caddyfile 加 request_body { max_size 200MB }(容得下真實 .dump 還原與 Excel 匯入);backup.md 註明超大 DB 還原改用 volume 複製法。相關端點皆需 editor/admin,屬內部誤操作等級。 - F(第三方授權揭露):新增 THIRD-PARTY-NOTICES.md(psycopg=LGPL、poppler/pdftoppm=GPL 子行程、Noto CJK=OFL,皆動態相依/子行程使用,MIT 相容),README 連結。 - D/E/G 列 v1.x(下方 Backlog)。


M6 v1.1 加固(Fable 5 開卡,2026-07-13;範圍與取捨理由見 docs/roadmap.md)

目標:2026 年 8 月排課季前發布 v1.1。依序做,M6-2 先定容器架構,後面的文件與 E2E 疊在其上。 CI 已含 e2e job(30 tests),每張卡完成後 push 即有全棧迴歸把關;仍須遵守既有 DoD(含真 PostgreSQL 實測與截圖目視)。

M6-1 E2E/測試硬編日期動態化(死線:2026-11 前)

  • 描述:多處測試硬編未來日期,真實日期越過後 clock.is_past_slot 會拒絕代課指派,CI 將無聲轉紅。已知:frontend/e2e/substitution-stats.spec.ts(DAY='2026-11-11')、full-journey.spec.tsbackend/tests/test_full_flow.py(_SEM_START=2026-09-01_SEM_END=2027-01-31)等;開工先全案掃一次(grep 2026-/2027-)。改為以「執行當日」推算:請假日=下一個週三(或其他固定星期),學期起訖=今天前後推(起=今天往前一個月、訖=往後六個月之類),集中成 helper(前端 e2e/helpers.ts、後端 conftest 或 fixtures)供各 spec 共用。
  • 驗收標準: 1. 全案無「會過期」的硬編日期(節次表等與日曆無關的常數不在此列) 2. 日期 helper 有單元測試(含「今天就是週三」邊界) 3. 全套 pytest 與 e2e 綠
  • 測試方式:pytest + Playwright 全套;人工檢視 grep 結果確認無漏網
  • 實作後(commit 待補):兩支 helper——backend/tests/dates.pyfrontend/e2e/dates.ts(同一套規則),由「執行當日」推算出一個基準週(距今 ≥14 天:確保基準週每一節都還沒上過,不受執行時刻影響)。範圍比卡上預估大:後端 8 個測試檔、前端 9 支 spec 全數改用 helper 常數。
  • 基準週必須「當週到下週三同月」——這是硬需求,不是美觀:代課推薦的公平計數與月結統計都以「受影響節次那一天的月份」為範圍(_monthly_sub_countsaffected.date.replace(day=1))。第一版 helper 只保證「距今 ≥14 天」,今天(2026-07-14)推出來的基準週剛好是 WED=7/29、WED2=8/5 跨月,於是 test_fewer_monthly_sub_periods_ranks_higher(林師本月已代 1 節、陳師 0 節)與 test_cancelling_leave_keeps_already_taught_period 當場翻車——動態化第一天就抓到自己的設計缺陷。修正:base_monday() 往後找到「週一 +9 天仍同月」的那一週;跨月案例改由 cross_month_wednesday()(相鄰兩個週三分屬前後月)專門負責,月結拆帳測試仍驗得到。
  • 順手修掉一個假 fallback:full-journey.spec.tsleaveDay || '2026-11-11' 是死路徑(兩邊同值),真正該做的是「請假日跟著該格位的星期走」,已改為 dayOfBaseWeek(entry.weekday)
  • manual-shots.spec.ts(手冊截圖產生器,非迴歸):改為向示範站查學期,取「學期內、今日之後的第一個週三」;學期已過期則明確報錯要求重建示範資料,不再靜默產出錯的圖。
  • 驗證:pytest 452 綠(+59,含 helper 的參數化單元測試:每種「今天」落點、跨年、閏年、以及「不論今天是哪一天,WED/WED2 都同月」40 組)、ruff/mypy 乾淨;前端 eslint/vue-tsc/vitest 綠;乾淨全棧(schedci,:8090)e2e 30/30 綠;截圖目視確認 UI 顯示的是動態算出的「2026-08-05(週三)」而非硬編日期。

M6-2 背景任務佇列拆分(default / ops)

  • 描述:單一 RQ worker 循序執行,排課(可達數分鐘)期間匯出/備份逾時失敗(M5 複審 A 的正解)。拆 ops 佇列:匯出(render_export)、備份/還原(_run_blocking)、email 改走 ops;自動排課獨走 default。同一 worker 映像加第二個容器(如 worker-ops,command 帶佇列名;app/workers/worker.py 支援指定佇列)。資料安全語意不變:排課中還原仍須 409(還原覆蓋整個 DB,與排課寫回互斥),solver_busy() 只看 default 佇列即可;每日備份排程器要決定歸屬(建議 ops)。更新 docker-compose.yml(5→6 容器)、docker-compose.limits.yml(worker-ops 建議 512M,總和仍 ≤4GB)、部署/升級文件(docs/deploy/)、CONTRIBUTING 架構描述。
  • 驗收標準: 1. 真實 60 班自動排課進行中,匯出 Excel/PNG 與「立即備份」數秒內成功(docker 實測) 2. 排課進行中還原仍被 409 擋下(既有測試不退步) 3. 排課中 worker-ops 被 kill,匯出回明確錯誤而非無聲卡死;重啟後恢復 4. limits compose 全棧跑 M5-4 旅程無 OOMKilled
  • 測試方式:pytest(佇列路由單元測試)+ docker 全棧實測(排課中匯出/備份/還原)+ e2e 全套
  • 實作後:queue.py 分出 default(只跑 run_auto_schedule)與 ops(匯出 render_export、備份/還原 _run_blocking、寄信 enqueue_email);worker.py 收佇列名參數(python -m app.workers.worker [ops]),entrypoint worker 角色把餘下參數透傳;compose 新增 worker-ops(同一 worker 映像,command: ["worker","ops"]),5→6 容器。
  • 排程器改掛 ops worker:定時任務(每日備份、心跳)都是維運工作,排進 ops 並由 worker-opswith_scheduler=True 撈回執行。排課 worker 不跑排程器——它一忙就是好幾分鐘,不該負責「準時」的事
  • 升級路徑的隱形殺手:M6-2 之前每日備份排在 default 的 ScheduledJobRegistry。排程器改看 ops 後,舊的那筆再也沒人撈——每日備份會在升級當天靜默斷裂(備份最不能忍的失敗模式)。_drop_legacy_default_schedules() 於 ops worker 啟動時以固定 job_id 精準移除舊排程再重排(不碰別人的 job),附回歸測試。
  • solver_busy() 的 409 保留,但理由改寫:分佇列後還原不再「排在排課後面」,可是仍必須擋——這是資料安全不是排隊:pg_restore 覆蓋整個資料庫,而排課 worker 正要把結果寫回同一個庫。
  • 驗證(docker 全棧,六容器 + limits 3.7GB):60 班自動排課進行中打維運端點——① 匯出 Excel 0.0s、② 匯出 PNG(ops worker WeasyPrint 渲染)4.0s、③ 立即備份(ops worker pg_dump)0.5s、④ 還原被 409 擋下;排課全程 running 未被打斷。舊架構下 ①②③ 會排在排課後面直到逾時失敗——這就是這張卡的全部意義。 另驗 ops worker 停擺:匯出/備份回明確 502(「背景忙碌或逾時」,90s/120s 上限後)而非無聲卡死,重啟後 3.6s 恢復。limits 下跑全套 e2e 30/30 綠,六容器 OOMKilled 全 false、Restarts 全 0(worker-ops 峰值遠低於 512MB 上限)。
  • 品質門檻:pytest 463(+11,test_queue_split.py)、ruff/mypy 乾淨;前端 eslint/vue-tsc/vitest 綠;docker compose config 與 limits 疊加皆通過。
  • 文件:docs/architecture.md 新增 D9 佇列分工(含容器圖改繪)、deploy/READMEinstallupgrade(顯著警語:v1.1 多一個容器,只換映像不換 compose 會讓匯出/備份/寄信全部逾時失敗)、faq(新增「匯出一直失敗怎麼查」)、READMECONTRIBUTING(新增背景任務該走哪條佇列的準則)。

M6-3 部分排課三合一(排不下的課不再炸整鍋)

  • 描述:(a) model_builder 對候選為空的課直接 raise SolverInputError,整個部分排課失敗——改為部分排課模式下建模前把該課移入未排清單,其餘正常;非部分排課維持 raise(訊息要進 log,順手修 Backlog「check_feasibility 吞訊息」)。(b) 未排清單目前只活在 Redis 24h(progress.py TTL),草稿可被 force 發布後就沒有任何紀錄——改為隨結果草稿持久化(建議 timetables 加 JSON 欄或子表,Alembic 遷移),版本頁與發布警告都改讀持久來源。(c) _unscheduled() 按 assignment 逐筆記,跑班群組掉一格記 N 筆——按排課單位去重,「未排 N 節」不再灌水。
  • 驗收標準: 1. 一門完全被擋死的課(如未放寬 H4 的協同教學)→ 部分排課成功,該課列未排清單並註明原因 2. Redis 清空後,草稿的未排清單仍可查;force 發布後版本頁可見「發布時未排 N 節」 3. 跑班群組掉 1 格只記 1 筆(單元測試) 4. validator 全套與既有 solver 測試不退步
  • 測試方式:pytest(solver + API)+ 真 PostgreSQL 實測 + e2e auto-schedule spec 擴充
  • 實作後(2026-07-14):
  • (a) 完全排不下的課不再炸整鍋:_make_lesson_vars 對候選為空的 lesson,只在部分排課模式下改為 _force_drop()(建一個恆為 1 的 drop 變數,不建 x/pos 變數),列入未排清單並帶上原因;一般模式維持 raise。部分排課的承諾就是「排不下的列清單、其他照排」,先前卻在最需要它的時候整鍋失敗。
  • (b) 未排清單持久化:timetables.unscheduled JSONB(遷移 0016,真 PG 驗過 upgrade/downgrade 可逆),由 write_result 隨結果草稿寫入。但 Backlog 的描述與現況不符,已據實修正:「哪些課沒排」其實一直查得到——completeness() 從 DB 重算(配課應排節數 vs 已排格位),對草稿與已發布課表皆可,不依賴 Redis。真正只活在 Redis 24h 的是排不下的原因(只有建模當下的 solver 知道)。故設計為:未排清單仍以 DB 推導為唯一真相(連手動改過的課表都算得對),持久化的 solver 紀錄只補上「為什麼」——completeness() 的每筆 unplaced 多一個 reason 欄。
  • (c) 跑班群組不灌水:extract() 的未排節數改以排課單位計數(先前按 assignment 逐筆記)。順帶發現群組的 subject_name 只印第一門選修會誤導(一個群組是「多門選修同時段開」),改為列出所有科目(「選修A、選修B」)。
  • 順手修:check_feasibility 吞掉 SolverInputError 訊息 → 改為記 log(不記的話,未來任何建模 bug 都會偽裝成「這份資料無解」)。Backlog 該項結案。
  • 驗證:pytest 468(+5,tests/solver/test_partial_hardening.py)、ruff/mypy 乾淨;前端 eslint/vue-tsc/vitest 綠;遷移 0016 對真 PostgreSQL upgrade→downgrade→upgrade 全過;e2e 31/31(新增 partial-unscheduled.spec.ts),截圖目視確認自排頁未排表列出「美術 · 找不到任何可排的 1 連堂時段」、版本頁發布警告的「原因」欄同樣顯示該句,且 force 發布後 completeness 仍查得到。

M6-4 開新學期複製補全(起訖日 + constraint_config)

  • 描述:semester_copy.py 不帶學期起訖日與 constraint_config(軟約束權重回預設),新學期忘補起訖日會讓「今日」判定全錯。複製對話框加起訖日欄位(必填,預設帶「上學期 +半年」推算值);constraint_config 隨複製帶過去。
  • 驗收標準: 1. 複製後新學期有正確起訖日與相同軟約束權重(真 PG 實測) 2. 前端對話框有起訖日欄位與預設值 3. e2e copy-semester spec 擴充驗證
  • 測試方式:pytest + e2e
  • 實作後(2026-07-14):SemesterCopyRequeststart_date/end_date(pydantic 驗證結束不早於開始 → 422)與 constraint_config: bool = True;copy_semester() 以 keyword-only 收起訖日,並複製 constraint_configs 各列。起訖日刻意不沿用來源(那是上學期的日期),由呼叫端明確給。前端複製對話框新增起訖日 date-picker,預設值為來源學期往後推半年(halfYearLater()),並在下方提示「請確認實際校曆後修改」;起訖日未填時「建立新學期」停用(漏填不會報錯,但請假展開、今日看板、代課「已上過」判定會整個算錯,而畫面上看不出來)。複製項目多一個「排課偏好設定」勾選——先前新學期會悄悄回到預設權重,上學期調好的偏好就白調了。
  • 驗證:pytest 472(+4:起訖日寫入、顛倒日期 422、偏好跟著複製、明確不勾選時回預設)、ruff/mypy 乾淨;前端 eslint/vue-tsc/build/vitest 綠;e2e 31/31(copy-semester.spec.ts 擴充為驗起訖日預設值 +6 個月、實際寫入、偏好設定跟著走),截圖目視確認對話框帶出「2027-03-01 ~ 2027-07-20」。真 PostgreSQL 實測:來源設 cap=4/S2=55/S5=30 → 複製後新學期起訖 2027-02-15~2027-06-30、偏好完全一致;顛倒日期回 422。

M6-5 小型加固批次(六小項)

  • 描述:一次出貨六個 S 級項目——①班級名稱加 uq(semester_id, name)(遷移前先清重複,API 撞名回 409);②/api/docs/openapi.json 預設關閉,.env 顯式開啟(API_DOCS_ENABLED,dev compose 帶開);③主題主色調深至白字對比 ≥4.5:1(不動整體設計),a11y.spec.ts 按鈕門檻提到 4.5 並移除「未達 AA」註記;④衝突定位把 should_stop 傳進 conflict_explainer 逐步試解迴圈,按取消得 cancelled;~~⑤check_feasibilitySolverInputError 訊息記 log~~(M6-3 已修,本卡不必再做);⑥substitution-log/leaves 等清單查詢加伺服器端上限(如 limit≤1000,完整分頁留 v1.2)。
  • 驗收標準:逐項——重複班名 409 且遷移可從有重複資料的庫升級;正式 compose 下 /api/docs 404、設定開啟後可用;a11y 測試以 4.5 門檻綠;定位中按取消 ≤數秒內回 cancelled;清單超限回截斷結果與提示
  • 測試方式:pytest(每項至少一測)+ e2e(a11y、取消路徑)+ 真 PG 遷移實測
  • 實作後(2026-07-14):
  • ① 班名唯一:uq(semester_id, name)(遷移 0017)+ API 建立/改名 409 + Excel 匯入逐列擋下(檔案內重複、與既有班級重複都指出是第幾列,不讓它撞 DB 約束變成看不懂的錯誤)。遷移必須先處理既有重複資料——有重複班名的學校正是最需要這個約束的人,不能讓他們一升級就失敗。重複者依 id 保留第一筆、其餘改名為「301 (2)」…,且會避開資料庫裡已存在的同名;不刪任何資料。真 PG 實測最惡劣情況(三個 301 + 一個既有的「301 (2)」)→ 得到 301 / 301 (3) / 301 (4) / 301 (2) / 302,零重複、約束建立、可逆。
  • /api/docs 預設關閉:api_docs_enabled=False(docs_url=None → 路由不存在,404);.env 可顯式打開,docker-compose.dev.yml 帶開。端點本身都有權限守著,公開它不是漏洞,但沒必要把整套內部 API 攤在網路上(尤其 VPS + 公開網域)。正式棧實測 /api/docs → 404。
  • ③ 主色達 AA:新增 frontend/src/theme.ts——Naive 預設 #18a058 白字只有 ~3.4:1(那是 1.4.11 非文字元件的門檻,而按鈕上的字就是文字)。壓深到 #0d7a43(5.41:1),hover #0e8449(4.76:1)也達標,色相不動。a11y.spec.ts 門檻從權宜的 3:1 提到 AA 的 4.5:1,並移除 M5 留下的「未達 AA」誠實揭露——現在真的達了。
  • ④ 定位可取消:explain()should_stop,每次試解前檢查並擲 Cancelled;solve_job 接住後把任務標為 cancelled(而非 failed)。定位最長跑一分鐘,先前完全不看取消旗標——使用者按了取消只能乾等,最後還收到一份他已經說不要的報告。
  • ⑥ 清單上限:substitution_log.query(limit=MAX_ROWS)GET /leaves(MAX_LEAVE_ROWS)各 1000,下到 SQL 的 .limit()(測試以 limit=1 驗證真的生效,不是個沒人用的參數)。完整分頁 UI 留 v1.2。
  • 驗證:pytest 482(+10,tests/test_m6_hardening.py)、ruff/mypy 乾淨;前端 eslint/vue-tsc/build/vitest 綠;e2e 31/31;真 PG 遷移實測(含重複資料與可逆);正式棧 /api/docs 404;截圖目視新主色。
  • e2e 抓到一個既有的測試腳本缺陷:substitutions.spec.tsklass() 看似 get-or-create,其實每次都 POST——place('王師','國文','701') 會把「701」再建一次。先前靠著「允許重複班名」矇混過去(實際上悄悄建了兩個 701),加了唯一約束後當場 409。已改為真正的 get-or-create。連帶 wizard.spec 也曾紅一次:它斷言儀表板顯示的學期,而 substitutions 失敗後沒跑清理、殘留的學期把儀表板頂掉了——是連鎖傷害,不是第二個 bug

M6-6 複審修正(Fable 5 M6 複審判為「有條件可發行」的兩個阻擋項 + 兩個順手項)

  • 描述:A(阻擋)ops 佇列無 worker 時 fail-fast;B(阻擋)dev compose 沒有任何行程守 ops;C 核心相依釘主版號上限;D 清單截斷提示。
  • 驗收標準:停掉 worker-ops 後匯出/備份/還原立即回一句說得出處置的錯誤(不是逾時);dev compose 起得動匯出/備份;相依裝得起來且全測綠;清單取到上限時畫面講明被截斷。
  • 測試方式:pytest + vitest + 六容器棧實測(含實跑一次完整還原)
  • 實作後(2026-07-14):
  • A:ops_worker_available()(rq.Worker.count(connection, queue=ops_queue))。render_export_run_blocking(備份/還原)在派工前檢查,沒有 worker 就立刻擲 RenderError/BackupJobError,訊息直接點名 worker-ops 與 docker-compose.yml。派工前擋下對還原尤其要緊:任務若躺在佇列裡,晚點 worker 起來會無預警覆蓋資料庫enqueue_emaillogger.error 不擲例外——它的呼叫點在交易 commit 之後,站內通知已送達,不能為了一封信讓已成功的操作看起來像失敗(信照排,worker-ops 一起來就補寄)。api 啟動時另做一次背景檢查(給 6×2 秒寬限期,避開 compose 平行啟動的假警報)寫進 log。判斷不了時(Redis 抖動)一律放行——誤判成「沒有 worker」會擋掉本來會成功的匯出,比讓它照原路逾時更糟。
  • 原本的升級陷阱比 M6-2 卡上寫的更嚴重:舊 compose 的 command: ["worker"] 在新映像下不只不守 ops,也不跑排程器——每日自動備份是靜默停擺的(匯出逾時至少還很吵)。這正是 fail-fast 必須做進 v1.1 的理由:文件警語擋不住沒讀文件的人。
  • B:docker-compose.dev.yml 的 worker 改 command: ["worker", "ops", "default"](單行程守兩條佇列;正式環境才拆兩個容器)。先前 dev 完全沒有行程在守 ops,匯出、備份、寄信、定時任務全失效——repo 已公開,這會是外部貢獻者的第一印象。
  • C:核心相依全部釘主版號上限(fastapi/sqlalchemy/redis/rq/pydantic/psycopg/alembic/uvicorn/bcrypt/openpyxl/itsdangerous)。踩過:redis 未設上限 → 某次重建裝到 redis-py 8 → 匯出/備份在新環境一律逾時。映像每次發行重新建置,不釘上限等於「上游哪天發大版,使用者的部署自己壞掉」。
  • D:SubstitutionLogLeaves 取到上限筆數(1000)時各顯示一行截斷提示(要看更早的請縮小日期區間/改用調代課紀錄查詢)。M6-5 卡上寫了「與提示」卻只做了截斷——不講的話,組長會以為「這學期就只有這些紀錄」。
  • 驗證:pytest 488(+6,test_queue_split.py:count=0 → 匯出/備份/還原立即擲含「worker-ops」的錯且沒有派工、email 不擲例外仍派工並記 error、Redis 異常時放行)、ruff/mypy 乾淨;前端 eslint/vue-tsc/build 綠、vitest 15(+4:兩個畫面各驗「達上限提示/未達上限不提示」);e2e 31/31六容器棧實測:①停 worker-ops → 匯出 502/0.07s、備份 502/0.014s、還原 502/0.016s,訊息完整(先前是 90~180 秒的謎樣逾時);②docker compose start worker-ops → 匯出 200(43KB PNG);③實跑一次完整還原(M6-2 之後從未在新架構驗過):還原 4.05s、presafe 備份自動產生、學期/班級/已發布課表格位全數回復、舊 session 401「系統已還原或重設,請重新登入」、重新登入後資料正確;順帶確認備份清單裡有 backup_20260714_020000_auto.dump——每日排程確實跑在 worker-ops 上。

M6-7 還原後 log 噴 AdminShutdown traceback(M6-6 實測發現,發行前修掉)

  • 描述:還原成功後,api log 會噴一段 ERROR: Exception in ASGI application + AdminShutdown traceback。功能完全正常(回應 200、資料正確、後續請求正常),但剛按下「還原」的教學組長是全系統最緊張的那一刻——他去看 log 想確認成功與否,迎面一段紅字,只會以為還原壞了。這不是資料問題,是信任問題,不該帶著發行。
  • 根因:pg_restore --clean 會中止資料庫上的所有連線,包含本請求驗證身分時開的那條 session(路由本身沒宣告 db,但 admin_onlyget_active_userDepends(get_db) 有)。FastAPI 0.106 起,yield 依賴的收尾是在回應送出後才執行,屆時 db.close() 對一條已死的連線送出 ROLLBACK → AdminShutdown 逸出成 ASGI 例外。因為回應早已送出,使用者拿到的是正確的 200——只有 log 難看。
  • 修法(兩層): 1. 根因:_restore() 在派工前先 db.close()。還原期間本來就用不到這條 session(稽核一向另開新連線寫進還原後的資料庫)。路由多宣告一個 db: Session = Depends(get_db)——FastAPI 對同一個 callable 有請求內快取,拿到的就是 admin_only 內部那條 session,不是第二條。關閉前先把 user.id/user.username 取成純量,避免 user 成為 detached instance。 2. 防線:get_db()finally 吞掉 close() 的例外並記一行 warning。收尾發生在回應送出後,此時擲例外只會變成一段沒有請求可歸屬的 traceback;真正的失敗會在查詢當下就報錯,不會被這裡蓋掉。這道防線也涵蓋「還原期間其他使用者的 in-flight 請求」。
  • 驗證:pytest 490(+2:①攔下請求 session,斷言 run_restore 被呼叫的那一刻它已經關了;②get_db 收尾遇上 close 失敗不擲出、只記 warning)、ruff/mypy 乾淨;e2e 31/31六容器棧實測:建學期 152/班級 799 → 備份 → 刪學期 → 還原 → api log 全程零 traceback、零 ERROR(先前必噴),回應 200/3.5s、學期與班級回復、舊 session 401、稽核以新連線寫入(admin | 還原自 …;現狀已備份為 …)。

M6-8 「系統管理」整頁打不開(v1.0.0 起就有,重拍手冊截圖時抓到)

  • 描述:/settings/system 只剩左側選單,內容區一片空白——備份、還原、SMTP、重設精靈四項功能全都點不進去System.vue 呼叫 useDialog()(M5 複審 fca20ca 加的,用來顯示還原後的可忽略警告),但 App.vue 從來沒掛 <n-dialog-provider>;Naive 會在 setup 直接擲錯,整頁渲染不出來。
  • 為什麼溜過所有測試:這一頁沒有任何 e2e 覆蓋(31 支 spec 沒一支碰它),vitest 也沒測。備份/還原的驗證一直是走 API,從沒走過 UI。是「重拍手冊截圖」這件事把它逼出來的——截圖產生器截到一張白畫面。
  • 修法:App.vue 補上 <n-dialog-provider>;seed_e2e 新增 e2e_admin 帳號(卡片是 admin-only);新增 system-settings.spec.ts——三張卡片渲染 + 立即備份(真的打到 worker-ops 的 pg_dump)+ 刪除備份。第一個斷言就是核心:頁面只要 setup 擲錯就是全白,必紅。
  • 驗證:pytest 490、vitest 15、e2e 32/32(+1)、ruff/mypy/eslint/vue-tsc 乾淨;截圖目視系統管理頁三張卡片完整。

M6-9 操作手冊 10 張截圖重拍 + 截圖產生器自備示範資料

  • 描述:M6-5 把主色調深後,手冊的 10 張截圖全成了舊主色;且截圖產生器 manual-shots.spec.ts 依賴一台「已經灌好示範資料」的測試站——那些資料當初是手動灌的、沒留腳本,導致要重拍時沒人知道當初的資料長什麼樣。
  • 修法:示範資料(115 學年度、8 位教師、701~703 班、24 筆配課)與首次登入改密都收進 spec 本身,冪等且可重跑;發布用的 locator 不再寫死草稿名;工作台改先排一半的課(空白課表講不了「拖拉排課」);備份頁先真的備一份再截。整套從空資料庫一次跑完 = 10 張圖。
  • 驗證:docker compose -p manual up -d(空 DB)→ E2E_BASE_URL=... npm run e2e:manual → 10 張全新截圖,逐張目視:新主色、真實資料、鐘點「剛好」不再滿江紅、看板有實際代課紀錄、系統管理頁完整。

M6-10 首次登入改密頁補測試(覆蓋清點後的最後一個缺口)

  • 描述:M6-8 的教訓(整頁壞掉兩個版本沒人發現,因為沒有 e2e)促成一次全面清點——把 20 個前端路由對照 e2e 實際造訪的頁面。結果 18 個有覆蓋,唯一沒有的是 /change-password:所有測試都走 API 改密碼,從沒有人點過那個畫面。而它是每一位新使用者進入系統的第一個畫面,壞掉的話連門都進不來。
  • 這一頁的特殊性:只有在「被強制改密」狀態下進得去(路由守衛會把非強制狀態的人導回儀表板),所以測試必須用一個處於首次登入狀態的帳號。seed_e2e 新增 e2e_newuser,且每次 seed 都強制重設回首次登入——測試必然會把這個狀態用掉(它就是去改密碼的),不重設的話第二次跑 e2e 會失敗。
  • 測試涵蓋:登入後被導向改密頁 → 頁面真的渲染(核心:整頁空白就必紅)→ 後端也擋(未改密前功能性 API 回 403,不是只靠前端守衛)→ 想繞去別頁會被送回來 → 密碼太短/兩次不一致/原密碼錯誤各自的訊息 → 成功後離開該頁且 API 通了 → 新密碼真的能用且不再被要求改密。
  • 順帶抓到一隻 bug 並修掉:送出鈕同時掛了 attr-type="submit"(觸發表單 submit)與 @click,確認欄又另外掛 @keyup.enter,於是 onSubmit 每次送出都跑兩遍。在成功路徑上等於送出兩次改密請求,第二次必然因為密碼已被改掉而回「原密碼錯誤」——新使用者設定密碼時,會同時看到「密碼已更新」和一則紅色錯誤。改為只走表單 submit 這一條路(按鈕仍是 type=submit,輸入框按 Enter 照樣觸發),並加上送出中不受理的防連點。測試以「同一次送出只出現一則訊息」守住。
  • 驗證:pytest 490、vitest 15、e2e 33/33(+1)、ruff/mypy/eslint/vue-tsc 乾淨;seed_e2e 連跑兩次驗冪等(第二次顯示「已重設回首次登入狀態」);截圖目視改密頁渲染正確;Enter 鍵送出路徑在測試中實際走過。

測試策略總則

  1. 三套學制驗證資料集(backend/tests/fixtures/,M1 期間建立,全案共用): - elementary_small:國小 6 班(包班+科任+週三下午空+導師時間) - junior_high_mid:國中 12 班(領域課程+彈性課程+兼行政減課教師) - vocational_high:技高 15 班 3 科(3 連堂實習+工場容量限制+業界師資限定時段+跑班)
  2. 排課引擎雙重驗證:所有 solver 測試以獨立 validator.py 逐項檢查硬約束,絕不以 solver 自身狀態為準;validator 同時用於「匯入外部課表檢查衝突」功能的基礎。
  3. 測試金字塔:pytest 單元(服務層/引擎)為主體;Vitest 覆蓋 TimetableGrid 等核心元件;Playwright 僅覆蓋六大關鍵旅程(登入、精靈、手排、自排、調代課、匯出)。
  4. 每張任務卡的完成定義(DoD):功能實作 + 卡上驗收標準自驗通過 + 新增測試綠燈 + 既有測試不退步 + ruff/eslint 乾淨。

給開發 AI 的固定工作準則

  1. 開工前先讀 docs/architecture.md 對應章節;規格衝突時以 architecture.md 為準並回報矛盾。
  2. 一次只做一張卡;卡外的好點子記入本檔末尾「Backlog」區,不順手實作。
  3. UI 文案一律繁體中文台灣教務用語(節次、科任、配課、鐘點、跑班)。
  4. 資料庫 schema 變更必附 Alembic 遷移,且可從前一版順向升級。
  5. 完成後更新本檔核取方塊為 [x],並在 PR/回報中逐條對照驗收標準說明驗證方式與結果。
  6. UI 驗收由 AI 以 Playwright 直接執行(2026-07-09 起,使用者已授權):對含 UI 的任務卡,撰寫 Playwright 驗收腳本,以 headed + slowMo 模式在使用者螢幕上可見地執行,關鍵步驟截圖存 frontend/e2e/screenshots/(gitignore),AI 讀取截圖確認後向使用者回報;腳本存入 frontend/e2e/ 累積為迴歸測試套件(即 M5-4 的分攤)。使用者僅需在旁觀看並對 UX/文案給回饋,不再手動逐步操作。

Backlog(開發中冒出的點子記這裡,不排程)

  • 【Fable 5 總體檢 D】CORS cors_origins 內建 localhost 且不可由 .env 設定;同源部署下無害,v1.x 改為可設定並於正式部署收斂。
  • 【Fable 5 總體檢 E】清單查詢未分頁 → M6-5 已加伺服器端上限 1000(下到 SQL),audit-logs 本來就有 limit;完整分頁 UI 仍列 v1.2(單校規模下上限已足夠)。
  • ~~【Fable 5 總體檢 G】/api/docs 與 openapi 在正式環境公開~~ 已於 M6-5 修畢(2026-07-14):預設關閉(404),.envAPI_DOCS_ENABLED 可顯式打開,dev compose 帶開。
  • 【Fable 5 總體檢 C 後續】restore-upload 目前 await file.read() 整包進記憶體;v1.x 改為串流落地,免大備份佔滿 api 記憶體(現以 Caddy 200MB 上限 + 超大 DB 走 volume 複製法緩解)。
  • 【Fable 5 M5 複審 A 正解】背景任務分 default(排課)/ops(匯出/備份/還原)兩佇列 + 第二個 worker 行程,讓快慢任務隔離——目前排課佔住單一 worker 時,組長匯出課表會逾時失敗(已由 cancel-on-timeout + 還原前 409 封死資料安全洞,但匯出體驗仍受影響)。需評估行程管理、4GB 記憶體預算與部署文件(5→6 容器或單容器雙進程)。
  • ~~【Fable 5 M5 複審 H】主題主色 #18a058 白字按鈕對比僅 ~3.4:1~~ 已於 M6-5 修畢:主色壓深至 #0d7a43(5.41:1),a11y 測試門檻提到 AA 的 4.5:1。
  • 【Fable 5 M5 複審 G】還原溯源:backup_dir 加 append-only restore.log(誰於何時還原哪份),因目前稽核寫進還原後 DB 有溯源斷點(presafe 檔名時戳暫可佐證);條件 D 的 stale 警告改為今日看板持久徽章而非一閃即逝的 toast。
  • 前端 bundle 偏大(~1.4MB,主因 app.use(naive) 全量註冊 Naive UI)。M2 課表頁完成後改為按元件 import 或用 naive-ui/es 自動匯入,縮小體積。
  • ~~M0-3 CI 的 Playwright E2E job 尚未加入~~ 已完成(2026-07-13,Fable 5):CI 新增 e2e job——runner 上以 buildx 建三映像(GHA cache 與 images job 共用 scope,PR 才驗得到 PR 自己的程式碼)、docker compose up -d --wait 起全棧、python -m app.scripts.seed_e2e 建測試帳號(冪等,含精靈標完成)、跑 Playwright chromium project(30 tests;manual-shots/perf-page-load 以 project 分組排除——前者需另備示範資料站,後者為 60 班壓測、門檻受 runner 效能影響易 flaky)。失敗上傳 playwright-report/trace/失敗截圖/容器 log;images job 改為 needs e2e,E2E 紅燈不發映像。本機等價驗證:乾淨棧(project schedci,:8090)→ seed 兩次驗冪等 → 30/30 綠(1.8m)。CI 首跑即抓到一隻真蟲:redis-py 8(pyproject redis>=5.2 無上限,重建映像靜默升級)下,RQ latest_result(timeout=) 的阻塞 XREAD 在「共用 client + 多執行緒併發」時被污染,Timeout reading from socket——worker 6 秒完成 PNG 渲染,api 卻等到逾時回 500(容器內最小重現:阻塞 XREAD + 並行 ping 同一 client,5 秒炸)。本機因渲染 2 秒內結束撞不到 race 窗口而全綠——正是 e2e 進 CI 要抓的環境性回歸。修法:queue._wait_result() 以 XREVRANGE 每 0.5s 輪詢取代阻塞讀(render_export 與 _run_blocking 共用),不依賴 blocked-client 喚醒,任何 client 版本皆穩。
  • ~~前端 CI 用 npm install(未提交 package-lock.json)~~ 描述已過時,順手處理(2026-07-13):lock 檔其實早已入庫;CI 的 frontend 與 e2e job 改用 npm ci + npm 快取(以 npm ci --dry-run 驗證 lock 與 package.json 同步)。
  • ~~docker compose up 端到端煙霧測試尚未納入 CI~~ 已由 e2e job 涵蓋(2026-07-13):e2e job 即為「compose 起全棧 + healthcheck + 真實使用者流程」的煙霧測試超集。
  • LINE 通知 adapter(v2):LINE Notify 已停用(2025-03),改走 LINE 官方帳號 Messaging API:各校自申請 OA 取得 channel token 填入系統設定;教師加 OA 好友後以綁定碼綁定取得推播用 userId(teachers.line_id 為人工聯絡用,不能直接推播)。實作為 NotificationChannel 的一個 adapter。
  • ~~開新學期複製目前不帶學期起訖日~~ 已於 M6-4 修畢(2026-07-14):複製對話框加起訖日欄位(必填,預設帶來源 +半年)。
  • ~~班級名稱同學期無唯一性約束(可建兩個「301」)~~ 已於 M6-5 修畢:uq(semester_id, name)(遷移 0017,會先為既有重複資料改名)+ API/匯入擋下。
  • 跑班群組內配課的 periods_per_week 未強制一致(M3-0 發現):群組是「同時段開課」,placements_for 一次放入全部成員配課,節數不一致時較短的一筆會先被 H8 週節數守恆擋下,語意曖昧。class_loads 已取群組內最長者計算班級佔用;M3-2 的 pre-flight 已加 group_shape_mismatch 錯誤、建模則直接拒絕。仍建議在配課建立/修改的 API 就擋下(409),讓使用者當場知道。
  • 映像因 ortools 膨脹到 660MB(M3-2):ortools 連帶拉進 numpy/pandas/protobuf。實際只有 worker 容器需要排課引擎,api 容器不需要。可拆成兩個映像(共用 base + worker 額外裝 ortools),或改用 ortools 的精簡發行版。部署頻寬敏感時再處理。
  • ~~開新學期複製不帶 constraint_config(M3-3)~~ 已於 M6-4 修畢:複製對話框加「排課偏好設定」勾選(預設帶)。
  • 軟約束權重設定 UI(M3-3,v2):目前只有 GET/PUT /api/solver/config,沒有畫面。等 M3-4 的自動排課頁上線後,把權重滑桿放在該頁的「進階設定」摺疊區。
  • 科目 Excel 匯入沒有「主科」欄(M3-3):subjects.is_major 只能在科目表單勾選。匯入範本可加一個選填欄。
  • 一門課整學期固定一間教室(M3-2 建模選擇):y[配課, 教室] 是每筆配課一個變數,而非逐格挑教室。符合實務(課表上一門課就在一間教室),變數量也小得多。若日後需要「同一門課不同節在不同教室」,改為 y[配課, 節次, 教室] 即可,約束式不變。
  • teacher_time_rule 無節次表維度(M2 健檢 2026-07-10):(weekday, period_no) 的牆鐘意義隨班級節次表浮動,多表學校中同一條規則在國中部與高中部指到不同時間。v1 定案:規則以「該筆配課班級的節次表」解讀(現行 conflict_checker 行為,M3-2 建模比照,單表學校無此問題);日後如有跨表教師的實際需求,再改為牆鐘區間定義(schema 需加 period_table_id 或改存時間區間)。
  • ~~【Fable 5 審查】部分排課宣稱「永遠有解」,但 _make_lesson_vars 在候選為空時先 raise~~ 已於 M6-3 修畢(2026-07-14):部分排課模式改為 _force_drop 列入未排清單並註明原因;一般模式維持 raise。
  • ~~【Fable 5 審查】跑班群組在部分排課掉一格時,「未排 N 節」會灌水數倍~~ 已於 M6-3 修畢:未排節數改以排課單位計數。
  • ~~【Fable 5 審查】衝突定位期間(最長 60 秒)不檢查 should_stop~~ 已於 M6-5 修畢:每次試解前檢查,取消得 cancelled。
  • 【Fable 5 審查】check_feasibility 吞掉 SolverInputError 的訊息:未來任何建模 bug 都會偽裝成「資料無解」。至少把原始訊息記入 log / conflict detail。
  • 【Fable 5 審查】test_purity.py 只收 level == 0 的 import,相對匯入(from ..models import ...)可完全繞過純度掃描。
  • ~~【Fable 5 審查】未排清單只活在 Redis(24h TTL)~~ 已於 M6-3 處理,但敘述須更正(2026-07-14):「哪些課沒排」其實一直查得到——completeness() 從 DB 重算,對草稿與已發布課表皆可。真正會遺失的是排不下的原因(只有 solver 知道),已隨草稿存進 timetables.unscheduled(遷移 0016)並在完整性報告中呈現。
  • 【Fable 5 審查】「validator/report 與 model_builder 零共用程式碼」嚴格說不成立:三者共用 problem.pyslots_overlap(D7 判定)與 course_key(排課單位語意)。獨立性涵蓋約束編碼,不涵蓋這兩個定義層謂詞。應為它們補直接的邊界單元測試。
  • 求解前先跑一次 hard-only 可行性探測(約 1 秒):既能提早回報「這份資料無解」,又能把該解當成正式求解的 warm start。目前是在失敗之後才探測。
  • 部分排課的 timeout 幾乎必定用滿:CP-SAT 找到最佳的「未排 2 節」很快,但要證明「不可能只少排 1 節」很慢。可考慮找到解後以未排節數為上界再收斂,或給部分排課獨立的較短預設時限。
  • 衝突定位的旋鈕清單未含「班級可排節次」與「連堂結構」;structural 模式目前只列最吃緊的班級/教師,沒有具體到「哪一門課改成連堂就好」。
  • ~~【E2E 進 CI 後的定時炸彈,2026-07-13 發現】多支 e2e spec 硬編未來日期~~ 已於 M6-1 修畢(2026-07-14):前後端各一支 dates helper 由執行當日推算基準週,全案 17 個測試檔改用;引信拆除。

M3 審查修正(Fable 5 獨立審查,2026-07-10)

M3 完成後由 Fable 5 做獨立技術審查,判決「有條件可進 M4」。以下 5 項已修:

  • A. H10 雙軌判定:conflict_checker 寫死 cap=2,solver 卻讀 constraint_config。學校把上限設成 3,自動排課排得出來、手動拖曳卻報違規。改為由 check_conflict 讀學期設定(hot path 加一次查詢,p95 仍 <100ms)。M4 調代課直接重用這支檢查器,這條裂縫必須先補。
  • B. 軟約束權重無上限:PUT /solver/config 接受 {"S2": 20000},而部分排課的「整節不排入」懲罰是 10000 → solver 會理性地丟課換分散度。新增 MAX_WEIGHT = 100(API 擋、load_config 讀取時夾),並在 Relaxation.__post_init__ 斷言量級順序。
  • C. unknown 靜默降級:試解逾時回 unknown 時被當成「不可行」,但 complete 沒有跟著降,structural 於是宣稱「即使放寬所有可調整的項目仍然無解」——一句從未被證明的話。新增 _Prober 追蹤 certain,任何 unknown 都讓 complete=False,structural 措辭隨之收斂。
  • D. pre-flight 場地供給不看科目適用性:唯一的專科教室綁「美術」,音樂課要求專科教室 → 檢查放行、建模必然失敗、定位找不到該場地、報告文不對題。改為依「候選場地集合」分組比對供需(與 _candidate_rooms 同義),新增 room_no_candidate 結構性錯誤(部分排課亦擋)。
  • E. _room_numbers 混用池需求與單間供給:多間同類型教室時,原因卡會憑空放大缺口。改為整池計算並在訊息中列出教室名。

驗證:pytest 273(+11,含 unknown 路徑 4 個測試)、真實 PostgreSQL 打過 A/B/D 端點、e2e 16 綠。

本頁由 docs/tasks.md 自動產生(scripts/build_docs.py)。 要修改內容請改 Markdown 原始檔,不要直接編輯這份 HTML。
排課與調代課系統 · MIT 授權 · GitHub