|
|
@@ -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",
|
|
|
+]
|