Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 22 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

**下拉選單可以打字搜尋了。** 來自使用者詢問:社團、分組多的學校,跑班群組有十幾二十個,新增配課時只能在下拉清單裡一個個找。
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) 回報任何問題。

Expand Down
11 changes: 11 additions & 0 deletions backend/app/api/substitutions.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
BatchAssignRequest,
BatchSkipOut,
CandidateOut,
MergeOptionsOut,
RecommendationOut,
SlipsOut,
SubstitutionOut,
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions backend/app/schemas/substitution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_*。"""

Expand Down
35 changes: 29 additions & 6 deletions backend/app/services/availability.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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:
Expand All @@ -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 時段是否請假。

Expand Down
60 changes: 59 additions & 1 deletion backend/app/services/substitution_recommender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
10 changes: 8 additions & 2 deletions backend/app/services/substitutions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading