| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934 |
- """值班排班领域服务 — 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_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)
- 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)
- 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
- # 软删旧的 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",
- ]
|