- 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
149 lines
5.2 KiB
Python
149 lines
5.2 KiB
Python
"""节假日 / 调休补班管理 — 工作日时长计算所需的工作日历配置
|
||
|
||
一条 = 一个「例外日」:要么放假(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()
|