Files
track-LICA/backend/app/api/v1/endpoints/holidays.py
duxingchen dd4824b730 feat(holiday): 工作日历支持调休补班,补齐节假日管理页
- holidays 表加 kind 列区分 holiday(放假) / workday(调休补班):
  原判定 weekday()<5 and day not in holidays 只能「减」不能「加」,
  补班的周末被整天扣掉,工时系统性少算
- 新增 WorkCalendar(补班 → 放假 → 周末 的判定优先级)与
  work_calendar_service(全仓唯一的日历读取入口,不再各函数手拼集合)
- 9 处工作日计算调用点统一改用 load_work_calendar(db)
  (analytics / dashboard / product,含产品列表的工作日天数)
- 新增 PATCH /holidays/{id}(改类型或说明,日期不可改);列表按 kind 出参
- 前端新增「节假日管理」页与菜单入口
- 迁移 r1s2t3u4v5w6
2026-10-09 13:29:18 +08:00

149 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""节假日 / 调休补班管理 — 工作日时长计算所需的工作日历配置
一条 = 一个「例外日」:要么放假(holiday,不计工时),要么调休补班
(workday,计工时)。判定逻辑见 app/core/time_utils.py 的 WorkCalendar。
"""
from datetime import date
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.deps import require_admin
from app.models.holiday import HOLIDAY_KIND_LABELS, Holiday
router = APIRouter(prefix="/holidays", tags=["节假日管理"])
HolidayKind = Literal["holiday", "workday"]
class HolidayCreate(BaseModel):
day: date = Field(..., description="日期")
name: str | None = Field(
None, max_length=100, description="说明(如 国庆节 / 国庆前补班)",
)
# 默认 holiday 是为了兼容脚本与既有调用方;**前端表单必须显式选择** ——
# 默认「放假」会让用户忘记切换,从而复现「补班表达不了」这个原 bug。
kind: HolidayKind = Field(
"holiday", description="holiday=放假(不计工时) / workday=调休补班(计工时)",
)
class HolidayUpdate(BaseModel):
"""PATCH 语义:只改传入的字段。
`day` **不可改** —— 它是自然键(唯一索引),改日期等于删除重建。
用 extra="forbid" 明确拒绝而不是静默忽略:用户传了 day 却以为改成功了,
比直接报错更糟。
"""
model_config = ConfigDict(extra="forbid")
name: str | None = Field(None, max_length=100)
kind: HolidayKind | None = None
def _row(h: Holiday) -> dict:
"""统一出参。kind_label 由服务端下发(见 AGENTS.md「中文枚举标签尽量由
服务端下发」),前端直接用,不要再抄一份映射。"""
return {
"id": h.id,
"date": h.day.isoformat(),
"name": h.name,
"kind": h.kind,
"kind_label": HOLIDAY_KIND_LABELS.get(h.kind, h.kind),
}
@router.get("/")
async def list_holidays(
start: date | None = None,
end: date | None = None,
kind: HolidayKind | None = None,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""获取工作日历(按日期正序)。
表只有几百行,不做分页 —— 筛选交给客户端完全够用,这里的 start/end/kind
只是顺手支持。
⚠️ 本接口原先**完全匿名**(AGENTS.md 已把读接口未鉴权列为待修),这里按
该文档的指引补上 require_admin。
"""
stmt = select(Holiday)
if start:
stmt = stmt.where(Holiday.day >= start)
if end:
stmt = stmt.where(Holiday.day <= end)
if kind:
stmt = stmt.where(Holiday.kind == kind)
result = await db.execute(stmt.order_by(Holiday.day.asc()))
return [_row(h) for h in result.scalars().all()]
@router.post("/", status_code=201)
async def create_holiday(
data: HolidayCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""添加一个日期(放假或补班)"""
exists = await db.scalar(select(Holiday).where(Holiday.day == data.day))
if exists:
# 文案要指路:重复登记多半不是想新增,而是想**改类型** ——
# 只回一句「已存在」,用户只能删了重建(丢 id,还多两条审计噪音)。
label = HOLIDAY_KIND_LABELS.get(exists.kind, exists.kind)
raise HTTPException(
status_code=400,
detail=f"{data.day} 已登记为「{label}」,如需改变类型请编辑该条(id={exists.id})",
)
h = Holiday(day=data.day, name=data.name, kind=data.kind)
db.add(h)
await db.commit()
await db.refresh(h)
return _row(h)
@router.patch("/{holiday_id}")
async def update_holiday(
holiday_id: int,
data: HolidayUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""改类型或说明(日期不可改)。
录错类型是必然会发生的事(把补班录成放假 → 工时少算一整天),没有这个
接口就只能删了重建。
"""
h = await db.get(Holiday, holiday_id)
if not h:
raise HTTPException(status_code=404, detail="节假日不存在")
# model_fields_set 区分「没传」与「显式传 null」—— name 允许被清空,
# 只看 `data.name is not None` 就没法把说明擦掉。
if "kind" in data.model_fields_set and data.kind is not None:
h.kind = data.kind
if "name" in data.model_fields_set:
h.name = data.name
await db.commit()
await db.refresh(h)
return _row(h)
@router.delete("/{holiday_id}", status_code=204)
async def delete_holiday(
holiday_id: int,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""删除一个日期(该日退回普通周末/工作日规则)"""
h = await db.get(Holiday, holiday_id)
if not h:
raise HTTPException(status_code=404, detail="节假日不存在")
await db.delete(h)
await db.commit()