| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009 |
- """值班排班领域服务 — 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 _sync_assigned_people(db, tenant_no, slot, items, existing, user_id, dept_id, now):
- if items is None:
- return
- assigned = [a for a in existing if a.source == SOURCE_ASSIGN]
- by_id = {a.id: a for a in assigned}
- old_users = {a.user_id for a in assigned if a.user_id}
- seen_ids, seen_people = set(), set()
- planned = []
- for item in items:
- if item.source != SOURCE_ASSIGN:
- raise DutyBizError("抢班记录须通过抢班流程操作,不能作为指定人员保存")
- uid = (item.userId or "").strip() or None
- name = normalize_person_name((item.personName or "").strip())
- if not name:
- raise DutyBizError("指定员工须有姓名")
- key = ("user", uid) if uid else ("name", name)
- if key in seen_people:
- raise DutyBizError("同一班次不能重复指定同一人员")
- seen_people.add(key)
- row = None
- if item.id:
- row = by_id.get(item.id)
- if row is None or item.id in seen_ids:
- raise DutyBizError("指定人员记录无效、重复或不属于当前班次")
- else:
- matches = [a for a in assigned if
- ((a.user_id == uid) if uid else (not a.user_id and a.person_name == name))]
- if len(matches) > 1:
- raise DutyBizError("历史指定人员重复,请刷新并按记录 ID 保存")
- row = matches[0] if matches else None
- if row is not None:
- if row.id in seen_ids:
- raise DutyBizError("指定人员记录不能重复提交")
- seen_ids.add(row.id)
- if uid and uid not in old_users:
- assert_user_is_duty_staff(db, tenant_no, uid)
- if any(a.source == SOURCE_GRAB and (a.user_id == uid if uid else a.person_name == name) for a in existing):
- raise DutyBizError("该人员已抢班,不能重复指定")
- planned.append((row, uid, name))
- for row in assigned:
- if row.id not in seen_ids:
- row.del_flag = 1
- row.update_by = user_id
- row.update_time = now
- for row, uid, name in planned:
- if row is None:
- db.add(DutyAssignment(
- id=generate_id(), duty_slot_id=slot.id, source=SOURCE_ASSIGN,
- user_id=uid, person_name=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,
- ))
- elif row.user_id != uid or row.person_name != name:
- row.user_id = uid
- row.person_name = name
- row.update_by = user_id
- row.update_time = now
- 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
- existing_assigns = []
- 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:
- # 抢班记录保留;指定名单未传时也保留。
- existing_assigns = (
- db.query(DutyAssignment)
- .filter(DutyAssignment.duty_slot_id == slot.id, DutyAssignment.tenant_no == tenant_no, DutyAssignment.del_flag == 0)
- .all()
- )
- grab_rows = [a for a in existing_assigns if a.source == SOURCE_GRAB]
- new_assign_count = (len(slot_in.assignments) if slot_in.assignments is not None
- else sum(a.source == SOURCE_ASSIGN for a in existing_assigns))
- 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
- _sync_assigned_people(
- db, tenant_no, slot, slot_in.assignments, existing_assigns,
- user_id, dept_id, now,
- )
- # SessionLocal 禁用 autoflush,容量校验须看到本次增删后的名单。
- db.flush()
- # 新建 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",
- ]
|