992 lines
31 KiB
Python
992 lines
31 KiB
Python
"""值班排班领域服务 — docs/superpowers/specs/2026-08-14-duty-schedule-design.md"""
|
||
from __future__ import annotations
|
||
|
||
import calendar
|
||
import re
|
||
import secrets
|
||
from datetime import date, datetime, time
|
||
from typing import Any, Dict, List, Optional, Sequence
|
||
|
||
from pypinyin import Style, lazy_pinyin
|
||
from sqlalchemy import and_
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.config import settings
|
||
from app.core.utils import generate_id, generate_uuid
|
||
from app.models.duty_models import (
|
||
DUTY_STAFF_ROLE_ID,
|
||
DUTY_STAFF_ROLE_NAME,
|
||
DICT_SHIFT_TYPE,
|
||
DICT_TIME_RANGE,
|
||
DICT_WORK_PLAN,
|
||
LINK_CLOSED,
|
||
LINK_OPEN,
|
||
SHIFT_LABELS,
|
||
SOURCE_ASSIGN,
|
||
SOURCE_GRAB,
|
||
DutyAssignment,
|
||
DutyDay,
|
||
DutySlot,
|
||
)
|
||
from app.models.models import SysDictData, SysRole, SysUser, SysUserRole
|
||
from app.schemas.duty import DutyDaySaveBody, DutySlotSaveIn
|
||
|
||
PERSON_NAME_MAX_LEN = 20
|
||
|
||
|
||
class DutyBizError(ValueError):
|
||
"""业务错误(可映射为 HTTP 400)"""
|
||
|
||
|
||
def parse_hhmm(value: Optional[str]) -> Optional[time]:
|
||
raw = (value or "").strip()
|
||
if not raw:
|
||
return None
|
||
parts = raw.split(":")
|
||
if len(parts) < 2:
|
||
raise DutyBizError("时间格式须为 HH:mm")
|
||
try:
|
||
h, m = int(parts[0]), int(parts[1])
|
||
except ValueError as exc:
|
||
raise DutyBizError("时间格式须为 HH:mm") from exc
|
||
if not (0 <= h <= 23 and 0 <= m <= 59):
|
||
raise DutyBizError("时间格式须为 HH:mm")
|
||
return time(hour=h, minute=m)
|
||
|
||
|
||
def format_hhmm(t: Optional[time]) -> Optional[str]:
|
||
if t is None:
|
||
return None
|
||
return f"{t.hour:02d}:{t.minute:02d}"
|
||
|
||
|
||
def format_time_label(
|
||
start: Optional[time], end: Optional[time], cross_day: bool
|
||
) -> Optional[str]:
|
||
if start is None or end is None:
|
||
return None
|
||
s, e = format_hhmm(start), format_hhmm(end)
|
||
if cross_day:
|
||
return f"{s}–次日{e}"
|
||
return f"{s}–{e}"
|
||
|
||
|
||
def split_work_plans(raw: Optional[str]) -> List[str]:
|
||
if not raw:
|
||
return []
|
||
return [p.strip() for p in str(raw).replace(",", ",").split(",") if p.strip()]
|
||
|
||
|
||
def join_work_plans(values: Sequence[str]) -> Optional[str]:
|
||
parts = [v.strip() for v in values if (v or "").strip()]
|
||
return ",".join(parts) if parts else None
|
||
|
||
|
||
def build_dict_value_from_label(label: str) -> str:
|
||
text = (label or "").strip()
|
||
parts: List[str] = []
|
||
for ch in text:
|
||
if "\u4e00" <= ch <= "\u9fff":
|
||
pys = lazy_pinyin(ch, style=Style.NORMAL)
|
||
if pys and pys[0]:
|
||
parts.append(str(pys[0]).lower())
|
||
elif ch.isalnum():
|
||
parts.append(ch.lower())
|
||
base = "".join(parts)[:32] or "item"
|
||
return base
|
||
|
||
|
||
def _pad_hhmm(h: int, m: int) -> str:
|
||
return f"{h:02d}:{m:02d}"
|
||
|
||
|
||
def parse_time_range_input(raw: str) -> Dict[str, Any]:
|
||
"""解析「09:00-17:00」「18:00-次日07:00」等,返回 start/end/crossDay/label/value。"""
|
||
text = (raw or "").strip().replace("—", "-").replace("–", "-").replace("~", "-")
|
||
text = re.sub(r"\s+", "", text)
|
||
if not text:
|
||
raise DutyBizError("时段不能为空")
|
||
m = re.match(
|
||
r"^(\d{1,2}):(\d{2})[-至到](次日)?(\d{1,2}):(\d{2})$",
|
||
text,
|
||
)
|
||
if not m:
|
||
raise DutyBizError("时段格式须为 09:00-17:00 或 18:00-次日07:00")
|
||
sh, sm, cross_flag, eh, em = m.groups()
|
||
try:
|
||
start_h, start_m = int(sh), int(sm)
|
||
end_h, end_m = int(eh), int(em)
|
||
except ValueError as exc:
|
||
raise DutyBizError("时段格式须为 09:00-17:00 或 18:00-次日07:00") from exc
|
||
if not (0 <= start_h <= 23 and 0 <= start_m <= 59 and 0 <= end_h <= 23 and 0 <= end_m <= 59):
|
||
raise DutyBizError("时段格式须为 09:00-17:00 或 18:00-次日07:00")
|
||
start = _pad_hhmm(start_h, start_m)
|
||
end = _pad_hhmm(end_h, end_m)
|
||
cross = 1 if cross_flag or end < start else 0
|
||
label = f"{start}–次日{end}" if cross else f"{start}–{end}"
|
||
value = f"{start}|{end}|{cross}"
|
||
return {
|
||
"startTime": start,
|
||
"endTime": end,
|
||
"crossDay": bool(cross),
|
||
"label": label,
|
||
"value": value,
|
||
}
|
||
|
||
|
||
def ensure_dict_data(
|
||
db: Session,
|
||
*,
|
||
tenant_no: str,
|
||
dict_type: str,
|
||
label: str,
|
||
user_id: str = "",
|
||
preferred_value: Optional[str] = None,
|
||
) -> Dict[str, str]:
|
||
label = (label or "").strip()
|
||
if not label:
|
||
raise DutyBizError("字典标签不能为空")
|
||
if dict_type not in (DICT_SHIFT_TYPE, DICT_WORK_PLAN, DICT_TIME_RANGE):
|
||
raise DutyBizError("不支持的字典类型")
|
||
|
||
preferred = (preferred_value or "").strip() or None
|
||
if dict_type == DICT_TIME_RANGE:
|
||
parsed = parse_time_range_input(label)
|
||
label = parsed["label"]
|
||
preferred = preferred or parsed["value"]
|
||
|
||
existing_label = (
|
||
db.query(SysDictData)
|
||
.filter(
|
||
SysDictData.tenant_no == tenant_no,
|
||
SysDictData.dict_type == dict_type,
|
||
SysDictData.dict_label == label,
|
||
SysDictData.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if existing_label:
|
||
return {"label": existing_label.dict_label, "value": existing_label.dict_value}
|
||
|
||
if preferred:
|
||
by_value = (
|
||
db.query(SysDictData)
|
||
.filter(
|
||
SysDictData.tenant_no == tenant_no,
|
||
SysDictData.dict_type == dict_type,
|
||
SysDictData.dict_value == preferred,
|
||
SysDictData.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if by_value:
|
||
return {"label": by_value.dict_label, "value": by_value.dict_value}
|
||
|
||
value = preferred or build_dict_value_from_label(label)
|
||
for _ in range(8):
|
||
clash = (
|
||
db.query(SysDictData)
|
||
.filter(
|
||
SysDictData.tenant_no == tenant_no,
|
||
SysDictData.dict_type == dict_type,
|
||
SysDictData.dict_value == value,
|
||
SysDictData.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not clash:
|
||
break
|
||
value = f"{value[:27]}_{secrets.token_hex(2)}"
|
||
else:
|
||
raise DutyBizError("无法生成唯一字典值")
|
||
|
||
obj = SysDictData(
|
||
id=generate_uuid(),
|
||
dict_label=label,
|
||
dict_value=value,
|
||
dict_type=dict_type,
|
||
tag_type="",
|
||
tenant_no=tenant_no,
|
||
order_num=0,
|
||
remark=None,
|
||
create_by=user_id or None,
|
||
create_dept_id="",
|
||
create_time=datetime.now(),
|
||
update_by=user_id or None,
|
||
update_time=datetime.now(),
|
||
del_flag=0,
|
||
)
|
||
db.add(obj)
|
||
db.flush()
|
||
return {"label": obj.dict_label, "value": obj.dict_value}
|
||
|
||
|
||
def _load_dict_label_map(db: Session, tenant_no: str, dict_type: str) -> Dict[str, str]:
|
||
rows = (
|
||
db.query(SysDictData)
|
||
.filter(
|
||
SysDictData.tenant_no == tenant_no,
|
||
SysDictData.dict_type == dict_type,
|
||
SysDictData.del_flag == 0,
|
||
)
|
||
.all()
|
||
)
|
||
return {r.dict_value: r.dict_label for r in rows}
|
||
|
||
|
||
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],
|
||
*,
|
||
shift_labels: Dict[str, str],
|
||
work_plan_labels: Dict[str, str],
|
||
) -> Dict[str, Any]:
|
||
active = [a for a in assignments if a.del_flag == 0]
|
||
plans = split_work_plans(getattr(slot, "work_plans", None))
|
||
cross = bool(getattr(slot, "cross_day", 0) or 0)
|
||
start = getattr(slot, "start_time", None)
|
||
end = getattr(slot, "end_time", None)
|
||
return {
|
||
"id": slot.id,
|
||
"shiftType": slot.shift_type,
|
||
"shiftLabel": shift_labels.get(slot.shift_type)
|
||
or SHIFT_LABELS.get(slot.shift_type, slot.shift_type),
|
||
"capacity": slot.capacity,
|
||
"taken": len(active),
|
||
"workPlans": plans,
|
||
"workPlanLabels": [work_plan_labels.get(v, v) for v in plans],
|
||
"remark": (slot.remark or "").strip() or None,
|
||
"startTime": format_hhmm(start),
|
||
"endTime": format_hhmm(end),
|
||
"crossDay": cross,
|
||
"timeLabel": format_time_label(start, end, cross),
|
||
"assignments": [_assignment_dict(a) for a in active],
|
||
}
|
||
|
||
|
||
def _day_dict(
|
||
day: DutyDay,
|
||
slots: Sequence[DutySlot],
|
||
assignments_by_slot: Dict[str, List[DutyAssignment]],
|
||
*,
|
||
shift_labels: Dict[str, str],
|
||
work_plan_labels: Dict[str, str],
|
||
) -> Dict[str, Any]:
|
||
slot_payload = [
|
||
_slot_dict(
|
||
s,
|
||
assignments_by_slot.get(s.id, []),
|
||
shift_labels=shift_labels,
|
||
work_plan_labels=work_plan_labels,
|
||
)
|
||
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_all_slots(db: Session, day_id: str) -> List[DutySlot]:
|
||
return db.query(DutySlot).filter(DutySlot.duty_day_id == day_id).all()
|
||
|
||
|
||
def _tombstone_shift_type(shift_type: str, slot_id: str) -> str:
|
||
"""改写已软删行的 shift_type,释放 uk_day_shift(day, type, del_flag=1)。"""
|
||
suffix = (slot_id or "x")[-8:]
|
||
base = (shift_type or "shift")[:22]
|
||
return f"{base}~{suffix}"
|
||
|
||
|
||
def _free_soft_deleted_uk(
|
||
db: Session,
|
||
day_id: str,
|
||
shift_type: str,
|
||
keep_slot_id: str,
|
||
now: datetime,
|
||
user_id: str,
|
||
) -> None:
|
||
"""同一天同班次若已有 del_flag=1 墓碑,先改写其 shift_type,避免再次软删撞 uk。"""
|
||
rows = (
|
||
db.query(DutySlot)
|
||
.filter(
|
||
DutySlot.duty_day_id == day_id,
|
||
DutySlot.shift_type == shift_type,
|
||
DutySlot.del_flag == 1,
|
||
DutySlot.id != keep_slot_id,
|
||
)
|
||
.all()
|
||
)
|
||
for row in rows:
|
||
row.shift_type = _tombstone_shift_type(shift_type, row.id)
|
||
row.update_by = user_id or None
|
||
row.update_time = now
|
||
|
||
|
||
def _retire_slot(db: Session, slot: DutySlot, user_id: str, now: datetime) -> None:
|
||
_free_soft_deleted_uk(db, slot.duty_day_id, slot.shift_type, slot.id, now, user_id)
|
||
db.flush()
|
||
slot.del_flag = 1
|
||
slot.update_by = user_id or None
|
||
slot.update_time = now
|
||
|
||
|
||
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])
|
||
shift_labels = _load_dict_label_map(db, day.tenant_no, DICT_SHIFT_TYPE)
|
||
work_plan_labels = _load_dict_label_map(db, day.tenant_no, DICT_WORK_PLAN)
|
||
return _day_dict(
|
||
day,
|
||
slots,
|
||
assigns,
|
||
shift_labels=shift_labels,
|
||
work_plan_labels=work_plan_labels,
|
||
)
|
||
|
||
|
||
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)
|
||
all_slots = _load_all_slots(db, day.id)
|
||
existing_by_type = {s.shift_type: s for s in existing_slots}
|
||
soft_by_type = {
|
||
s.shift_type: s
|
||
for s in all_slots
|
||
if s.del_flag == 1 and s.shift_type not in existing_by_type
|
||
}
|
||
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}仍有报名,请先清空后再移除该班次")
|
||
_retire_slot(db, slot, user_id, now)
|
||
|
||
order = 0
|
||
for slot_in in body.slots:
|
||
order += 1
|
||
slot = existing_by_type.get(slot_in.shiftType)
|
||
revived = False
|
||
if slot is None:
|
||
soft = soft_by_type.get(slot_in.shiftType)
|
||
if soft is not None:
|
||
slot = soft
|
||
slot.del_flag = 0
|
||
revived = True
|
||
slot_remark = (slot_in.remark or "").strip() or None
|
||
plans_raw = list(slot_in.workPlans or [])
|
||
for v in plans_raw:
|
||
v = (v or "").strip()
|
||
if not v:
|
||
continue
|
||
row = (
|
||
db.query(SysDictData)
|
||
.filter(
|
||
SysDictData.tenant_no == tenant_no,
|
||
SysDictData.dict_type == DICT_WORK_PLAN,
|
||
SysDictData.dict_value == v,
|
||
SysDictData.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not row:
|
||
ensure_dict_data(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
dict_type=DICT_WORK_PLAN,
|
||
label=v,
|
||
preferred_value=v,
|
||
user_id=user_id,
|
||
)
|
||
slot_work_plans = join_work_plans(plans_raw)
|
||
|
||
if slot_in.shiftType not in SHIFT_LABELS:
|
||
row = (
|
||
db.query(SysDictData)
|
||
.filter(
|
||
SysDictData.tenant_no == tenant_no,
|
||
SysDictData.dict_type == DICT_SHIFT_TYPE,
|
||
SysDictData.dict_value == slot_in.shiftType,
|
||
SysDictData.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not row:
|
||
ensure_dict_data(
|
||
db,
|
||
tenant_no=tenant_no,
|
||
dict_type=DICT_SHIFT_TYPE,
|
||
label=SHIFT_LABELS.get(slot_in.shiftType, slot_in.shiftType),
|
||
preferred_value=slot_in.shiftType,
|
||
user_id=user_id,
|
||
)
|
||
|
||
start_t = parse_hhmm(slot_in.startTime)
|
||
end_t = parse_hhmm(slot_in.endTime)
|
||
if (start_t is None) ^ (end_t is None):
|
||
raise DutyBizError("值班时间须同时填写开始与结束,或全部留空")
|
||
cross = 1 if slot_in.crossDay else 0
|
||
|
||
# 已有 slot 上历史 assign 用户可再保存(D4);新建 slot 必须具备值班人员角色(D7)
|
||
grandfathered_user_ids: set = set()
|
||
if slot is None:
|
||
slot = DutySlot(
|
||
id=generate_id(),
|
||
duty_day_id=day.id,
|
||
shift_type=slot_in.shiftType,
|
||
capacity=slot_in.capacity,
|
||
work_plans=slot_work_plans,
|
||
start_time=start_t,
|
||
end_time=end_t,
|
||
cross_day=cross,
|
||
remark=slot_remark,
|
||
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()
|
||
)
|
||
grandfathered_user_ids = {
|
||
a.user_id
|
||
for a in existing_assigns
|
||
if a.source == SOURCE_ASSIGN and a.user_id
|
||
}
|
||
# 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.work_plans = slot_work_plans
|
||
slot.start_time = start_t
|
||
slot.end_time = end_t
|
||
slot.cross_day = cross
|
||
slot.remark = slot_remark
|
||
slot.order_num = order
|
||
slot.update_by = user_id
|
||
slot.update_time = now
|
||
if revived:
|
||
slot.del_flag = 0
|
||
|
||
# 软删旧的 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("指定员工姓名不能为空")
|
||
if item.source == SOURCE_ASSIGN and uid:
|
||
if uid not in grandfathered_user_ids:
|
||
assert_user_is_duty_staff(db, tenant_no, uid)
|
||
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 resolve_duty_staff_role_id(db: Session, tenant_no: str) -> Optional[str]:
|
||
"""按固定 id 查找「值班人员」角色;找不到再按角色名兜底。"""
|
||
role = (
|
||
db.query(SysRole)
|
||
.filter(
|
||
SysRole.id == DUTY_STAFF_ROLE_ID,
|
||
SysRole.tenant_no == tenant_no,
|
||
SysRole.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if role:
|
||
return role.id
|
||
role = (
|
||
db.query(SysRole)
|
||
.filter(
|
||
SysRole.role_name == DUTY_STAFF_ROLE_NAME,
|
||
SysRole.tenant_no == tenant_no,
|
||
SysRole.del_flag == 0,
|
||
SysRole.status == "1",
|
||
)
|
||
.first()
|
||
)
|
||
return role.id if role else None
|
||
|
||
|
||
def list_duty_staff_users(db: Session, tenant_no: str) -> List[Dict[str, str]]:
|
||
"""本租户启用且绑定「值班人员」角色的用户(公开页 users / 管理端 options)。"""
|
||
role_id = resolve_duty_staff_role_id(db, tenant_no)
|
||
if not role_id:
|
||
return []
|
||
rows = (
|
||
db.query(SysUser)
|
||
.join(
|
||
SysUserRole,
|
||
and_(
|
||
SysUserRole.user_id == SysUser.id,
|
||
SysUserRole.role_id == role_id,
|
||
SysUserRole.del_flag == 0,
|
||
SysUserRole.tenant_no == tenant_no,
|
||
),
|
||
)
|
||
.filter(
|
||
SysUser.tenant_no == tenant_no,
|
||
SysUser.del_flag == 0,
|
||
SysUser.status == "1",
|
||
)
|
||
.order_by(SysUser.nick_name.asc(), SysUser.user_no.asc())
|
||
.all()
|
||
)
|
||
out: List[Dict[str, str]] = []
|
||
seen = set()
|
||
for r in rows:
|
||
if r.id in seen:
|
||
continue
|
||
seen.add(r.id)
|
||
label = (r.nick_name or "").strip() or (r.user_no or "").strip()
|
||
if not label:
|
||
continue
|
||
out.append({"id": r.id, "label": label})
|
||
return out
|
||
|
||
|
||
def assert_user_is_duty_staff(db: Session, tenant_no: str, user_id: str) -> None:
|
||
uid = (user_id or "").strip()
|
||
if not uid:
|
||
raise DutyBizError("用户无效或不具备值班人员角色")
|
||
user = (
|
||
db.query(SysUser)
|
||
.filter(
|
||
SysUser.id == uid,
|
||
SysUser.tenant_no == tenant_no,
|
||
SysUser.del_flag == 0,
|
||
SysUser.status == "1",
|
||
)
|
||
.first()
|
||
)
|
||
if not user:
|
||
raise DutyBizError("用户无效或不具备值班人员角色")
|
||
role_id = resolve_duty_staff_role_id(db, tenant_no)
|
||
if not role_id:
|
||
raise DutyBizError("未配置「值班人员」角色,请联系管理员")
|
||
link = (
|
||
db.query(SysUserRole)
|
||
.filter(
|
||
SysUserRole.user_id == uid,
|
||
SysUserRole.role_id == role_id,
|
||
SysUserRole.tenant_no == tenant_no,
|
||
SysUserRole.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if not link:
|
||
raise DutyBizError("用户无效或不具备值班人员角色")
|
||
|
||
|
||
def _list_public_users(db: Session, tenant_no: str) -> List[Dict[str, str]]:
|
||
return list_duty_staff_users(db, tenant_no)
|
||
|
||
|
||
def _resolve_grab_user(db: Session, tenant_no: str, user_id: str) -> SysUser:
|
||
uid = (user_id or "").strip()
|
||
if not uid:
|
||
raise DutyBizError("请选择有效用户")
|
||
user = (
|
||
db.query(SysUser)
|
||
.filter(
|
||
SysUser.id == uid,
|
||
SysUser.tenant_no == tenant_no,
|
||
SysUser.del_flag == 0,
|
||
SysUser.status == "1",
|
||
)
|
||
.first()
|
||
)
|
||
if not user:
|
||
raise DutyBizError("用户无效或不存在")
|
||
assert_user_is_duty_staff(db, tenant_no, user.id)
|
||
return user
|
||
|
||
|
||
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)
|
||
return {
|
||
"dutyDate": payload["dutyDate"],
|
||
"linkStatus": payload["linkStatus"],
|
||
"slots": payload["slots"],
|
||
"closed": day.link_status == LINK_CLOSED,
|
||
"users": _list_public_users(db, day.tenant_no),
|
||
}
|
||
|
||
|
||
def grab(db: Session, token: str, shift_type: str, user_id: str) -> Dict[str, Any]:
|
||
shift_type = (shift_type or "").strip()
|
||
if not shift_type:
|
||
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("报名已关闭")
|
||
|
||
user = _resolve_grab_user(db, day.tenant_no, user_id)
|
||
name = normalize_person_name((user.nick_name or "").strip() or (user.user_no or "").strip())
|
||
|
||
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("该班次不存在")
|
||
|
||
dup = (
|
||
db.query(DutyAssignment)
|
||
.filter(
|
||
DutyAssignment.duty_slot_id == slot.id,
|
||
DutyAssignment.user_id == user.id,
|
||
DutyAssignment.del_flag == 0,
|
||
)
|
||
.first()
|
||
)
|
||
if dup:
|
||
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=user.id,
|
||
person_name=name,
|
||
tenant_no=day.tenant_no,
|
||
create_by=user.id,
|
||
create_dept_id="",
|
||
create_time=now,
|
||
update_by=user.id,
|
||
update_time=now,
|
||
del_flag=0,
|
||
)
|
||
db.add(row)
|
||
db.commit()
|
||
shift_labels = _load_dict_label_map(db, day.tenant_no, DICT_SHIFT_TYPE)
|
||
return {
|
||
"dutyDate": day.duty_date.isoformat(),
|
||
"shiftType": shift_type,
|
||
"shiftLabel": shift_labels.get(shift_type)
|
||
or SHIFT_LABELS.get(shift_type, shift_type),
|
||
"personName": name,
|
||
"userId": user.id,
|
||
"status": "ok",
|
||
}
|
||
|
||
|
||
# re-export for tests
|
||
__all__ = [
|
||
"SHIFT_LABELS",
|
||
"DutyBizError",
|
||
"parse_hhmm",
|
||
"parse_time_range_input",
|
||
"format_hhmm",
|
||
"format_time_label",
|
||
"split_work_plans",
|
||
"join_work_plans",
|
||
"build_dict_value_from_label",
|
||
"ensure_dict_data",
|
||
"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",
|
||
"resolve_duty_staff_role_id",
|
||
"list_duty_staff_users",
|
||
"assert_user_is_duty_staff",
|
||
]
|