From f2db1cccab26f77c0bc7902c5ff0708da1a97b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E4=BD=B3=E6=81=A9?= Date: Wed, 21 Oct 2026 21:44:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(substitution):=20=E4=BD=B5=E7=8F=AD?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E9=81=B8=E8=80=81=E5=B8=AB(=E5=88=97?= =?UTF-8?q?=E5=87=BA=E9=80=99=E4=B8=80=E7=AF=80=E6=AD=A3=E5=9C=A8=E4=B8=8A?= =?UTF-8?q?=E8=AA=B2=E7=9A=84=E8=80=81=E5=B8=AB);=E8=AA=BF=E8=AA=B2?= =?UTF-8?q?=E5=96=AE=E8=A8=98=E8=99=9F=E8=AA=AA=E6=98=8E;=E7=99=BC?= =?UTF-8?q?=E5=B8=83=20v1.3.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LINE 社群使用者回報:按「併班」沒有地方選老師,一按就是「「併班」需要指定教師」。 後端又要求接手老師空堂,與「併到他班一起上」及「建議改採併班」的提示矛盾。 - merge-options:這一節正在上自己班的老師與班級、科目,同年級優先、同科目其次 - 併班接手老師不必空堂,只要沒請假;代課規則不變 - 調課記號日期改為 10/30;調課單下方加一行記號說明 - availability:lessons_at、on_leave 供併班使用 Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 +- CHANGELOG.md | 23 +++- README.md | 6 +- backend/app/api/substitutions.py | 11 ++ backend/app/schemas/substitution.py | 15 +++ backend/app/services/availability.py | 35 +++++- .../app/services/substitution_recommender.py | 60 +++++++++- backend/app/services/substitutions.py | 10 +- backend/app/services/swap_slips.py | 14 ++- backend/tests/test_merge_class.py | 108 ++++++++++++++++++ backend/tests/test_substitutions.py | 2 +- docs/deploy/upgrade.html | 4 +- docs/deploy/upgrade.md | 4 +- docs/features.html | 11 +- docs/index.html | 5 +- docs/roadmap.html | 7 +- docs/roadmap.md | 3 +- frontend/e2e/merge-class.spec.ts | 77 +++++++++++++ frontend/e2e/substitutions.spec.ts | 11 +- frontend/src/api/substitutions.ts | 15 ++- .../src/views/substitution/SlipsPrint.vue | 15 +++ .../src/views/substitution/Substitutions.vue | 61 +++++++++- 22 files changed, 459 insertions(+), 40 deletions(-) create mode 100644 backend/tests/test_merge_class.py create mode 100644 frontend/e2e/merge-class.spec.ts diff --git a/.env.example b/.env.example index 1f25dea..2e90b29 100644 --- a/.env.example +++ b/.env.example @@ -32,7 +32,7 @@ HTTP_PORT=80 # ── 預建映像版本(僅「拉取映像」部署方式需要)────────── # 用 docker compose pull 拉官方映像時,決定要拉哪一版。 # 預設 latest(最新);正式部署建議釘選版本號以利可控升級,例如: -# IMAGE_TAG=v1.3.4 +# IMAGE_TAG=v1.3.5 # 若你是從原始碼 docker compose up -d 自行建置,此項可忽略。 IMAGE_TAG=latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 653dc7f..099a8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,31 @@ 破壞性變更(需人工介入才能升級)以 ⚠️ 標註。 -> **新使用者請直接安裝 v1.3.4**(見 [README](README.md) 快速開始)。v1.0.0 ~ v1.3.3 是先前版本;以下條目保留作為變更紀錄。 +> **新使用者請直接安裝 v1.3.5**(見 [README](README.md) 快速開始)。v1.0.0 ~ v1.3.4 是先前版本;以下條目保留作為變更紀錄。 ## [Unreleased] +## [1.3.5] — 2026-10-21 + +**併班終於選得到老師。** 來自 LINE 社群使用者回報:按「併班」沒有地方選老師,一按就跳出「「併班」需要指定教師」。 + +**本版沒有資料表結構變更**,升級只需換映像;若要退回 v1.3.4,把 `IMAGE_TAG` 改回即可。 + +官方映像(amd64 + arm64 雙架構)已發布於 GHCR: +`ghcr.io/begin0808/course_scheduling_system-{api,worker,web}:v1.3.5` + +### 修正 +- **併班選不到老師**:畫面上的「併班」按鈕沒有帶老師就送出,而後端規定一定要指定,所以每次都失敗。另外後端原本要求接手老師在那一節必須**空堂**,與「併到他班一起上」的意思、以及找不到空堂老師時「建議改採併班」的提示互相矛盾。 + - 按「併班」改為展開清單,列出**這一節正在上自己班的老師**與他在上的班級、科目;**同年級**排前面、**同科目**其次。選一位就成立。 + - 接手老師不必空堂,只要當天沒有請假;兩個班可以併到同一位老師。空堂老師不列在併班清單,請用「代課」。 + - 代課的規則不變(接手老師仍須空堂)。 + - 新 API:`GET /api/affected-periods/{id}/merge-options`。 + +### 調整 +- **調課記號**的日期改寫成習慣的 `10/30`(原本是 `10-30`):`[調10/30_56]`。 +- **調課單下方加一行記號說明**,以該頁的第一個記號舉例,例如「記號說明:[調10/30_56] 表示這一格和 10/30(星期五)第 6 節對調;底線後第一碼是星期,其餘是節次。」 +- 手冊 8.2 補上「併班要選誰」、8.3 的記號範例更新。 + ## [1.3.4] — 2026-10-21 **下拉選單可以打字搜尋了。** 來自使用者詢問:社團、分組多的學校,跑班群組有十幾二十個,新增配課時只能在下拉清單裡一個個找。 diff --git a/README.md b/README.md index e509f49..459f2b6 100644 --- a/README.md +++ b/README.md @@ -132,11 +132,11 @@ docker compose up -d # 首次會建置映像,需數分鐘 ## 專案狀態 -**v1.3.4 已發行(2026-10-21)。** 六大里程碑 M0–M5 全部完成,功能齊備並經完整驗收(後端 685 項單元/整合測試、53 項 Playwright 端對端測試涵蓋全部 20 個頁面,每次提交皆對真實 Docker 全棧跑過)。官方映像(amd64 + arm64)已發布於 GHCR。 +**v1.3.5 已發行(2026-10-21)。** 六大里程碑 M0–M5 全部完成,功能齊備並經完整驗收(後端 692 項單元/整合測試、54 項 Playwright 端對端測試涵蓋全部 20 個頁面,每次提交皆對真實 Docker 全棧跑過)。官方映像(amd64 + arm64)已發布於 GHCR。 -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.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.3.4` 是目前建議使用的版本。各版變更見 [CHANGELOG](CHANGELOG.md),開發歷程見 [docs/tasks.md](docs/tasks.md)。 +**請直接從最新版開始安裝**(見上方快速開始);`v1.3.5` 是目前建議使用的版本。各版變更見 [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/substitutions.py b/backend/app/api/substitutions.py index c5a7d63..3e06098 100644 --- a/backend/app/api/substitutions.py +++ b/backend/app/api/substitutions.py @@ -26,6 +26,7 @@ BatchAssignRequest, BatchSkipOut, CandidateOut, + MergeOptionsOut, RecommendationOut, SlipsOut, SubstitutionOut, @@ -82,6 +83,16 @@ def get_recommendations( ) +@router.get("/affected-periods/{affected_id}/merge-options", response_model=MergeOptionsOut) +def get_merge_options( + affected_id: int, db: Session = Depends(get_db), _: User = Depends(editor) +): + """併班:列出這一節正在上自己班的老師(同年級優先、同科目其次),選一位把班併過去。""" + affected = _get_affected(db, affected_id) + return MergeOptionsOut.model_validate( + {"options": [asdict(o) for o in recommender.merge_options(db, affected)]}) + + @router.get("/affected-periods/{affected_id}/swap-options", response_model=SwapOptionsOut) def get_swap_options( affected_id: int, diff --git a/backend/app/schemas/substitution.py b/backend/app/schemas/substitution.py index 9796774..e006f54 100644 --- a/backend/app/schemas/substitution.py +++ b/backend/app/schemas/substitution.py @@ -23,6 +23,21 @@ class RecommendationOut(BaseModel): no_candidate_hint: str = "" +class MergeOptionOut(BaseModel): + teacher_id: int + teacher_name: str + class_names: str + subject_name: str + same_grade: bool + same_subject: bool + + +class MergeOptionsOut(BaseModel): + """併班:這一節正在上自己班、可以把請假老師的班併過去的老師(v1.3.5)。""" + + options: list[MergeOptionOut] = [] + + class AssignRequest(BaseModel): """指派處置。type=substitute/swap/merge 需 handler_teacher_id;swap 另需 swap_*。""" diff --git a/backend/app/services/availability.py b/backend/app/services/availability.py index dbafa80..dd18beb 100644 --- a/backend/app/services/availability.py +++ b/backend/app/services/availability.py @@ -90,6 +90,8 @@ def __init__(self, db: Session, semester_id: int, timetable: Timetable | None = self.timetable = timetable or published_timetable(db, semester_id) # 每位教師的授課時段,附上週別(None = 每週;odd/even = 只上單週/雙週) self._teaching: dict[int, list[tuple[Interval, str | None]]] | None = None + # 同上,另附配課 id(併班要知道接手老師那一節在上哪個班) + self._lessons: dict[int, list[tuple[Interval, str | None, int]]] | None = None self._table_periods: dict[int, dict[tuple[int, int], Period]] = {} semester = db.get(Semester, semester_id) self.semester_start = semester.start_date if semester else None @@ -105,11 +107,19 @@ def entry_runs_on(self, entry: ScheduleEntry, when: date) -> bool: # ── 週課表:每位教師的授課時段(牆鐘區間)── def _teaching_map(self) -> dict[int, list[tuple[Interval, str | None]]]: - if self._teaching is not None: - return self._teaching - result: dict[int, list[tuple[Interval, str | None]]] = {} + if self._teaching is None: + self._teaching = { + t: [(iv, parity) for iv, parity, _ in lessons] + for t, lessons in self._lesson_map().items() + } + return self._teaching + + def _lesson_map(self) -> dict[int, list[tuple[Interval, str | None, int]]]: + if self._lessons is not None: + return self._lessons + result: dict[int, list[tuple[Interval, str | None, int]]] = {} if self.timetable is None: - self._teaching = result + self._lessons = result return result rows = self.db.execute( @@ -129,10 +139,20 @@ def _teaching_map(self) -> dict[int, list[tuple[Interval, str | None]]]: result.setdefault(teacher_id, []).append((Interval( weekday, period_no + k, p.start_time if p else None, p.end_time if p else None, - ), parity)) - self._teaching = result + ), parity, a_id)) + self._lessons = result return result + def lessons_at(self, slot: Interval, when: date) -> list[tuple[int, int]]: + """when 這一天 slot 時段正在上課的 (教師, 配課);單雙週的課只看那一週。""" + week = self.week_of(when) + return [ + (teacher_id, a_id) + for teacher_id, lessons in self._lesson_map().items() + for iv, parity, a_id in lessons + if iv.overlaps(slot) and runs_in_week(parity, week) + ] + def _table_of_assignment(self, assignment_id: int) -> int | None: a = self.db.get(CourseAssignment, assignment_id) if a is None or not a.scheduling_unit.members: @@ -147,6 +167,9 @@ def _period(self, table_id: int, weekday: int, period_no: int) -> Period | None: return self._table_periods[table_id].get((weekday, period_no)) # ── 當日請假:該教師自己那天那個時段是否也請假 ── + def on_leave(self, teacher_id: int, when: date, slot: Interval) -> bool: + return self._on_leave(teacher_id, when, slot) + def _on_leave(self, teacher_id: int, when: date, slot: Interval) -> bool: """該教師在 when 這天的 slot 時段是否請假。 diff --git a/backend/app/services/substitution_recommender.py b/backend/app/services/substitution_recommender.py index 8ecc00f..6a93d38 100644 --- a/backend/app/services/substitution_recommender.py +++ b/backend/app/services/substitution_recommender.py @@ -13,7 +13,8 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session -from app.models.basedata import Teacher +from app.models.assignment import CourseAssignment +from app.models.basedata import Subject, Teacher from app.models.leave import AffectedPeriod, AffectedStatus from app.models.substitution import Substitution from app.services.availability import Availability @@ -142,3 +143,60 @@ def recommend( f"該時段全校 {blocked} 位教師都無法代課,建議改採「併班」或「自習」" ) return rec + + +@dataclass(frozen=True, slots=True) +class MergeOption: + teacher_id: int + teacher_name: str + class_names: str # 接手老師這一節在上的班(併過去的地方) + subject_name: str + same_grade: bool + same_subject: bool + + +def merge_options( + db: Session, + affected: AffectedPeriod, + *, + availability: Availability | None = None, +) -> list[MergeOption]: + """併班:這一節**正在上自己班**的老師,把請假老師的班併過去一起上(v1.3.5 使用者定案)。 + + 空堂老師請用代課,不列在這裡;同一堂課的協同教師也不列(不是「併到他班」)。 + 排序:同年級優先、同科目其次。 + """ + av = availability or Availability(db, affected.semester_id) + slot = av.slot_of(affected) + absent_teacher_id = affected.leave_request.teacher_id + own = (db.get(CourseAssignment, affected.course_assignment_id) + if affected.course_assignment_id else None) + grades = {m.class_unit.grade for m in own.scheduling_unit.members} if own else set() + + out: list[MergeOption] = [] + seen: set[tuple[int, int]] = set() + for teacher_id, a_id in av.lessons_at(slot, affected.date): + if (teacher_id, a_id) in seen or teacher_id == absent_teacher_id: + continue + seen.add((teacher_id, a_id)) + if a_id == affected.course_assignment_id: + continue + teacher = db.get(Teacher, teacher_id) + if teacher is None or not teacher.is_active: + continue + if av.on_leave(teacher_id, affected.date, slot): + continue + a = db.get(CourseAssignment, a_id) + if a is None: + continue + classes = [m.class_unit for m in a.scheduling_unit.members] + subject = db.get(Subject, a.subject_id) + subject_name = subject.name if subject else "" + out.append(MergeOption( + teacher_id=teacher.id, teacher_name=teacher.name, + class_names="、".join(c.name for c in classes), subject_name=subject_name, + same_grade=bool(grades & {c.grade for c in classes}), + same_subject=subject_name == affected.subject_name, + )) + out.sort(key=lambda o: (not o.same_grade, not o.same_subject, o.class_names, o.teacher_name)) + return out diff --git a/backend/app/services/substitutions.py b/backend/app/services/substitutions.py index 1965bd3..06a1a06 100644 --- a/backend/app/services/substitutions.py +++ b/backend/app/services/substitutions.py @@ -202,8 +202,14 @@ def _resolve_handler( if teacher.id == affected.leave_request.teacher_id: raise SubstitutionError("不能指派請假教師代自己的課") - # 代課/併班:接手者在該時段必須是空的(調課的衝突另由 _validate_swap 檢查) - if sub_type != SubstitutionType.swap.value: + if sub_type == SubstitutionType.merge.value: + # 併班:接手的是這一節正在上自己班的老師(併到他班一起上),不必空堂, + # 只要人在(v1.3.5 使用者定案;先前要求空堂,與「併到他班」的意思相反) + if av.on_leave(teacher.id, affected.date, av.slot_of(affected)): + raise SubstitutionError( + f"{teacher.name} {affected.date} {affected.period_name} 當天也請假,無法併班") + elif sub_type != SubstitutionType.swap.value: + # 代課:接手者在該時段必須是空的(調課的衝突另由 _validate_swap 檢查) conflict = av.conflict_for(teacher.id, affected.date, av.slot_of(affected)) if conflict is not None: raise SubstitutionError( diff --git a/backend/app/services/swap_slips.py b/backend/app/services/swap_slips.py index 01a75b0..6555b49 100644 --- a/backend/app/services/swap_slips.py +++ b/backend/app/services/swap_slips.py @@ -1,7 +1,8 @@ """調課通知單(教師調課單、班級調課單)的資料組裝(v1.2.2)。 版面照使用學校現行的紙本:一張週課表,只在「有異動的格子」寫上 -「日期 / 科目 / 實際上課的老師 / [調MM-DD_星期節次]」,中括號指向對調的另一節。 +「日期 / 科目 / 實際上課的老師 / [調月/日_星期節次]」,中括號指向對調的另一節 +(例如 [調10/30_56] = 10/30 星期五第 6 節;v1.3.5 起日期寫成 10/30,原本是 10-30)。 格線與跨週分頁等共用部分見 `slip_layout`。 **一筆調課 = 兩個異動格。** 甲請假那節改由乙上;乙原本那節改由甲上。 @@ -16,6 +17,8 @@ 本模組只讀,不寫資料庫;資料全部來自已成立調課的快照欄位與節次表。 """ +from datetime import date + from sqlalchemy import select from sqlalchemy.orm import Session @@ -25,6 +28,11 @@ from app.services.slip_layout import Move, SlipError, Slips, Tables, assemble, school_title +def _md(d: date) -> str: + """記號裡的日期照習慣寫成 10/30(不補零)。""" + return f"{d.month}/{d.day}" + + def _moves(db: Session, tables: Tables, ap: AffectedPeriod, sub: Substitution) -> list[Move]: absent = ap.leave_request.teacher partner = sub.handler @@ -42,13 +50,13 @@ def _moves(db: Session, tables: Tables, ap: AffectedPeriod, sub: Substitution) - date=ap.date, period_no=ap.period_no, class_names=ap.class_names, subject_name=sub.swap_subject_name if same_class else ap.subject_name, teacher_id=partner.id, teacher_name=partner.name, table_id=leave_table, - code=f"調{sub.swap_date:%m-%d}_{swap_wd}{swap_ord}", + code=f"調{_md(sub.swap_date)}_{swap_wd}{swap_ord}", ), Move( # 乙原本那節:改由甲上 date=sub.swap_date, period_no=sub.swap_period_no, class_names=sub.swap_class_names, subject_name=ap.subject_name if same_class else sub.swap_subject_name, teacher_id=absent.id, teacher_name=absent.name, table_id=swap_table, - code=f"調{ap.date:%m-%d}_{leave_wd}{leave_ord}", + code=f"調{_md(ap.date)}_{leave_wd}{leave_ord}", ), ] diff --git a/backend/tests/test_merge_class.py b/backend/tests/test_merge_class.py new file mode 100644 index 0000000..72de6fc --- /dev/null +++ b/backend/tests/test_merge_class.py @@ -0,0 +1,108 @@ +"""併班(v1.3.5,LINE 社群使用者回報):把請假老師的班併到「這一節正在上自己班的老師」那裡一起上。 + +先前畫面上的「併班」沒有地方選老師,一按就是「「併班」需要指定教師」;後端又要求接手老師 +必須空堂——與手冊「併到他班一起上」、以及找不到空堂老師時「建議改採併班」的提示都矛盾。 +使用者定案:列出這一節有課的老師(同年級優先、同科目其次),空堂老師請用代課。 +""" + +from app.models.leave import AffectedPeriod +from tests.dates import WED +from tests.test_substitutions import _World, env2 # noqa: F401 - env2 為 fixture + + +def _options(w, affected_id): + r = w.client.get(f"/api/affected-periods/{affected_id}/merge-options") + assert r.status_code == 200, r.json() + return [(o["teacher_name"], o["class_names"], o["subject_name"], o["same_grade"], + o["same_subject"]) for o in r.json()["options"]] + + +def _grade8(w: _World, name: str) -> None: + w.classes[name] = w.client.post(f"/api/class-units{w.q}", json={ + "grade": 8, "name": name, "track": "junior_high"}).json()["id"] + + +def _school(w): + """王師週三第一節 701 國文請假。同一節:陳師 702 國文、林師 703 數學、李師 801 國文; + 張師空堂;趙師 704 英語但當天也請假。""" + _grade8(w, "801") + for name, subject in [("王師", "國文"), ("陳師", "國文"), ("林師", "數學"), + ("李師", "國文"), ("張師", "國文"), ("趙師", "英語")]: + w.teacher(name, [subject]) + w.place("王師", "國文", "701", 0) + w.place("陳師", "國文", "702", 0) + w.place("林師", "數學", "703", 0) + w.place("李師", "國文", "801", 0) + w.place("趙師", "英語", "704", 0) + w.publish() + w.leave("趙師") + return w.leave("王師")[0]["id"] + + +def test_options_are_teachers_having_class_now_same_grade_first(env2): # noqa: F811 + w = env2 + affected_id = _school(w) + assert _options(w, affected_id) == [ + ("陳師", "702", "國文", True, True), # 同年級、同科目 + ("林師", "703", "數學", True, False), # 同年級 + ("李師", "801", "國文", False, True), # 同科目 + ] + # 張師空堂(請用代課)、趙師也請假、王師自己都不在名單上 + + +def test_merge_into_a_teacher_who_is_teaching_is_accepted(env2): # noqa: F811 + """先前要求接手老師空堂,一定會被擋;現在正在上課的老師可以接手。""" + w = env2 + affected_id = _school(w) + code, body = w.assign(affected_id, type="merge", handler_teacher_id=w.teachers["陳師"]) + assert code == 200, body + assert (body["type_label"], body["handler_name"]) == ("併班", "陳師") + assert body["counts_toward_hours"] is False # 併班不另計代課鐘點 + ap = w.db.get(AffectedPeriod, affected_id) + assert ap.handler_teacher_id == w.teachers["陳師"] + + +def test_merge_into_a_teacher_on_leave_is_rejected(env2): # noqa: F811 + w = env2 + affected_id = _school(w) + code, body = w.assign(affected_id, type="merge", handler_teacher_id=w.teachers["趙師"]) + assert code == 409 + assert "趙師" in body["detail"] and "請假" in body["detail"] + + +def test_merge_still_needs_a_teacher(env2): # noqa: F811 + w = env2 + affected_id = _school(w) + code, body = w.assign(affected_id, type="merge") + assert code == 409 and "需要指定教師" in body["detail"] + + +def test_two_classes_can_merge_into_the_same_teacher(env2): # noqa: F811 + """王師、林師都請假,兩個班都併到陳師那裡。""" + w = env2 + first = _school(w) + second = w.leave("林師")[0]["id"] + assert w.assign(first, type="merge", handler_teacher_id=w.teachers["陳師"])[0] == 200 + names = [o[0] for o in _options(w, second)] + assert "陳師" in names + assert w.assign(second, type="merge", handler_teacher_id=w.teachers["陳師"])[0] == 200 + + +def test_substitute_still_needs_a_free_teacher(env2): # noqa: F811 + """代課的規則不變:正在上課的老師不能代。""" + w = env2 + affected_id = _school(w) + code, body = w.assign(affected_id, type="substitute", handler_teacher_id=w.teachers["陳師"]) + assert code == 409 and "自己的課" in body["detail"] + + +def test_options_on_another_day_follow_that_days_timetable(env2): # noqa: F811 + """請假那天才算:週三那一節有課的人,不會因為別天的課表出現在名單上。""" + w = env2 + w.teacher("王師", ["國文"]) + w.teacher("陳師", ["國文"]) + w.place("王師", "國文", "701", 0) + w.place("陳師", "國文", "702", 0, weekday=4) # 陳師的課在週四 + w.publish() + affected_id = w.leave("王師", WED)[0]["id"] + assert _options(w, affected_id) == [] diff --git a/backend/tests/test_substitutions.py b/backend/tests/test_substitutions.py index 38b4edc..27942eb 100644 --- a/backend/tests/test_substitutions.py +++ b/backend/tests/test_substitutions.py @@ -602,7 +602,7 @@ def test_swap_slips_same_class_follow_the_paper_form(env2): assert kinds == [("teacher", "陳師", "701"), ("teacher", "王師", "701"), ("class", "", "701")] chen, wang_slip, klass = body["slips"] - thu, wed = THU.strftime("%m-%d"), WED.strftime("%m-%d") + thu, wed = f"{THU.month}/{THU.day}", f"{WED.month}/{WED.day}" # 10/30,不補零(v1.3.5) assert _cells(chen) == [(WED.isoformat(), 1, "數學", "陳師", f"調{thu}_42")] assert _cells(wang_slip) == [(THU.isoformat(), 2, "國文", "王師", f"調{wed}_31")] assert len(_cells(klass)) == 2 diff --git a/docs/deploy/upgrade.html b/docs/deploy/upgrade.html index 1c166c3..08587e1 100644 --- a/docs/deploy/upgrade.html +++ b/docs/deploy/upgrade.html @@ -213,7 +213,7 @@

方式 A:拉取映像部署(最常 # https://raw.githubusercontent.com/begin0808/Course_Scheduling_System/main/docker-compose.yml # 3) 選擇版本:編輯 .env - # 釘選版本(可控):IMAGE_TAG=v1.3.4 + # 釘選版本(可控):IMAGE_TAG=v1.3.5 # 永遠最新: IMAGE_TAG=latest # 4) 拉新映像並重啟 @@ -233,7 +233,7 @@

方式 B:從原始碼建置部署


關於版本釘選

diff --git a/docs/deploy/upgrade.md b/docs/deploy/upgrade.md index 983917b..9aef337 100644 --- a/docs/deploy/upgrade.md +++ b/docs/deploy/upgrade.md @@ -40,7 +40,7 @@ v1.2.19 三個版本的實際資料上驗證過:課表、配課、鐘點、請 # https://raw.githubusercontent.com/begin0808/Course_Scheduling_System/main/docker-compose.yml # 3) 選擇版本:編輯 .env -# 釘選版本(可控):IMAGE_TAG=v1.3.4 +# 釘選版本(可控):IMAGE_TAG=v1.3.5 # 永遠最新: IMAGE_TAG=latest # 4) 拉新映像並重啟 @@ -70,7 +70,7 @@ docker compose up -d --build # 重新建置並重啟 ## 關於版本釘選 -- **正式環境建議 `IMAGE_TAG=v1.3.4` 這樣釘住特定版本**,你才能決定何時升級、升到哪一版,而不是每次 `pull` 都可能變動。 +- **正式環境建議 `IMAGE_TAG=v1.3.5` 這樣釘住特定版本**,你才能決定何時升級、升到哪一版,而不是每次 `pull` 都可能變動。 - 想升級時,把 `IMAGE_TAG` 改成新版本號,再 `docker compose pull && up -d`。 - 各版本的變更內容見專案根目錄的 [CHANGELOG.md](../../CHANGELOG.md);破壞性變更(若有)會在該版本明確標註 ⚠️ 與對應處置。 diff --git a/docs/features.html b/docs/features.html index 13070aa..bd2a67e 100644 --- a/docs/features.html +++ b/docs/features.html @@ -3,7 +3,7 @@ - + 功能總覽 · 排課與調代課系統