feat(duty): add schemas and duty_schedule service with capacity tests

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-14 17:12:13 +08:00
1 parent f5ad2d7217
commit 49cad98c84
3 files changed
+683

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
"""值班排班 Pydantic schemas"""
from __future__ import annotations
from datetime import date
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
ShiftType = Literal["morning", "evening", "duty"]
LinkStatus = Literal["open", "closed"]
SourceType = Literal["assign", "grab"]
class DutyAssignmentIn(BaseModel):
source: SourceType = "assign"
userId: Optional[str] = None
personName: str = ""
class DutySlotSaveIn(BaseModel):
shiftType: ShiftType
capacity: int = Field(ge=1, le=99)
assignments: List[DutyAssignmentIn] = []
class DutyDaySaveBody(BaseModel):
dutyDate: date
slots: List[DutySlotSaveIn]
linkStatus: Optional[LinkStatus] = None
class DutyLinkBody(BaseModel):
linkStatus: LinkStatus
class DutyGrabBody(BaseModel):
shiftType: ShiftType
personName: str
+473
View File
@@ -0,0 +1,473 @@
"""值班排班领域服务 — docs/superpowers/specs/2026-08-14-duty-schedule-design.md"""
from __future__ import annotations
import calendar
import secrets
from datetime import date, datetime
from typing import Any, Dict, List, Optional, Sequence
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.utils import generate_id
from app.models.duty_models import (
LINK_CLOSED,
LINK_OPEN,
SHIFT_LABELS,
SOURCE_ASSIGN,
SOURCE_GRAB,
DutyAssignment,
DutyDay,
DutySlot,
)
from app.schemas.duty import DutyDaySaveBody, DutySlotSaveIn
PERSON_NAME_MAX_LEN = 20
class DutyBizError(ValueError):
"""业务错误(可映射为 HTTP 400)"""
def normalize_person_name(name: str) -> str:
raw = (name or "").strip()
if not raw:
raise DutyBizError("姓名不能为空")
if len(raw) > PERSON_NAME_MAX_LEN:
raise DutyBizError(f"姓名不能超过{PERSON_NAME_MAX_LEN}个字符")
return raw
def new_access_token() -> str:
return secrets.token_urlsafe(24)[:64]
def build_public_url(token: str) -> str:
origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
return f"{origin}/#/public/duty/{token}"
def _assignment_dict(row: DutyAssignment) -> Dict[str, Any]:
return {
"id": row.id,
"source": row.source,
"userId": row.user_id,
"personName": row.person_name,
}
def _slot_dict(slot: DutySlot, assignments: Sequence[DutyAssignment]) -> Dict[str, Any]:
active = [a for a in assignments if a.del_flag == 0]
return {
"id": slot.id,
"shiftType": slot.shift_type,
"shiftLabel": SHIFT_LABELS.get(slot.shift_type, slot.shift_type),
"capacity": slot.capacity,
"taken": len(active),
"assignments": [_assignment_dict(a) for a in active],
}
def _day_dict(
day: DutyDay,
slots: Sequence[DutySlot],
assignments_by_slot: Dict[str, List[DutyAssignment]],
) -> Dict[str, Any]:
slot_payload = [
_slot_dict(s, assignments_by_slot.get(s.id, []))
for s in sorted(slots, key=lambda x: (x.order_num or 0, x.shift_type or ""))
if s.del_flag == 0
]
return {
"id": day.id,
"dutyDate": day.duty_date.isoformat() if day.duty_date else None,
"linkStatus": day.link_status,
"accessToken": day.access_token,
"publicUrl": build_public_url(day.access_token),
"slots": slot_payload,
}
def _load_day_by_date(db: Session, tenant_no: str, duty_date: date) -> Optional[DutyDay]:
return (
db.query(DutyDay)
.filter(
DutyDay.tenant_no == tenant_no,
DutyDay.duty_date == duty_date,
DutyDay.del_flag == 0,
)
.first()
)
def _load_day_by_id(db: Session, tenant_no: str, day_id: str) -> Optional[DutyDay]:
return (
db.query(DutyDay)
.filter(DutyDay.id == day_id, DutyDay.tenant_no == tenant_no, DutyDay.del_flag == 0)
.first()
)
def _load_slots(db: Session, day_id: str) -> List[DutySlot]:
return (
db.query(DutySlot)
.filter(DutySlot.duty_day_id == day_id, DutySlot.del_flag == 0)
.all()
)
def _load_assignments_for_slots(
db: Session, slot_ids: Sequence[str]
) -> Dict[str, List[DutyAssignment]]:
if not slot_ids:
return {}
rows = (
db.query(DutyAssignment)
.filter(DutyAssignment.duty_slot_id.in_(list(slot_ids)), DutyAssignment.del_flag == 0)
.all()
)
out: Dict[str, List[DutyAssignment]] = {sid: [] for sid in slot_ids}
for row in rows:
out.setdefault(row.duty_slot_id, []).append(row)
return out
def _serialize_day(db: Session, day: DutyDay) -> Dict[str, Any]:
slots = _load_slots(db, day.id)
assigns = _load_assignments_for_slots(db, [s.id for s in slots])
return _day_dict(day, slots, assigns)
def empty_day_skeleton(duty_date: date) -> Dict[str, Any]:
return {
"id": None,
"dutyDate": duty_date.isoformat(),
"linkStatus": LINK_OPEN,
"accessToken": None,
"publicUrl": None,
"slots": [],
}
def get_month_summary(db: Session, tenant_no: str, year: int, month: int) -> List[Dict[str, Any]]:
last_day = calendar.monthrange(year, month)[1]
start = date(year, month, 1)
end = date(year, month, last_day)
days = (
db.query(DutyDay)
.filter(
DutyDay.tenant_no == tenant_no,
DutyDay.del_flag == 0,
DutyDay.duty_date >= start,
DutyDay.duty_date <= end,
)
.order_by(DutyDay.duty_date.asc())
.all()
)
result = []
for day in days:
payload = _serialize_day(db, day)
# 月历摘要可精简 assignments 人名
result.append(payload)
return result
def get_day_detail(db: Session, tenant_no: str, duty_date: date) -> Dict[str, Any]:
day = _load_day_by_date(db, tenant_no, duty_date)
if not day:
return empty_day_skeleton(duty_date)
return _serialize_day(db, day)
def soft_delete_assignment(
db: Session, tenant_no: str, assignment_id: str, operator_id: str = ""
) -> None:
row = (
db.query(DutyAssignment)
.filter(
DutyAssignment.id == assignment_id,
DutyAssignment.tenant_no == tenant_no,
DutyAssignment.del_flag == 0,
)
.first()
)
if not row:
raise DutyBizError("报名记录不存在")
row.del_flag = 1
row.update_by = operator_id or None
row.update_time = datetime.now()
db.commit()
def set_link_status(
db: Session, tenant_no: str, day_id: str, status: str, operator_id: str = ""
) -> Dict[str, Any]:
if status not in (LINK_OPEN, LINK_CLOSED):
raise DutyBizError("链接状态无效")
day = _load_day_by_id(db, tenant_no, day_id)
if not day:
raise DutyBizError("值班日不存在")
day.link_status = status
day.update_by = operator_id or None
day.update_time = datetime.now()
db.commit()
db.refresh(day)
return _serialize_day(db, day)
def _count_active_assignments(db: Session, slot_id: str) -> int:
return (
db.query(DutyAssignment)
.filter(DutyAssignment.duty_slot_id == slot_id, DutyAssignment.del_flag == 0)
.count()
)
def save_day(
db: Session,
tenant_no: str,
user: Dict[str, Any],
body: DutyDaySaveBody,
) -> Dict[str, Any]:
user_id = (user.get("user_id") or user.get("id") or "") or ""
dept_id = (user.get("region_id") or user.get("create_dept_id") or "") or ""
now = datetime.now()
# 校验:每个 slot 提交的指定人数不超过 capacity
seen_types = set()
for slot_in in body.slots:
if slot_in.shiftType in seen_types:
raise DutyBizError("同一天不能重复配置相同班次")
seen_types.add(slot_in.shiftType)
assign_count = len(slot_in.assignments or [])
if assign_count > slot_in.capacity:
label = SHIFT_LABELS.get(slot_in.shiftType, slot_in.shiftType)
raise DutyBizError(f"{label}报名人数不能超过名额")
day = _load_day_by_date(db, tenant_no, body.dutyDate)
if not day:
day = DutyDay(
id=generate_id(),
duty_date=body.dutyDate,
access_token=new_access_token(),
link_status=body.linkStatus or LINK_OPEN,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=dept_id,
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(day)
db.flush()
else:
if body.linkStatus in (LINK_OPEN, LINK_CLOSED):
day.link_status = body.linkStatus
day.update_by = user_id
day.update_time = now
existing_slots = _load_slots(db, day.id)
existing_by_type = {s.shift_type: s for s in existing_slots}
incoming_types = {s.shiftType for s in body.slots}
# 停用未再提交且已有报名的班次:禁止
for shift_type, slot in existing_by_type.items():
if shift_type not in incoming_types:
taken = _count_active_assignments(db, slot.id)
if taken > 0:
label = SHIFT_LABELS.get(shift_type, shift_type)
raise DutyBizError(f"{label}仍有报名,请先清空后再移除该班次")
slot.del_flag = 1
slot.update_by = user_id
slot.update_time = now
order = 0
for slot_in in body.slots:
order += 1
slot = existing_by_type.get(slot_in.shiftType)
if slot is None:
slot = DutySlot(
id=generate_id(),
duty_day_id=day.id,
shift_type=slot_in.shiftType,
capacity=slot_in.capacity,
tenant_no=tenant_no,
order_num=order,
create_by=user_id,
create_dept_id=dept_id,
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(slot)
db.flush()
else:
taken = _count_active_assignments(db, slot.id)
# 缩容:不能小于「保留后的报名」——保存时以 body.assignments 为准重建指定项,
# 但抢班记录不在 body 里时需计入 taken 中不在本次提交删除范围的 grab。
# 简化策略:先统计将保留的 grab + 新 assign 总数,再与 capacity 比。
existing_assigns = (
db.query(DutyAssignment)
.filter(DutyAssignment.duty_slot_id == slot.id, DutyAssignment.del_flag == 0)
.all()
)
# body.assignments 视为该班次最终「指定」名单;grab 全部保留,除非超容时拒绝缩容
grab_rows = [a for a in existing_assigns if a.source == SOURCE_GRAB]
new_assign_count = len(slot_in.assignments or [])
final_count = len(grab_rows) + new_assign_count
if final_count > slot_in.capacity:
label = SHIFT_LABELS.get(slot_in.shiftType, slot_in.shiftType)
raise DutyBizError(
f"{label}当前已有 {len(grab_rows)} 人抢班 + {new_assign_count} 人指定,"
f"超过名额 {slot_in.capacity},请先删人再缩容"
)
slot.capacity = slot_in.capacity
slot.order_num = order
slot.update_by = user_id
slot.update_time = now
# 软删旧的 assign 指定,再写入新的;grab 保留
for a in existing_assigns:
if a.source == SOURCE_ASSIGN:
a.del_flag = 1
a.update_by = user_id
a.update_time = now
# 新 slot:写入 assignments;已存在 slot:写入新的 assign
for item in slot_in.assignments or []:
person_name = (item.personName or "").strip()
uid = (item.userId or "").strip() or None
if item.source == SOURCE_ASSIGN and not person_name and not uid:
continue
if not person_name:
raise DutyBizError("指定员工须有姓名")
person_name = normalize_person_name(person_name) if person_name else person_name
# 若只有 userId,允许 personName 已由上层填好
if not person_name:
raise DutyBizError("指定员工姓名不能为空")
db.add(
DutyAssignment(
id=generate_id(),
duty_slot_id=slot.id,
source=SOURCE_ASSIGN if item.source != SOURCE_GRAB else SOURCE_GRAB,
user_id=uid,
person_name=person_name,
tenant_no=tenant_no,
create_by=user_id,
create_dept_id=dept_id,
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
)
# 新建 slot 时也要校验人数
if slot.id and _count_active_assignments(db, slot.id) > slot.capacity:
label = SHIFT_LABELS.get(slot.shift_type, slot.shift_type)
raise DutyBizError(f"{label}报名人数不能超过名额")
db.commit()
db.refresh(day)
return _serialize_day(db, day)
def get_public_page(db: Session, token: str) -> Dict[str, Any]:
token = (token or "").strip()
if not token:
raise DutyBizError("链接无效或已失效")
day = (
db.query(DutyDay)
.filter(DutyDay.access_token == token, DutyDay.del_flag == 0)
.first()
)
if not day:
raise DutyBizError("链接无效或已失效")
payload = _serialize_day(db, day)
# 公开页不强制返回 accessToken 也可,保留便于调试;可去掉敏感则仅留必要字段
return {
"dutyDate": payload["dutyDate"],
"linkStatus": payload["linkStatus"],
"slots": payload["slots"],
"closed": day.link_status == LINK_CLOSED,
}
def grab(db: Session, token: str, shift_type: str, person_name: str) -> Dict[str, Any]:
name = normalize_person_name(person_name)
shift_type = (shift_type or "").strip()
if shift_type not in SHIFT_LABELS:
raise DutyBizError("班次类型无效")
day = (
db.query(DutyDay)
.filter(DutyDay.access_token == token, DutyDay.del_flag == 0)
.with_for_update()
.first()
)
if not day:
raise DutyBizError("链接无效或已失效")
if day.link_status == LINK_CLOSED:
raise DutyBizError("报名已关闭")
slot = (
db.query(DutySlot)
.filter(
DutySlot.duty_day_id == day.id,
DutySlot.shift_type == shift_type,
DutySlot.del_flag == 0,
)
.with_for_update()
.first()
)
if not slot:
raise DutyBizError("该班次不存在")
taken = _count_active_assignments(db, slot.id)
if taken >= slot.capacity:
raise DutyBizError("名额已满")
now = datetime.now()
row = DutyAssignment(
id=generate_id(),
duty_slot_id=slot.id,
source=SOURCE_GRAB,
user_id=None,
person_name=name,
tenant_no=day.tenant_no,
create_by="",
create_dept_id="",
create_time=now,
update_by="",
update_time=now,
del_flag=0,
)
db.add(row)
db.commit()
return {
"dutyDate": day.duty_date.isoformat(),
"shiftType": shift_type,
"shiftLabel": SHIFT_LABELS.get(shift_type, shift_type),
"personName": name,
"status": "ok",
}
# re-export for tests
__all__ = [
"SHIFT_LABELS",
"DutyBizError",
"normalize_person_name",
"new_access_token",
"build_public_url",
"get_month_summary",
"get_day_detail",
"empty_day_skeleton",
"save_day",
"set_link_status",
"soft_delete_assignment",
"get_public_page",
"grab",
]
+172
View File
@@ -0,0 +1,172 @@
"""值班排班服务单测"""
from datetime import date
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from app.models.duty_models import (
LINK_CLOSED,
LINK_OPEN,
SHIFT_EVENING,
SHIFT_LABELS,
SHIFT_MORNING,
SOURCE_GRAB,
)
from app.schemas.duty import DutyAssignmentIn, DutyDaySaveBody, DutySlotSaveIn
from app.services.duty_schedule import (
DutyBizError,
grab,
normalize_person_name,
save_day,
)
def test_normalize_person_name_ok():
assert normalize_person_name(" 张三 ") == "张三"
def test_normalize_person_name_empty():
with pytest.raises(DutyBizError, match="姓名"):
normalize_person_name(" ")
def test_normalize_person_name_too_long():
with pytest.raises(DutyBizError, match="姓名"):
normalize_person_name("啊" * 21)
def test_shift_labels():
assert SHIFT_LABELS[SHIFT_MORNING] == "早班"
def test_grab_rejects_when_full():
db = MagicMock()
day = SimpleNamespace(
id="day1",
tenant_no="t1",
link_status=LINK_OPEN,
duty_date=date(2026, 8, 20),
access_token="tok",
del_flag=0,
)
slot = SimpleNamespace(
id="slot1",
duty_day_id="day1",
shift_type=SHIFT_EVENING,
capacity=1,
del_flag=0,
)
day_q = MagicMock()
day_q.filter.return_value.with_for_update.return_value.first.return_value = day
slot_q = MagicMock()
slot_q.filter.return_value.with_for_update.return_value.first.return_value = slot
def query_side_effect(model):
name = getattr(model, "__name__", str(model))
if name == "DutyDay":
return day_q
if name == "DutySlot":
return slot_q
# DutyAssignment count
aq = MagicMock()
aq.filter.return_value.count.return_value = 1
return aq
db.query.side_effect = query_side_effect
with pytest.raises(DutyBizError, match="名额已满"):
grab(db, "tok", SHIFT_EVENING, "李四")
def test_grab_rejects_when_closed():
db = MagicMock()
day = SimpleNamespace(
id="day1",
tenant_no="t1",
link_status=LINK_CLOSED,
duty_date=date(2026, 8, 20),
access_token="tok",
del_flag=0,
)
day_q = MagicMock()
day_q.filter.return_value.with_for_update.return_value.first.return_value = day
db.query.return_value = day_q
with pytest.raises(DutyBizError, match="关闭"):
grab(db, "tok", SHIFT_EVENING, "李四")
def test_save_day_rejects_shrink_below_grab_count():
"""缩容:已有 1 人抢班时 capacity=1 且再指定 1 人应失败。"""
db = MagicMock()
day = SimpleNamespace(
id="day1",
duty_date=date(2026, 8, 20),
access_token="tok",
link_status=LINK_OPEN,
tenant_no="t1",
del_flag=0,
update_by=None,
update_time=None,
)
slot = SimpleNamespace(
id="slot1",
duty_day_id="day1",
shift_type=SHIFT_EVENING,
capacity=2,
del_flag=0,
order_num=1,
update_by=None,
update_time=None,
)
grab_row = SimpleNamespace(
id="a1",
duty_slot_id="slot1",
source=SOURCE_GRAB,
del_flag=0,
update_by=None,
update_time=None,
)
day_q = MagicMock()
day_q.filter.return_value.first.return_value = day
slot_list_q = MagicMock()
slot_list_q.filter.return_value.all.return_value = [slot]
assign_q = MagicMock()
assign_q.filter.return_value.all.return_value = [grab_row]
count_q = MagicMock()
count_q.filter.return_value.count.return_value = 1
def query_side_effect(model):
name = getattr(model, "__name__", "")
if name == "DutyDay":
return day_q
if name == "DutySlot":
return slot_list_q
if name == "DutyAssignment":
# first call in loop may be count, then all
m = MagicMock()
m.filter.return_value.count.return_value = 1
m.filter.return_value.all.return_value = [grab_row]
return m
return MagicMock()
db.query.side_effect = query_side_effect
body = DutyDaySaveBody(
dutyDate=date(2026, 8, 20),
slots=[
DutySlotSaveIn(
shiftType=SHIFT_EVENING,
capacity=1,
assignments=[
DutyAssignmentIn(source="assign", userId="u1", personName="张三")
],
)
],
)
with pytest.raises(DutyBizError, match="超过名额|缩容|报名"):
save_day(db, "t1", {"user_id": "op1"}, body)