duty_schedule.py 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009
  1. """值班排班领域服务 — docs/superpowers/specs/2026-08-14-duty-schedule-design.md"""
  2. from __future__ import annotations
  3. import calendar
  4. import re
  5. import secrets
  6. from datetime import date, datetime, time
  7. from typing import Any, Dict, List, Optional, Sequence
  8. from pypinyin import Style, lazy_pinyin
  9. from sqlalchemy import and_
  10. from sqlalchemy.orm import Session
  11. from app.core.config import settings
  12. from app.core.utils import generate_id, generate_uuid
  13. from app.models.duty_models import (
  14. DUTY_STAFF_ROLE_ID,
  15. DUTY_STAFF_ROLE_NAME,
  16. DICT_SHIFT_TYPE,
  17. DICT_TIME_RANGE,
  18. DICT_WORK_PLAN,
  19. LINK_CLOSED,
  20. LINK_OPEN,
  21. SHIFT_LABELS,
  22. SOURCE_ASSIGN,
  23. SOURCE_GRAB,
  24. DutyAssignment,
  25. DutyDay,
  26. DutySlot,
  27. )
  28. from app.models.models import SysDictData, SysRole, SysUser, SysUserRole
  29. from app.schemas.duty import DutyDaySaveBody, DutySlotSaveIn
  30. PERSON_NAME_MAX_LEN = 20
  31. class DutyBizError(ValueError):
  32. """业务错误(可映射为 HTTP 400)"""
  33. def parse_hhmm(value: Optional[str]) -> Optional[time]:
  34. raw = (value or "").strip()
  35. if not raw:
  36. return None
  37. parts = raw.split(":")
  38. if len(parts) < 2:
  39. raise DutyBizError("时间格式须为 HH:mm")
  40. try:
  41. h, m = int(parts[0]), int(parts[1])
  42. except ValueError as exc:
  43. raise DutyBizError("时间格式须为 HH:mm") from exc
  44. if not (0 <= h <= 23 and 0 <= m <= 59):
  45. raise DutyBizError("时间格式须为 HH:mm")
  46. return time(hour=h, minute=m)
  47. def format_hhmm(t: Optional[time]) -> Optional[str]:
  48. if t is None:
  49. return None
  50. return f"{t.hour:02d}:{t.minute:02d}"
  51. def format_time_label(
  52. start: Optional[time], end: Optional[time], cross_day: bool
  53. ) -> Optional[str]:
  54. if start is None or end is None:
  55. return None
  56. s, e = format_hhmm(start), format_hhmm(end)
  57. if cross_day:
  58. return f"{s}–次日{e}"
  59. return f"{s}–{e}"
  60. def split_work_plans(raw: Optional[str]) -> List[str]:
  61. if not raw:
  62. return []
  63. return [p.strip() for p in str(raw).replace(",", ",").split(",") if p.strip()]
  64. def join_work_plans(values: Sequence[str]) -> Optional[str]:
  65. parts = [v.strip() for v in values if (v or "").strip()]
  66. return ",".join(parts) if parts else None
  67. def build_dict_value_from_label(label: str) -> str:
  68. text = (label or "").strip()
  69. parts: List[str] = []
  70. for ch in text:
  71. if "\u4e00" <= ch <= "\u9fff":
  72. pys = lazy_pinyin(ch, style=Style.NORMAL)
  73. if pys and pys[0]:
  74. parts.append(str(pys[0]).lower())
  75. elif ch.isalnum():
  76. parts.append(ch.lower())
  77. base = "".join(parts)[:32] or "item"
  78. return base
  79. def _pad_hhmm(h: int, m: int) -> str:
  80. return f"{h:02d}:{m:02d}"
  81. def parse_time_range_input(raw: str) -> Dict[str, Any]:
  82. """解析「09:00-17:00」「18:00-次日07:00」等,返回 start/end/crossDay/label/value。"""
  83. text = (raw or "").strip().replace("—", "-").replace("–", "-").replace("~", "-")
  84. text = re.sub(r"\s+", "", text)
  85. if not text:
  86. raise DutyBizError("时段不能为空")
  87. m = re.match(
  88. r"^(\d{1,2}):(\d{2})[-至到](次日)?(\d{1,2}):(\d{2})$",
  89. text,
  90. )
  91. if not m:
  92. raise DutyBizError("时段格式须为 09:00-17:00 或 18:00-次日07:00")
  93. sh, sm, cross_flag, eh, em = m.groups()
  94. try:
  95. start_h, start_m = int(sh), int(sm)
  96. end_h, end_m = int(eh), int(em)
  97. except ValueError as exc:
  98. raise DutyBizError("时段格式须为 09:00-17:00 或 18:00-次日07:00") from exc
  99. if not (0 <= start_h <= 23 and 0 <= start_m <= 59 and 0 <= end_h <= 23 and 0 <= end_m <= 59):
  100. raise DutyBizError("时段格式须为 09:00-17:00 或 18:00-次日07:00")
  101. start = _pad_hhmm(start_h, start_m)
  102. end = _pad_hhmm(end_h, end_m)
  103. cross = 1 if cross_flag or end < start else 0
  104. label = f"{start}–次日{end}" if cross else f"{start}–{end}"
  105. value = f"{start}|{end}|{cross}"
  106. return {
  107. "startTime": start,
  108. "endTime": end,
  109. "crossDay": bool(cross),
  110. "label": label,
  111. "value": value,
  112. }
  113. def ensure_dict_data(
  114. db: Session,
  115. *,
  116. tenant_no: str,
  117. dict_type: str,
  118. label: str,
  119. user_id: str = "",
  120. preferred_value: Optional[str] = None,
  121. ) -> Dict[str, str]:
  122. label = (label or "").strip()
  123. if not label:
  124. raise DutyBizError("字典标签不能为空")
  125. if dict_type not in (DICT_SHIFT_TYPE, DICT_WORK_PLAN, DICT_TIME_RANGE):
  126. raise DutyBizError("不支持的字典类型")
  127. preferred = (preferred_value or "").strip() or None
  128. if dict_type == DICT_TIME_RANGE:
  129. parsed = parse_time_range_input(label)
  130. label = parsed["label"]
  131. preferred = preferred or parsed["value"]
  132. existing_label = (
  133. db.query(SysDictData)
  134. .filter(
  135. SysDictData.tenant_no == tenant_no,
  136. SysDictData.dict_type == dict_type,
  137. SysDictData.dict_label == label,
  138. SysDictData.del_flag == 0,
  139. )
  140. .first()
  141. )
  142. if existing_label:
  143. return {"label": existing_label.dict_label, "value": existing_label.dict_value}
  144. if preferred:
  145. by_value = (
  146. db.query(SysDictData)
  147. .filter(
  148. SysDictData.tenant_no == tenant_no,
  149. SysDictData.dict_type == dict_type,
  150. SysDictData.dict_value == preferred,
  151. SysDictData.del_flag == 0,
  152. )
  153. .first()
  154. )
  155. if by_value:
  156. return {"label": by_value.dict_label, "value": by_value.dict_value}
  157. value = preferred or build_dict_value_from_label(label)
  158. for _ in range(8):
  159. clash = (
  160. db.query(SysDictData)
  161. .filter(
  162. SysDictData.tenant_no == tenant_no,
  163. SysDictData.dict_type == dict_type,
  164. SysDictData.dict_value == value,
  165. SysDictData.del_flag == 0,
  166. )
  167. .first()
  168. )
  169. if not clash:
  170. break
  171. value = f"{value[:27]}_{secrets.token_hex(2)}"
  172. else:
  173. raise DutyBizError("无法生成唯一字典值")
  174. obj = SysDictData(
  175. id=generate_uuid(),
  176. dict_label=label,
  177. dict_value=value,
  178. dict_type=dict_type,
  179. tag_type="",
  180. tenant_no=tenant_no,
  181. order_num=0,
  182. remark=None,
  183. create_by=user_id or None,
  184. create_dept_id="",
  185. create_time=datetime.now(),
  186. update_by=user_id or None,
  187. update_time=datetime.now(),
  188. del_flag=0,
  189. )
  190. db.add(obj)
  191. db.flush()
  192. return {"label": obj.dict_label, "value": obj.dict_value}
  193. def _load_dict_label_map(db: Session, tenant_no: str, dict_type: str) -> Dict[str, str]:
  194. rows = (
  195. db.query(SysDictData)
  196. .filter(
  197. SysDictData.tenant_no == tenant_no,
  198. SysDictData.dict_type == dict_type,
  199. SysDictData.del_flag == 0,
  200. )
  201. .all()
  202. )
  203. return {r.dict_value: r.dict_label for r in rows}
  204. def normalize_person_name(name: str) -> str:
  205. raw = (name or "").strip()
  206. if not raw:
  207. raise DutyBizError("姓名不能为空")
  208. if len(raw) > PERSON_NAME_MAX_LEN:
  209. raise DutyBizError(f"姓名不能超过{PERSON_NAME_MAX_LEN}个字符")
  210. return raw
  211. def new_access_token() -> str:
  212. return secrets.token_urlsafe(24)[:64]
  213. def build_public_url(token: str) -> str:
  214. origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
  215. return f"{origin}/#/public/duty/{token}"
  216. def _assignment_dict(row: DutyAssignment) -> Dict[str, Any]:
  217. return {
  218. "id": row.id,
  219. "source": row.source,
  220. "userId": row.user_id,
  221. "personName": row.person_name,
  222. }
  223. def _slot_dict(
  224. slot: DutySlot,
  225. assignments: Sequence[DutyAssignment],
  226. *,
  227. shift_labels: Dict[str, str],
  228. work_plan_labels: Dict[str, str],
  229. ) -> Dict[str, Any]:
  230. active = [a for a in assignments if a.del_flag == 0]
  231. plans = split_work_plans(getattr(slot, "work_plans", None))
  232. cross = bool(getattr(slot, "cross_day", 0) or 0)
  233. start = getattr(slot, "start_time", None)
  234. end = getattr(slot, "end_time", None)
  235. return {
  236. "id": slot.id,
  237. "shiftType": slot.shift_type,
  238. "shiftLabel": shift_labels.get(slot.shift_type)
  239. or SHIFT_LABELS.get(slot.shift_type, slot.shift_type),
  240. "capacity": slot.capacity,
  241. "taken": len(active),
  242. "workPlans": plans,
  243. "workPlanLabels": [work_plan_labels.get(v, v) for v in plans],
  244. "remark": (slot.remark or "").strip() or None,
  245. "startTime": format_hhmm(start),
  246. "endTime": format_hhmm(end),
  247. "crossDay": cross,
  248. "timeLabel": format_time_label(start, end, cross),
  249. "assignments": [_assignment_dict(a) for a in active],
  250. }
  251. def _day_dict(
  252. day: DutyDay,
  253. slots: Sequence[DutySlot],
  254. assignments_by_slot: Dict[str, List[DutyAssignment]],
  255. *,
  256. shift_labels: Dict[str, str],
  257. work_plan_labels: Dict[str, str],
  258. ) -> Dict[str, Any]:
  259. slot_payload = [
  260. _slot_dict(
  261. s,
  262. assignments_by_slot.get(s.id, []),
  263. shift_labels=shift_labels,
  264. work_plan_labels=work_plan_labels,
  265. )
  266. for s in sorted(slots, key=lambda x: (x.order_num or 0, x.shift_type or ""))
  267. if s.del_flag == 0
  268. ]
  269. return {
  270. "id": day.id,
  271. "dutyDate": day.duty_date.isoformat() if day.duty_date else None,
  272. "linkStatus": day.link_status,
  273. "accessToken": day.access_token,
  274. "publicUrl": build_public_url(day.access_token),
  275. "slots": slot_payload,
  276. }
  277. def _load_day_by_date(db: Session, tenant_no: str, duty_date: date) -> Optional[DutyDay]:
  278. return (
  279. db.query(DutyDay)
  280. .filter(
  281. DutyDay.tenant_no == tenant_no,
  282. DutyDay.duty_date == duty_date,
  283. DutyDay.del_flag == 0,
  284. )
  285. .first()
  286. )
  287. def _load_day_by_id(db: Session, tenant_no: str, day_id: str) -> Optional[DutyDay]:
  288. return (
  289. db.query(DutyDay)
  290. .filter(DutyDay.id == day_id, DutyDay.tenant_no == tenant_no, DutyDay.del_flag == 0)
  291. .first()
  292. )
  293. def _load_slots(db: Session, day_id: str) -> List[DutySlot]:
  294. return (
  295. db.query(DutySlot)
  296. .filter(DutySlot.duty_day_id == day_id, DutySlot.del_flag == 0)
  297. .all()
  298. )
  299. def _load_all_slots(db: Session, day_id: str) -> List[DutySlot]:
  300. return db.query(DutySlot).filter(DutySlot.duty_day_id == day_id).all()
  301. def _tombstone_shift_type(shift_type: str, slot_id: str) -> str:
  302. """改写已软删行的 shift_type,释放 uk_day_shift(day, type, del_flag=1)。"""
  303. suffix = (slot_id or "x")[-8:]
  304. base = (shift_type or "shift")[:22]
  305. return f"{base}~{suffix}"
  306. def _free_soft_deleted_uk(
  307. db: Session,
  308. day_id: str,
  309. shift_type: str,
  310. keep_slot_id: str,
  311. now: datetime,
  312. user_id: str,
  313. ) -> None:
  314. """同一天同班次若已有 del_flag=1 墓碑,先改写其 shift_type,避免再次软删撞 uk。"""
  315. rows = (
  316. db.query(DutySlot)
  317. .filter(
  318. DutySlot.duty_day_id == day_id,
  319. DutySlot.shift_type == shift_type,
  320. DutySlot.del_flag == 1,
  321. DutySlot.id != keep_slot_id,
  322. )
  323. .all()
  324. )
  325. for row in rows:
  326. row.shift_type = _tombstone_shift_type(shift_type, row.id)
  327. row.update_by = user_id or None
  328. row.update_time = now
  329. def _retire_slot(db: Session, slot: DutySlot, user_id: str, now: datetime) -> None:
  330. _free_soft_deleted_uk(db, slot.duty_day_id, slot.shift_type, slot.id, now, user_id)
  331. db.flush()
  332. slot.del_flag = 1
  333. slot.update_by = user_id or None
  334. slot.update_time = now
  335. def _load_assignments_for_slots(
  336. db: Session, slot_ids: Sequence[str]
  337. ) -> Dict[str, List[DutyAssignment]]:
  338. if not slot_ids:
  339. return {}
  340. rows = (
  341. db.query(DutyAssignment)
  342. .filter(DutyAssignment.duty_slot_id.in_(list(slot_ids)), DutyAssignment.del_flag == 0)
  343. .all()
  344. )
  345. out: Dict[str, List[DutyAssignment]] = {sid: [] for sid in slot_ids}
  346. for row in rows:
  347. out.setdefault(row.duty_slot_id, []).append(row)
  348. return out
  349. def _serialize_day(db: Session, day: DutyDay) -> Dict[str, Any]:
  350. slots = _load_slots(db, day.id)
  351. assigns = _load_assignments_for_slots(db, [s.id for s in slots])
  352. shift_labels = _load_dict_label_map(db, day.tenant_no, DICT_SHIFT_TYPE)
  353. work_plan_labels = _load_dict_label_map(db, day.tenant_no, DICT_WORK_PLAN)
  354. return _day_dict(
  355. day,
  356. slots,
  357. assigns,
  358. shift_labels=shift_labels,
  359. work_plan_labels=work_plan_labels,
  360. )
  361. def empty_day_skeleton(duty_date: date) -> Dict[str, Any]:
  362. return {
  363. "id": None,
  364. "dutyDate": duty_date.isoformat(),
  365. "linkStatus": LINK_OPEN,
  366. "accessToken": None,
  367. "publicUrl": None,
  368. "slots": [],
  369. }
  370. def get_month_summary(db: Session, tenant_no: str, year: int, month: int) -> List[Dict[str, Any]]:
  371. last_day = calendar.monthrange(year, month)[1]
  372. start = date(year, month, 1)
  373. end = date(year, month, last_day)
  374. days = (
  375. db.query(DutyDay)
  376. .filter(
  377. DutyDay.tenant_no == tenant_no,
  378. DutyDay.del_flag == 0,
  379. DutyDay.duty_date >= start,
  380. DutyDay.duty_date <= end,
  381. )
  382. .order_by(DutyDay.duty_date.asc())
  383. .all()
  384. )
  385. result = []
  386. for day in days:
  387. payload = _serialize_day(db, day)
  388. # 月历摘要可精简 assignments 人名
  389. result.append(payload)
  390. return result
  391. def get_day_detail(db: Session, tenant_no: str, duty_date: date) -> Dict[str, Any]:
  392. day = _load_day_by_date(db, tenant_no, duty_date)
  393. if not day:
  394. return empty_day_skeleton(duty_date)
  395. return _serialize_day(db, day)
  396. def soft_delete_assignment(
  397. db: Session, tenant_no: str, assignment_id: str, operator_id: str = ""
  398. ) -> None:
  399. row = (
  400. db.query(DutyAssignment)
  401. .filter(
  402. DutyAssignment.id == assignment_id,
  403. DutyAssignment.tenant_no == tenant_no,
  404. DutyAssignment.del_flag == 0,
  405. )
  406. .first()
  407. )
  408. if not row:
  409. raise DutyBizError("报名记录不存在")
  410. row.del_flag = 1
  411. row.update_by = operator_id or None
  412. row.update_time = datetime.now()
  413. db.commit()
  414. def set_link_status(
  415. db: Session, tenant_no: str, day_id: str, status: str, operator_id: str = ""
  416. ) -> Dict[str, Any]:
  417. if status not in (LINK_OPEN, LINK_CLOSED):
  418. raise DutyBizError("链接状态无效")
  419. day = _load_day_by_id(db, tenant_no, day_id)
  420. if not day:
  421. raise DutyBizError("值班日不存在")
  422. day.link_status = status
  423. day.update_by = operator_id or None
  424. day.update_time = datetime.now()
  425. db.commit()
  426. db.refresh(day)
  427. return _serialize_day(db, day)
  428. def _count_active_assignments(db: Session, slot_id: str) -> int:
  429. return (
  430. db.query(DutyAssignment)
  431. .filter(DutyAssignment.duty_slot_id == slot_id, DutyAssignment.del_flag == 0)
  432. .count()
  433. )
  434. def _sync_assigned_people(db, tenant_no, slot, items, existing, user_id, dept_id, now):
  435. if items is None:
  436. return
  437. assigned = [a for a in existing if a.source == SOURCE_ASSIGN]
  438. by_id = {a.id: a for a in assigned}
  439. old_users = {a.user_id for a in assigned if a.user_id}
  440. seen_ids, seen_people = set(), set()
  441. planned = []
  442. for item in items:
  443. if item.source != SOURCE_ASSIGN:
  444. raise DutyBizError("抢班记录须通过抢班流程操作,不能作为指定人员保存")
  445. uid = (item.userId or "").strip() or None
  446. name = normalize_person_name((item.personName or "").strip())
  447. if not name:
  448. raise DutyBizError("指定员工须有姓名")
  449. key = ("user", uid) if uid else ("name", name)
  450. if key in seen_people:
  451. raise DutyBizError("同一班次不能重复指定同一人员")
  452. seen_people.add(key)
  453. row = None
  454. if item.id:
  455. row = by_id.get(item.id)
  456. if row is None or item.id in seen_ids:
  457. raise DutyBizError("指定人员记录无效、重复或不属于当前班次")
  458. else:
  459. matches = [a for a in assigned if
  460. ((a.user_id == uid) if uid else (not a.user_id and a.person_name == name))]
  461. if len(matches) > 1:
  462. raise DutyBizError("历史指定人员重复,请刷新并按记录 ID 保存")
  463. row = matches[0] if matches else None
  464. if row is not None:
  465. if row.id in seen_ids:
  466. raise DutyBizError("指定人员记录不能重复提交")
  467. seen_ids.add(row.id)
  468. if uid and uid not in old_users:
  469. assert_user_is_duty_staff(db, tenant_no, uid)
  470. if any(a.source == SOURCE_GRAB and (a.user_id == uid if uid else a.person_name == name) for a in existing):
  471. raise DutyBizError("该人员已抢班,不能重复指定")
  472. planned.append((row, uid, name))
  473. for row in assigned:
  474. if row.id not in seen_ids:
  475. row.del_flag = 1
  476. row.update_by = user_id
  477. row.update_time = now
  478. for row, uid, name in planned:
  479. if row is None:
  480. db.add(DutyAssignment(
  481. id=generate_id(), duty_slot_id=slot.id, source=SOURCE_ASSIGN,
  482. user_id=uid, person_name=name, tenant_no=tenant_no,
  483. create_by=user_id, create_dept_id=dept_id, create_time=now,
  484. update_by=user_id, update_time=now, del_flag=0,
  485. ))
  486. elif row.user_id != uid or row.person_name != name:
  487. row.user_id = uid
  488. row.person_name = name
  489. row.update_by = user_id
  490. row.update_time = now
  491. def save_day(
  492. db: Session,
  493. tenant_no: str,
  494. user: Dict[str, Any],
  495. body: DutyDaySaveBody,
  496. ) -> Dict[str, Any]:
  497. user_id = (user.get("user_id") or user.get("id") or "") or ""
  498. dept_id = (user.get("region_id") or user.get("create_dept_id") or "") or ""
  499. now = datetime.now()
  500. # 校验:每个 slot 提交的指定人数不超过 capacity
  501. seen_types = set()
  502. for slot_in in body.slots:
  503. if slot_in.shiftType in seen_types:
  504. raise DutyBizError("同一天不能重复配置相同班次")
  505. seen_types.add(slot_in.shiftType)
  506. assign_count = len(slot_in.assignments or [])
  507. if assign_count > slot_in.capacity:
  508. label = SHIFT_LABELS.get(slot_in.shiftType, slot_in.shiftType)
  509. raise DutyBizError(f"{label}报名人数不能超过名额")
  510. day = _load_day_by_date(db, tenant_no, body.dutyDate)
  511. if not day:
  512. day = DutyDay(
  513. id=generate_id(),
  514. duty_date=body.dutyDate,
  515. access_token=new_access_token(),
  516. link_status=body.linkStatus or LINK_OPEN,
  517. tenant_no=tenant_no,
  518. create_by=user_id,
  519. create_dept_id=dept_id,
  520. create_time=now,
  521. update_by=user_id,
  522. update_time=now,
  523. del_flag=0,
  524. )
  525. db.add(day)
  526. db.flush()
  527. else:
  528. if body.linkStatus in (LINK_OPEN, LINK_CLOSED):
  529. day.link_status = body.linkStatus
  530. day.update_by = user_id
  531. day.update_time = now
  532. existing_slots = _load_slots(db, day.id)
  533. all_slots = _load_all_slots(db, day.id)
  534. existing_by_type = {s.shift_type: s for s in existing_slots}
  535. soft_by_type = {
  536. s.shift_type: s
  537. for s in all_slots
  538. if s.del_flag == 1 and s.shift_type not in existing_by_type
  539. }
  540. incoming_types = {s.shiftType for s in body.slots}
  541. # 停用未再提交且已有报名的班次:禁止
  542. for shift_type, slot in existing_by_type.items():
  543. if shift_type not in incoming_types:
  544. taken = _count_active_assignments(db, slot.id)
  545. if taken > 0:
  546. label = SHIFT_LABELS.get(shift_type, shift_type)
  547. raise DutyBizError(f"{label}仍有报名,请先清空后再移除该班次")
  548. _retire_slot(db, slot, user_id, now)
  549. order = 0
  550. for slot_in in body.slots:
  551. order += 1
  552. slot = existing_by_type.get(slot_in.shiftType)
  553. revived = False
  554. if slot is None:
  555. soft = soft_by_type.get(slot_in.shiftType)
  556. if soft is not None:
  557. slot = soft
  558. slot.del_flag = 0
  559. revived = True
  560. slot_remark = (slot_in.remark or "").strip() or None
  561. plans_raw = list(slot_in.workPlans or [])
  562. for v in plans_raw:
  563. v = (v or "").strip()
  564. if not v:
  565. continue
  566. row = (
  567. db.query(SysDictData)
  568. .filter(
  569. SysDictData.tenant_no == tenant_no,
  570. SysDictData.dict_type == DICT_WORK_PLAN,
  571. SysDictData.dict_value == v,
  572. SysDictData.del_flag == 0,
  573. )
  574. .first()
  575. )
  576. if not row:
  577. ensure_dict_data(
  578. db,
  579. tenant_no=tenant_no,
  580. dict_type=DICT_WORK_PLAN,
  581. label=v,
  582. preferred_value=v,
  583. user_id=user_id,
  584. )
  585. slot_work_plans = join_work_plans(plans_raw)
  586. if slot_in.shiftType not in SHIFT_LABELS:
  587. row = (
  588. db.query(SysDictData)
  589. .filter(
  590. SysDictData.tenant_no == tenant_no,
  591. SysDictData.dict_type == DICT_SHIFT_TYPE,
  592. SysDictData.dict_value == slot_in.shiftType,
  593. SysDictData.del_flag == 0,
  594. )
  595. .first()
  596. )
  597. if not row:
  598. ensure_dict_data(
  599. db,
  600. tenant_no=tenant_no,
  601. dict_type=DICT_SHIFT_TYPE,
  602. label=SHIFT_LABELS.get(slot_in.shiftType, slot_in.shiftType),
  603. preferred_value=slot_in.shiftType,
  604. user_id=user_id,
  605. )
  606. start_t = parse_hhmm(slot_in.startTime)
  607. end_t = parse_hhmm(slot_in.endTime)
  608. if (start_t is None) ^ (end_t is None):
  609. raise DutyBizError("值班时间须同时填写开始与结束,或全部留空")
  610. cross = 1 if slot_in.crossDay else 0
  611. existing_assigns = []
  612. if slot is None:
  613. slot = DutySlot(
  614. id=generate_id(),
  615. duty_day_id=day.id,
  616. shift_type=slot_in.shiftType,
  617. capacity=slot_in.capacity,
  618. work_plans=slot_work_plans,
  619. start_time=start_t,
  620. end_time=end_t,
  621. cross_day=cross,
  622. remark=slot_remark,
  623. tenant_no=tenant_no,
  624. order_num=order,
  625. create_by=user_id,
  626. create_dept_id=dept_id,
  627. create_time=now,
  628. update_by=user_id,
  629. update_time=now,
  630. del_flag=0,
  631. )
  632. db.add(slot)
  633. db.flush()
  634. else:
  635. # 抢班记录保留;指定名单未传时也保留。
  636. existing_assigns = (
  637. db.query(DutyAssignment)
  638. .filter(DutyAssignment.duty_slot_id == slot.id, DutyAssignment.tenant_no == tenant_no, DutyAssignment.del_flag == 0)
  639. .all()
  640. )
  641. grab_rows = [a for a in existing_assigns if a.source == SOURCE_GRAB]
  642. new_assign_count = (len(slot_in.assignments) if slot_in.assignments is not None
  643. else sum(a.source == SOURCE_ASSIGN for a in existing_assigns))
  644. final_count = len(grab_rows) + new_assign_count
  645. if final_count > slot_in.capacity:
  646. label = SHIFT_LABELS.get(slot_in.shiftType, slot_in.shiftType)
  647. raise DutyBizError(
  648. f"{label}当前已有 {len(grab_rows)} 人抢班 + {new_assign_count} 人指定,"
  649. f"超过名额 {slot_in.capacity},请先删人再缩容"
  650. )
  651. slot.capacity = slot_in.capacity
  652. slot.work_plans = slot_work_plans
  653. slot.start_time = start_t
  654. slot.end_time = end_t
  655. slot.cross_day = cross
  656. slot.remark = slot_remark
  657. slot.order_num = order
  658. slot.update_by = user_id
  659. slot.update_time = now
  660. if revived:
  661. slot.del_flag = 0
  662. _sync_assigned_people(
  663. db, tenant_no, slot, slot_in.assignments, existing_assigns,
  664. user_id, dept_id, now,
  665. )
  666. # SessionLocal 禁用 autoflush,容量校验须看到本次增删后的名单。
  667. db.flush()
  668. # 新建 slot 时也要校验人数
  669. if slot.id and _count_active_assignments(db, slot.id) > slot.capacity:
  670. label = SHIFT_LABELS.get(slot.shift_type, slot.shift_type)
  671. raise DutyBizError(f"{label}报名人数不能超过名额")
  672. db.commit()
  673. db.refresh(day)
  674. return _serialize_day(db, day)
  675. def resolve_duty_staff_role_id(db: Session, tenant_no: str) -> Optional[str]:
  676. """按固定 id 查找「值班人员」角色;找不到再按角色名兜底。"""
  677. role = (
  678. db.query(SysRole)
  679. .filter(
  680. SysRole.id == DUTY_STAFF_ROLE_ID,
  681. SysRole.tenant_no == tenant_no,
  682. SysRole.del_flag == 0,
  683. )
  684. .first()
  685. )
  686. if role:
  687. return role.id
  688. role = (
  689. db.query(SysRole)
  690. .filter(
  691. SysRole.role_name == DUTY_STAFF_ROLE_NAME,
  692. SysRole.tenant_no == tenant_no,
  693. SysRole.del_flag == 0,
  694. SysRole.status == "1",
  695. )
  696. .first()
  697. )
  698. return role.id if role else None
  699. def list_duty_staff_users(db: Session, tenant_no: str) -> List[Dict[str, str]]:
  700. """本租户启用且绑定「值班人员」角色的用户(公开页 users / 管理端 options)。"""
  701. role_id = resolve_duty_staff_role_id(db, tenant_no)
  702. if not role_id:
  703. return []
  704. rows = (
  705. db.query(SysUser)
  706. .join(
  707. SysUserRole,
  708. and_(
  709. SysUserRole.user_id == SysUser.id,
  710. SysUserRole.role_id == role_id,
  711. SysUserRole.del_flag == 0,
  712. SysUserRole.tenant_no == tenant_no,
  713. ),
  714. )
  715. .filter(
  716. SysUser.tenant_no == tenant_no,
  717. SysUser.del_flag == 0,
  718. SysUser.status == "1",
  719. )
  720. .order_by(SysUser.nick_name.asc(), SysUser.user_no.asc())
  721. .all()
  722. )
  723. out: List[Dict[str, str]] = []
  724. seen = set()
  725. for r in rows:
  726. if r.id in seen:
  727. continue
  728. seen.add(r.id)
  729. label = (r.nick_name or "").strip() or (r.user_no or "").strip()
  730. if not label:
  731. continue
  732. out.append({"id": r.id, "label": label})
  733. return out
  734. def assert_user_is_duty_staff(db: Session, tenant_no: str, user_id: str) -> None:
  735. uid = (user_id or "").strip()
  736. if not uid:
  737. raise DutyBizError("用户无效或不具备值班人员角色")
  738. user = (
  739. db.query(SysUser)
  740. .filter(
  741. SysUser.id == uid,
  742. SysUser.tenant_no == tenant_no,
  743. SysUser.del_flag == 0,
  744. SysUser.status == "1",
  745. )
  746. .first()
  747. )
  748. if not user:
  749. raise DutyBizError("用户无效或不具备值班人员角色")
  750. role_id = resolve_duty_staff_role_id(db, tenant_no)
  751. if not role_id:
  752. raise DutyBizError("未配置「值班人员」角色,请联系管理员")
  753. link = (
  754. db.query(SysUserRole)
  755. .filter(
  756. SysUserRole.user_id == uid,
  757. SysUserRole.role_id == role_id,
  758. SysUserRole.tenant_no == tenant_no,
  759. SysUserRole.del_flag == 0,
  760. )
  761. .first()
  762. )
  763. if not link:
  764. raise DutyBizError("用户无效或不具备值班人员角色")
  765. def _list_public_users(db: Session, tenant_no: str) -> List[Dict[str, str]]:
  766. return list_duty_staff_users(db, tenant_no)
  767. def _resolve_grab_user(db: Session, tenant_no: str, user_id: str) -> SysUser:
  768. uid = (user_id or "").strip()
  769. if not uid:
  770. raise DutyBizError("请选择有效用户")
  771. user = (
  772. db.query(SysUser)
  773. .filter(
  774. SysUser.id == uid,
  775. SysUser.tenant_no == tenant_no,
  776. SysUser.del_flag == 0,
  777. SysUser.status == "1",
  778. )
  779. .first()
  780. )
  781. if not user:
  782. raise DutyBizError("用户无效或不存在")
  783. assert_user_is_duty_staff(db, tenant_no, user.id)
  784. return user
  785. def get_public_page(db: Session, token: str) -> Dict[str, Any]:
  786. token = (token or "").strip()
  787. if not token:
  788. raise DutyBizError("链接无效或已失效")
  789. day = (
  790. db.query(DutyDay)
  791. .filter(DutyDay.access_token == token, DutyDay.del_flag == 0)
  792. .first()
  793. )
  794. if not day:
  795. raise DutyBizError("链接无效或已失效")
  796. payload = _serialize_day(db, day)
  797. return {
  798. "dutyDate": payload["dutyDate"],
  799. "linkStatus": payload["linkStatus"],
  800. "slots": payload["slots"],
  801. "closed": day.link_status == LINK_CLOSED,
  802. "users": _list_public_users(db, day.tenant_no),
  803. }
  804. def grab(db: Session, token: str, shift_type: str, user_id: str) -> Dict[str, Any]:
  805. shift_type = (shift_type or "").strip()
  806. if not shift_type:
  807. raise DutyBizError("班次类型无效")
  808. day = (
  809. db.query(DutyDay)
  810. .filter(DutyDay.access_token == token, DutyDay.del_flag == 0)
  811. .with_for_update()
  812. .first()
  813. )
  814. if not day:
  815. raise DutyBizError("链接无效或已失效")
  816. if day.link_status == LINK_CLOSED:
  817. raise DutyBizError("报名已关闭")
  818. user = _resolve_grab_user(db, day.tenant_no, user_id)
  819. name = normalize_person_name((user.nick_name or "").strip() or (user.user_no or "").strip())
  820. slot = (
  821. db.query(DutySlot)
  822. .filter(
  823. DutySlot.duty_day_id == day.id,
  824. DutySlot.shift_type == shift_type,
  825. DutySlot.del_flag == 0,
  826. )
  827. .with_for_update()
  828. .first()
  829. )
  830. if not slot:
  831. raise DutyBizError("该班次不存在")
  832. dup = (
  833. db.query(DutyAssignment)
  834. .filter(
  835. DutyAssignment.duty_slot_id == slot.id,
  836. DutyAssignment.user_id == user.id,
  837. DutyAssignment.del_flag == 0,
  838. )
  839. .first()
  840. )
  841. if dup:
  842. raise DutyBizError("您已报名该班次")
  843. taken = _count_active_assignments(db, slot.id)
  844. if taken >= slot.capacity:
  845. raise DutyBizError("名额已满")
  846. now = datetime.now()
  847. row = DutyAssignment(
  848. id=generate_id(),
  849. duty_slot_id=slot.id,
  850. source=SOURCE_GRAB,
  851. user_id=user.id,
  852. person_name=name,
  853. tenant_no=day.tenant_no,
  854. create_by=user.id,
  855. create_dept_id="",
  856. create_time=now,
  857. update_by=user.id,
  858. update_time=now,
  859. del_flag=0,
  860. )
  861. db.add(row)
  862. db.commit()
  863. shift_labels = _load_dict_label_map(db, day.tenant_no, DICT_SHIFT_TYPE)
  864. return {
  865. "dutyDate": day.duty_date.isoformat(),
  866. "shiftType": shift_type,
  867. "shiftLabel": shift_labels.get(shift_type)
  868. or SHIFT_LABELS.get(shift_type, shift_type),
  869. "personName": name,
  870. "userId": user.id,
  871. "status": "ok",
  872. }
  873. # re-export for tests
  874. __all__ = [
  875. "SHIFT_LABELS",
  876. "DutyBizError",
  877. "parse_hhmm",
  878. "parse_time_range_input",
  879. "format_hhmm",
  880. "format_time_label",
  881. "split_work_plans",
  882. "join_work_plans",
  883. "build_dict_value_from_label",
  884. "ensure_dict_data",
  885. "normalize_person_name",
  886. "new_access_token",
  887. "build_public_url",
  888. "get_month_summary",
  889. "get_day_detail",
  890. "empty_day_skeleton",
  891. "save_day",
  892. "set_link_status",
  893. "soft_delete_assignment",
  894. "get_public_page",
  895. "grab",
  896. "resolve_duty_staff_role_id",
  897. "list_duty_staff_users",
  898. "assert_user_is_duty_staff",
  899. ]