diff --git a/.env.example b/.env.example index 4afc3e5..46465de 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,7 @@ HTTP_PORT=80 # ── 預建映像版本(僅「拉取映像」部署方式需要)────────── # 用 docker compose pull 拉官方映像時,決定要拉哪一版。 # 預設 latest(最新);正式部署建議釘選版本號以利可控升級,例如: -# IMAGE_TAG=v1.4.1 +# IMAGE_TAG=v1.4.2 # 若你是從原始碼 docker compose up -d 自行建置,此項可忽略。 IMAGE_TAG=latest diff --git a/CHANGELOG.md b/CHANGELOG.md index b93c5c2..4ea73d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,30 @@ 破壞性變更(需人工介入才能升級)以 ⚠️ 標註。 -> **新使用者請直接安裝 v1.4.1**(見 [README](README.md) 快速開始)。v1.0.0 ~ v1.4.0 是先前版本;以下條目保留作為變更紀錄。 +> **新使用者請直接安裝 v1.4.2**(見 [README](README.md) 快速開始)。v1.0.0 ~ v1.4.1 是先前版本;以下條目保留作為變更紀錄。 ## [Unreleased] +## [1.4.2] — 2026-10-30 + +**事後才登記的請假,可以補登代課了。** 來自 LINE 社群一位組長:臨時找人代課,當天來不及在系統登記,事後才把公假補進去;這時節次已經過了,畫面上一直是「待處理」,按「處理」只會回「此節次已結束,無法變更處置」,代課老師的鐘點也就漏算。 + +**功能與資料表的其他部分都沒有變動**,照一般方式升級即可(`.env` 改 `IMAGE_TAG=v1.4.2` → `docker compose pull && docker compose up -d`)。 + +官方映像(amd64 + arm64 雙架構)已發布於 GHCR: +`ghcr.io/begin0808/course_scheduling_system-{api,worker,web}:v1.4.2` + +### 新增 +- **補登**:已經過去、還沒處理的節次標示「**待補登**」,按鈕寫「補登」,照平常方式選代課老師(或併班、自習、不處理)。補登後直接成為「已完成」,列入當月代課鐘點統計。 + - **不通知老師**:老師已經去上過課了,只留在統計裡。 + - **不能補登調課**:調課要安排之後的補課節次,過去的節次沒辦法調。 + - 「整批指派代課」也會一併補登已經過去的節次。 + - 操作紀錄寫明「補登」,日後查得到是誰補的。 +- **代導也可以補登**:導師的假事後才登記時,已經過去的代導日子標示「待補登」,規則相同(不通知,列入代導統計)。 +- 學期內都可以補登,和請假登記的日期限制一樣。 + +### 說明 +- 已經處理過、已經過去的節次與代導,**仍然不能修改或撤回**(避免月結後被動到),補登的也一樣,請確認後再按。 ## [1.4.1] — 2026-10-30 **授權改為 AGPL-3.0。** 這套系統的初衷是讓需要的學校(例如沒有經費購買商用系統的小校、廠商服務不到的海外台灣學校)免費使用,並透過大家的使用讓系統更完善。改用 AGPL-3.0 後,學校自架、使用、修改仍然完全免費;但修改後再提供給別人使用(包括架成網站讓其他學校從網路上使用)時,必須公開修改後的完整原始碼,讓改良回饋給所有學校,不會被包裝成封閉的商業產品販售。 diff --git a/README.md b/README.md index e9ad153..09befc4 100644 --- a/README.md +++ b/README.md @@ -132,11 +132,11 @@ docker compose up -d # 首次會建置映像,需數分鐘 ## 專案狀態 -**v1.4.1 已發行(2026-10-30)。** 六大里程碑 M0–M5 全部完成,功能齊備並經完整驗收(後端 710 項單元/整合測試、56 項 Playwright 端對端測試涵蓋全部 20 個頁面,每次提交皆對真實 Docker 全棧跑過)。官方映像(amd64 + arm64)已發布於 GHCR。 +**v1.4.2 已發行(2026-10-30)。** 六大里程碑 M0–M5 全部完成,功能齊備並經完整驗收(後端 718 項單元/整合測試、57 項 Playwright 端對端測試涵蓋全部 20 個頁面,每次提交皆對真實 Docker 全棧跑過)。官方映像(amd64 + arm64)已發布於 GHCR。 -v1.4.1 起改採 **AGPL-3.0 授權**(見下方「授權」;功能與資料都沒有變動);v1.4.0 新增**代導**(導師請假當天由誰代他帶班,當天沒課也算、半天也算;可印代導通知單,月結分開記全天與半天天數;⚠️ 資料表結構變更,升級前會自動備份);v1.3.5 修正**併班**選不到老師(改列出這一節正在上課的老師),調課單加上記號說明;v1.3.4 讓跑班群組、導師、任教科目等下拉選單都能**打字搜尋**;v1.3.3 的儀表板新增**即時概況**(現在這一節各班在上什麼課、誰在上,已套用當天的調代課);v1.3.2 新增**重設管理員密碼**的指令(忘了密碼或第一次就登不進去時,不必刪資料或還原備份);v1.3.1 新增**帳號管理**(系統管理員可設定教學組長、教務主任的帳號);v1.3.0 支援**單雙週課程**(同一節單週、雙週輪流上不同的課,排課、請假代課、調課、巡堂表、鐘點都分單雙週;⚠️ 首次改動資料表結構,升級前請先備份);v1.2.19 支援**日薪代課**(國小常見:月結統計把鐘點與日薪分開列)並可**整批指派代課**(整張假單或整天一次派給同一位老師);v1.2.18 可**檢查課表**(發布前列出衝堂、教師不可排時段等衝突;事後修改不可排時段時當場提示已排的課);v1.2.17 修正登入按一次送出兩筆請求(輸錯密碼約第 3 次就被鎖定);v1.2.15、v1.2.16 修正跑班群組的兩個問題(同一位老師在群組裡重複配課時自動排課會排出撞課的課表;同一格有多筆同科目配課時,手動排課把每日節數重複計算);v1.2.12~v1.2.14 可列印**巡堂表**並匯出 Excel(依當天的調代課結果產生,社團另出分組巡堂單);v1.2.10、v1.2.11 讓**國中小、完全中學**跨部上課的老師,課表與通知單都標出實際上課時間,兩部節數不同(國中七節、高中八節)也不會漏課;v1.2.9 可一次匯出**全校教師課表**(總表與批次打包);v1.2.8 可用 Excel **批次更新既有教師**(新學年職務異動),並能**停用/啟用教師登入帳號**(離職教師保留歷史紀錄又進不來);v1.2.7 補上**學期起訖日期**的填寫與修改(先前畫面上無處可填,導致請假被擋);v1.2.6 修掉第一批外部使用者回報的三個問題(教師帳號首次登入卡死、無法事後補開教師帳號、未排課務清單過長時拖不動),並可在畫面上自行修改與重設密碼;v1.2.5 可列印**教師代課通知單與班級代課通知單**(照學校紙本格式,代課時可選計費方式);v1.2.4 的調代課處理會寫出處置方式(代課或調課、哪天補課);v1.2.3 起系統會顯示目前版本,並在有新版時提醒系統管理員(只提醒、不自動升級);v1.2.2 可列印**教師調課通知單與班級調課通知單**(照學校紙本格式);v1.2.1 補上**調課**的操作畫面——請假(含研習)的節次可直接列出雙方都有空的對調節次,支援跨週(最多四週)與連堂,補課日也會出現在今日看板。v1.2.0 起另有**一鍵示範資料**,裝好後兩分鐘內就能看到一張排好的全校課表。 +v1.4.2 可以**補登**事後才登記的請假(已經過去的節次補登代課,不通知老師、列入鐘點統計);v1.4.1 起改採 **AGPL-3.0 授權**(見下方「授權」;功能與資料都沒有變動);v1.4.0 新增**代導**(導師請假當天由誰代他帶班,當天沒課也算、半天也算;可印代導通知單,月結分開記全天與半天天數;⚠️ 資料表結構變更,升級前會自動備份);v1.3.5 修正**併班**選不到老師(改列出這一節正在上課的老師),調課單加上記號說明;v1.3.4 讓跑班群組、導師、任教科目等下拉選單都能**打字搜尋**;v1.3.3 的儀表板新增**即時概況**(現在這一節各班在上什麼課、誰在上,已套用當天的調代課);v1.3.2 新增**重設管理員密碼**的指令(忘了密碼或第一次就登不進去時,不必刪資料或還原備份);v1.3.1 新增**帳號管理**(系統管理員可設定教學組長、教務主任的帳號);v1.3.0 支援**單雙週課程**(同一節單週、雙週輪流上不同的課,排課、請假代課、調課、巡堂表、鐘點都分單雙週;⚠️ 首次改動資料表結構,升級前請先備份);v1.2.19 支援**日薪代課**(國小常見:月結統計把鐘點與日薪分開列)並可**整批指派代課**(整張假單或整天一次派給同一位老師);v1.2.18 可**檢查課表**(發布前列出衝堂、教師不可排時段等衝突;事後修改不可排時段時當場提示已排的課);v1.2.17 修正登入按一次送出兩筆請求(輸錯密碼約第 3 次就被鎖定);v1.2.15、v1.2.16 修正跑班群組的兩個問題(同一位老師在群組裡重複配課時自動排課會排出撞課的課表;同一格有多筆同科目配課時,手動排課把每日節數重複計算);v1.2.12~v1.2.14 可列印**巡堂表**並匯出 Excel(依當天的調代課結果產生,社團另出分組巡堂單);v1.2.10、v1.2.11 讓**國中小、完全中學**跨部上課的老師,課表與通知單都標出實際上課時間,兩部節數不同(國中七節、高中八節)也不會漏課;v1.2.9 可一次匯出**全校教師課表**(總表與批次打包);v1.2.8 可用 Excel **批次更新既有教師**(新學年職務異動),並能**停用/啟用教師登入帳號**(離職教師保留歷史紀錄又進不來);v1.2.7 補上**學期起訖日期**的填寫與修改(先前畫面上無處可填,導致請假被擋);v1.2.6 修掉第一批外部使用者回報的三個問題(教師帳號首次登入卡死、無法事後補開教師帳號、未排課務清單過長時拖不動),並可在畫面上自行修改與重設密碼;v1.2.5 可列印**教師代課通知單與班級代課通知單**(照學校紙本格式,代課時可選計費方式);v1.2.4 的調代課處理會寫出處置方式(代課或調課、哪天補課);v1.2.3 起系統會顯示目前版本,並在有新版時提醒系統管理員(只提醒、不自動升級);v1.2.2 可列印**教師調課通知單與班級調課通知單**(照學校紙本格式);v1.2.1 補上**調課**的操作畫面——請假(含研習)的節次可直接列出雙方都有空的對調節次,支援跨週(最多四週)與連堂,補課日也會出現在今日看板。v1.2.0 起另有**一鍵示範資料**,裝好後兩分鐘內就能看到一張排好的全校課表。 -**請直接從最新版開始安裝**(見上方快速開始);`v1.4.1` 是目前建議使用的版本。各版變更見 [CHANGELOG](CHANGELOG.md),開發歷程見 [docs/tasks.md](docs/tasks.md)。 +**請直接從最新版開始安裝**(見上方快速開始);`v1.4.2` 是目前建議使用的版本。各版變更見 [CHANGELOG](CHANGELOG.md),開發歷程見 [docs/tasks.md](docs/tasks.md)。 系統仍在實際校園環境試用中,若你是第一批使用者,歡迎加入 [LINE 社群](https://line.me/ti/g2/hP0Lg7mqvE5rvWg0sE9MHI-QsLkd94Cfp3wKcg) 或透過 [Issues](https://github.com/begin0808/Course_Scheduling_System/issues) 回報任何問題。 diff --git a/backend/app/api/homeroom_covers.py b/backend/app/api/homeroom_covers.py index ff1932c..b368586 100644 --- a/backend/app/api/homeroom_covers.py +++ b/backend/app/api/homeroom_covers.py @@ -77,6 +77,8 @@ def assign_cover( ): """指派代導老師,或標記不需代導;whole_leave 時同一班其他還沒安排的日子一起指派。""" cover = _get_cover(db, cover_id) + rest = cover.leave_request.homeroom_covers if body.whole_leave else [cover] + backfill = any(cover_service.is_backfill(c) for c in rest) try: done, skipped = cover_service.assign( db, cover, handler_teacher_id=body.handler_teacher_id, no_cover=body.no_cover, @@ -89,7 +91,8 @@ def assign_cover( db.add(AuditLog( user_id=user.id, username=user.username, action="assign_homeroom_cover", target_type="homeroom_cover", target_id=cover.id, - detail=(f"{cover.leave_request.teacher.name} 請假,{cover.class_name} " + detail=(("補登 " if backfill else "") + + f"{cover.leave_request.teacher.name} 請假,{cover.class_name} " f"{', '.join(str(c.date) for c in done)} {who}")[:500], )) db.commit() diff --git a/backend/app/api/leaves.py b/backend/app/api/leaves.py index eb26fcc..3a5c9b7 100644 --- a/backend/app/api/leaves.py +++ b/backend/app/api/leaves.py @@ -8,6 +8,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session +from app.core import clock from app.core.auth import get_active_user, require_roles from app.core.db import get_db from app.models.audit import AuditLog @@ -55,6 +56,7 @@ def cover_out(c: HomeroomCover) -> HomeroomCoverOut: id=c.id, date=c.date, weekday=c.weekday, class_name=c.class_name, part_label=c.part_label, is_half_day=c.is_half_day, status=leave_service.cover_status(c), no_cover=c.no_cover, + is_past=leave_service.cover_is_past(c.date), handler_teacher_id=c.handler_teacher_id, handler_name=c.handler.name if c.handler else None, ) @@ -81,6 +83,7 @@ def _serialize(leave: LeaveRequest) -> LeaveRequestOut: "handler_teacher_id", )}, status=leave_service.effective_status(p.status, p.date, p.end_time), + is_past=clock.is_past_slot(p.date, p.end_time), handler_name=p.handler.name if p.handler else None, sub_type=p.substitution.type if p.substitution else None, swap_date=p.substitution.swap_date if p.substitution else None, diff --git a/backend/app/api/substitutions.py b/backend/app/api/substitutions.py index 3e06098..d51704a 100644 --- a/backend/app/api/substitutions.py +++ b/backend/app/api/substitutions.py @@ -146,6 +146,7 @@ def assign_substitution( ): """指派處置(代課/調課/併班/自習/不處理);指派即生效並通知處理教師。""" affected = _get_affected(db, affected_id) + backfill = sub_service.is_backfill(affected) try: sub = sub_service.assign( db, affected, @@ -162,7 +163,8 @@ def assign_substitution( user_id=user.id, username=user.username, action="assign_substitution", target_type="affected_period", target_id=affected.id, detail=( - f"{affected.leave_request.teacher.name} {affected.date} {affected.period_name}" + ("補登 " if backfill else "") + + f"{affected.leave_request.teacher.name} {affected.date} {affected.period_name}" f" → {SUBSTITUTION_TYPE_CN.get(sub.type, sub.type)}" + (f"({sub.handler.name})" if sub.handler else "") )[:500], @@ -205,6 +207,7 @@ def assign_substitutions_batch( leave = db.get(LeaveRequest, leave_id) if leave is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "找不到假單") + backfills = sum(1 for ap in leave.affected_periods if sub_service.is_backfill(ap)) try: done, skipped = sub_service.assign_batch( db, leave, @@ -225,6 +228,7 @@ def assign_substitutions_batch( detail=( f"{leave.teacher.name} {scope} → 代課({handler.name if handler else ''})" f" {len(done)} 節" + (f",{len(skipped)} 節未派" if skipped else "") + + (",含補登已過去的節次" if backfills else "") )[:500], )) db.commit() diff --git a/backend/app/schemas/leave.py b/backend/app/schemas/leave.py index ff8de00..572ced9 100644 --- a/backend/app/schemas/leave.py +++ b/backend/app/schemas/leave.py @@ -20,6 +20,8 @@ class AffectedPeriodOut(BaseModel): class_names: str room_name: str status: str # pending / resolved / completed / cancelled + # 已經過去、還沒處理:畫面標「待補登」,補登不通知、不能調課(v1.4.2) + is_past: bool = False handler_teacher_id: int | None = None handler_name: str | None = None sub_type: str | None = None # 已處置時的處置方式(substitute/swap/…),未處置為空 @@ -41,6 +43,7 @@ class HomeroomCoverOut(BaseModel): is_half_day: bool status: str # pending / resolved / completed / cancelled no_cover: bool # 已確認「不需代導」 + is_past: bool = False # 那天已經過了(還沒安排的話就是待補登,v1.4.2) handler_teacher_id: int | None = None handler_name: str | None = None diff --git a/backend/app/services/homeroom_covers.py b/backend/app/services/homeroom_covers.py index 5c23ce6..e29dd24 100644 --- a/backend/app/services/homeroom_covers.py +++ b/backend/app/services/homeroom_covers.py @@ -135,11 +135,18 @@ def options(db: Session, cover: HomeroomCover) -> list[CoverOption]: return out -def _check_open(cover: HomeroomCover) -> None: +def is_backfill(cover: HomeroomCover) -> bool: + """已經過去、還沒安排的代導:現在指派就是補登(v1.4.2,不通知)。""" + return cover.status == AffectedStatus.pending.value and leaves.cover_is_past(cover.date) + + +def _check_open(cover: HomeroomCover, *, allow_backfill: bool = False) -> None: if cover.status == AffectedStatus.cancelled.value: raise CoverError("這一天的代導已因銷假取消") - if leaves.cover_is_past(cover.date): + if leaves.cover_is_past(cover.date) and not (allow_backfill and is_backfill(cover)): raise CoverError("這一天已經過了,無法變更代導") + if cover.leave_request.status == LeaveStatus.cancelled.value: + raise CoverError("此假單已銷假") def _handler(db: Session, cover: HomeroomCover, teacher_id: int) -> Teacher: @@ -162,7 +169,8 @@ def _set(db: Session, cover: HomeroomCover, handler: Teacher | None, actor_name: cover.no_cover = handler is None cover.handler_teacher_id = handler.id if handler else None cover.assigned_by_name = actor_name - if handler is not None and previous != handler.id: + # 補登(那天已經過了)不通知:老師已經代導過了 + if handler is not None and previous != handler.id and not leaves.cover_is_past(cover.date): absent = cover.leave_request.teacher notifications.notify( db, semester_id=cover.semester_id, teacher_id=handler.id, @@ -196,7 +204,7 @@ def assign( `whole_leave`:同一張假單、同一班其他還沒安排的日子一起指派(多天的假一次派完)。 派不成的日子(那位老師當天整天請假)跳過並回報原因,仍維持待代導。 """ - _check_open(cover) + _check_open(cover, allow_backfill=True) handler: Teacher | None = None if not no_cover: if handler_teacher_id is None: @@ -210,7 +218,7 @@ def assign( rest = [ c for c in cover.leave_request.homeroom_covers if c.id != cover.id and c.class_name == cover.class_name - and c.status == AffectedStatus.pending.value and not leaves.cover_is_past(c.date) + and c.status == AffectedStatus.pending.value ] for c in rest: if handler is not None and on_full_day_leave(db, handler.id, c.date): diff --git a/backend/app/services/substitutions.py b/backend/app/services/substitutions.py index 06a1a06..202656e 100644 --- a/backend/app/services/substitutions.py +++ b/backend/app/services/substitutions.py @@ -3,6 +3,10 @@ 指派即生效:建立/更新處置 → 受影響節次轉『已確認』並記下處理教師 → 通知處理教師。 沒有邀請/婉拒;通知只是正式告知 +「確認收到」。 +**補登**(v1.4.2,組長回報):臨時找人代課、事後才登記請假時,節次已經過了還是「待處理」。 +這些節次可以補登處置,直接成為已完成並列入鐘點統計;**不通知**(老師已經去上過課了), +也**不能補登調課**(調課要安排之後的補課節次)。已處理過、已過去的節次仍然不能改或撤回。 + 各處置的鐘點:代課計、併班/自習/不處理不計(§5.4 月結)。 調課(swap)要驗兩位教師交換後都無衝突,拒絕時說出是誰在哪一節撞課。 """ @@ -77,10 +81,15 @@ def assign( """對一個受影響節次做處置。呼叫端負責 commit。""" if affected.status == AffectedStatus.cancelled.value: raise SubstitutionError("此節次已因銷假取消,無法處置") - if affected.status == AffectedStatus.completed.value or clock.is_past_slot( - affected.date, affected.end_time + backfill = is_backfill(affected) + if affected.status == AffectedStatus.completed.value or ( + clock.is_past_slot(affected.date, affected.end_time) and not backfill ): raise SubstitutionError("此節次已結束,無法變更處置") + if backfill and affected.leave_request.status == LeaveStatus.cancelled.value: + raise SubstitutionError("此假單已銷假") + if backfill and sub_type == SubstitutionType.swap.value: + raise SubstitutionError("這一節已經過了,無法補登調課(調課要安排之後的補課節次),請改登代課") if sub_type not in set(SubstitutionType): raise SubstitutionError(f"未知的處置方式:{sub_type}") @@ -114,11 +123,17 @@ def assign( affected.handler_teacher_id = handler.id if handler else None db.flush() - if handler is not None: + if handler is not None and not backfill: _notify_handler(db, affected, sub, handler) return sub +def is_backfill(affected: AffectedPeriod) -> bool: + """已經過去、還沒處理的節次:現在指派就是補登(不通知、不能調課)。""" + return affected.status == AffectedStatus.pending.value and clock.is_past_slot( + affected.date, affected.end_time) + + @dataclass(frozen=True, slots=True) class BatchSkip: """整批指派時沒派成的一節,以及原因(代課老師那節有課、節次已結束…)。""" @@ -145,8 +160,9 @@ def assign_batch( """把一張假單(或其中一天)還沒處理的節次,一次指派給同一位代課老師(#36)。 導師請整天或好幾天假時,一節一節點同一位代課老師要點幾十次。這裡逐節沿用 `assign` - ——檢查規則完全一樣——派得成的就派,派不成的(代課老師那一節自己有課、節次已結束) - 跳過並回報原因,由組長另外找人。已經處理過的節次不動。呼叫端負責 commit。 + ——檢查規則完全一樣——派得成的就派,派不成的(代課老師那一節自己有課) + 跳過並回報原因,由組長另外找人。已經過去、還沒處理的節次一併補登(不通知); + 已經處理過的節次不動。呼叫端負責 commit。 """ pending = sorted( ( diff --git a/backend/tests/test_backfill.py b/backend/tests/test_backfill.py new file mode 100644 index 0000000..a4694c5 --- /dev/null +++ b/backend/tests/test_backfill.py @@ -0,0 +1,136 @@ +"""補登(v1.4.2,組長回報):臨時找人代課,事後才登記請假。 + +那時節次已經過了,還是「待處理」,先前按下去只會回「此節次已結束」,鐘點也就漏算。 +使用者定案:過去的待處理節次(與代導)可以補登,學期內都可以;補登**不通知** +(老師已經去上過課了),直接成為已完成並列入統計;已處理過、已過去的仍然不能改或撤回。 +""" + +from sqlalchemy import select + +from app.core import clock +from app.models.audit import AuditLog +from app.models.notification import Notification +from tests.dates import MON, TUE, WED, WED2 +from tests.test_homeroom_cover import _assign, _covers, _homeroom, _leave, _notes +from tests.test_m4_hardening import AFTER, _stats, w # noqa: F401 - w 為 fixture +from tests.test_substitutions import env2 # noqa: F401 - env2 為 fixture + + +def _past_leave(world, monkeypatch, *days): + """時間快轉到課都上完之後,才登記那幾天的假(事後補登記)。""" + world.teacher("王師", ["國文"]) + world.teacher("陳師", ["國文"]) + world.place("王師", "國文", "701", 0) + world.place("王師", "國文", "702", 1) + world.publish() + monkeypatch.setattr(clock, "school_now", lambda: AFTER) + r = world.client.post(f"/api/leaves{world.q}", json={ + "teacher_id": world.teachers["王師"], "leave_type": "official", + "start_date": days[0].isoformat(), "end_date": days[-1].isoformat()}) + assert r.status_code == 201, r.json() + return r.json() + + +def _notifications(world, teacher: str) -> int: + return len(world.db.scalars( + select(Notification).where(Notification.teacher_id == world.teachers[teacher])).all()) + + +def test_past_pending_period_is_marked_and_can_be_backfilled(w, monkeypatch): # noqa: F811 + leave = _past_leave(w, monkeypatch, WED) + ap = leave["affected_periods"][0] + assert ap["status"] == "pending" and ap["is_past"] is True + + code, body = w.assign(ap["id"], type="substitute", handler_teacher_id=w.teachers["陳師"]) + assert code == 200, body + + w.db.expire_all() + after = w.client.get(f"/api/leaves/{leave['id']}").json()["affected_periods"][0] + assert after["status"] == "completed", "補登後直接是已完成" + chen = next(s for s in _stats(w)["summaries"] if s["teacher_name"] == "陳師") + assert chen["billable_count"] == 1, "補登的代課列入鐘點" + assert _notifications(w, "陳師") == 0, "補登不通知:老師已經去上過課了" + audit = w.db.scalars(select(AuditLog).where( + AuditLog.action == "assign_substitution")).all()[-1] + assert audit.detail.startswith("補登 ") + + +def test_backfilled_period_cannot_be_changed_or_undone(w, monkeypatch): # noqa: F811 + ap_id = _past_leave(w, monkeypatch, WED)["affected_periods"][0]["id"] + w.assign(ap_id, type="substitute", handler_teacher_id=w.teachers["陳師"]) + + code, body = w.assign(ap_id, type="self_study") + assert code == 409 and "已結束" in body["detail"] + r = w.client.delete(f"/api/affected-periods/{ap_id}/substitution") + assert r.status_code == 409 and "已結束" in r.json()["detail"] + + +def test_backfill_other_dispositions(w, monkeypatch): # noqa: F811 + """自習、不處理也能補登(沒有處理教師)。""" + periods = _past_leave(w, monkeypatch, WED)["affected_periods"] + assert w.assign(periods[0]["id"], type="self_study")[0] == 200 + assert w.assign(periods[1]["id"], type="cancel")[0] == 200 + + +def test_backfill_rejects_swap(w, monkeypatch): # noqa: F811 + ap_id = _past_leave(w, monkeypatch, WED)["affected_periods"][0]["id"] + code, body = w.assign(ap_id, type="swap", handler_teacher_id=w.teachers["陳師"], + swap_entry_id=1, swap_date=WED2.isoformat()) + assert code == 409 and "無法補登調課" in body["detail"] + + +def test_batch_backfills_past_pending_periods(w, monkeypatch): # noqa: F811 + leave = _past_leave(w, monkeypatch, WED, WED2) + assert leave["pending_count"] == 4 + r = w.client.post(f"/api/leaves/{leave['id']}/substitutions/batch", + json={"handler_teacher_id": w.teachers["陳師"], "funding_source": ""}) + assert r.status_code == 200, r.json() + assert r.json() == {"assigned": 4, "skipped": []} + assert _notifications(w, "陳師") == 0 + chen = next(s for s in _stats(w)["summaries"] if s["teacher_name"] == "陳師") + assert chen["billable_count"] >= 2 # WED 那個月的兩節(WED2 可能跨月) + + +def test_future_period_is_not_past(w): # noqa: F811 + w.teacher("王師", ["國文"]) + w.place("王師", "國文", "701", 0) + w.publish() + assert w.leave("王師")[0]["is_past"] is False + + +# ── 代導 ────────────────────────────────────────── +def test_past_cover_can_be_backfilled_without_notification(env2, monkeypatch): # noqa: F811 + cw = env2 + cw.teacher("王師") + cw.teacher("陳師") + _homeroom(cw, "701", "王師") + monkeypatch.setattr(clock, "school_today", lambda: WED) + leave = _leave(cw, "王師", MON, TUE) # 週三才補登週一、週二的假 + covers = leave["homeroom_covers"] + assert all(c["is_past"] and c["status"] == "pending" for c in covers) + + code, body = _assign(cw, covers[0]["id"], handler_teacher_id=cw.teachers["陳師"], + whole_leave=True) + assert code == 200 and body["assigned"] == 2, body + assert [c["status"] for c in _covers(cw, leave["id"])] == ["completed", "completed"] + assert _notes(cw, "陳師") == [], "補登不通知" + + # 補登後就跟其他過去的日子一樣,不能改、不能撤回 + code, body = _assign(cw, covers[0]["id"], no_cover=True) + assert code == 409 and "已經過了" in body["detail"] + r = cw.client.delete(f"/api/homeroom-covers/{covers[0]['id']}") + assert r.status_code == 409 + + +def test_whole_leave_from_future_day_includes_past_pending(env2, monkeypatch): # noqa: F811 + """週二補登記週一到週三的假,從週三那筆「指派整段」:週一補登(不通知)、週二週三照常。""" + cw = env2 + cw.teacher("王師") + cw.teacher("陳師") + _homeroom(cw, "701", "王師") + monkeypatch.setattr(clock, "school_today", lambda: TUE) + covers = _leave(cw, "王師", MON, WED)["homeroom_covers"] + code, body = _assign(cw, covers[2]["id"], handler_teacher_id=cw.teachers["陳師"], + whole_leave=True) + assert code == 200 and body["assigned"] == 3, body + assert len(_notes(cw, "陳師")) == 2, "只有還沒過的週二、週三發通知" diff --git a/backend/tests/test_homeroom_cover.py b/backend/tests/test_homeroom_cover.py index b8542f9..52a090c 100644 --- a/backend/tests/test_homeroom_cover.py +++ b/backend/tests/test_homeroom_cover.py @@ -228,13 +228,16 @@ def test_cancel_leave_cancels_future_covers_and_keeps_days_already_covered(env2, def test_past_day_cannot_be_changed(env2, monkeypatch): # noqa: F811 + """已安排、那天已經過了:不能改派。(還沒安排的過去日子可以補登,見 test_backfill)""" w = env2 w.teacher("王師") w.teacher("陳師") + w.teacher("林師") _homeroom(w, "701", "王師") cover_id = _leave(w, "王師", MON)["homeroom_covers"][0]["id"] + _assign(w, cover_id, handler_teacher_id=w.teachers["陳師"]) monkeypatch.setattr(clock, "school_today", lambda: TUE) - code, body = _assign(w, cover_id, handler_teacher_id=w.teachers["陳師"]) + code, body = _assign(w, cover_id, handler_teacher_id=w.teachers["林師"]) assert code == 409 and "已經過了" in body["detail"] diff --git a/docs/architecture.html b/docs/architecture.html index 6010b44..02a5da7 100644 --- a/docs/architecture.html +++ b/docs/architecture.html @@ -1193,6 +1193,7 @@

5.3 調代課狀態機

[*] --> 已登記: 教師請假/組長代登 已登記 --> 待處理: 系統展開受影響節次 待處理 --> 已確認: 指派(代課/調課/併班/自習/不處理) + 待處理 --> 已完成: 補登(節次已過,不通知、不能調課) 已確認 --> 已完成: 上課日結束自動轉換 已完成 --> [*]: 計入月鐘點統計 待處理 --> 已取消: 銷假 @@ -1202,6 +1203,7 @@

5.3 調代課狀態機

2026-07-09 使用者定案:不設「邀請/婉拒」流程。 台灣實務上教學組長指派代課前必先口頭徵得教師同意,系統通知只是正式告知。指派即生效;被指派教師收到通知後可一鍵「確認收到」(通知層的已讀確認旗標,不影響課務狀態),組長在看板可見各筆確認狀態並可對未確認者再次提醒。此設計較原「邀請中→接受/婉拒」大幅簡化狀態機與 UI。