test_duty_schedule.py 29 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007
  1. """值班排班服务单测"""
  2. from datetime import date
  3. from types import SimpleNamespace
  4. from unittest.mock import MagicMock
  5. import pytest
  6. from app.models.duty_models import (
  7. DUTY_STAFF_ROLE_ID,
  8. DUTY_STAFF_ROLE_NAME,
  9. LINK_CLOSED,
  10. LINK_OPEN,
  11. SHIFT_EVENING,
  12. SHIFT_LABELS,
  13. SHIFT_MORNING,
  14. SOURCE_ASSIGN,
  15. SOURCE_GRAB,
  16. )
  17. from app.schemas.duty import DutyAssignmentIn, DutyDaySaveBody, DutySlotSaveIn
  18. from app.models.models import SysDictData
  19. from app.services.duty_schedule import (
  20. DutyBizError,
  21. assert_user_is_duty_staff,
  22. build_dict_value_from_label,
  23. ensure_dict_data,
  24. format_time_label,
  25. grab,
  26. join_work_plans,
  27. list_duty_staff_users,
  28. normalize_person_name,
  29. parse_hhmm,
  30. resolve_duty_staff_role_id,
  31. save_day,
  32. split_work_plans,
  33. )
  34. def test_parse_hhmm():
  35. assert parse_hhmm("08:00").hour == 8
  36. assert parse_hhmm("18:30").minute == 30
  37. assert parse_hhmm(None) is None
  38. assert parse_hhmm("") is None
  39. def test_parse_hhmm_invalid_raises():
  40. with pytest.raises(DutyBizError, match="时间格式"):
  41. parse_hhmm("08:xx")
  42. def test_parse_time_range_input_day():
  43. from app.services.duty_schedule import parse_time_range_input
  44. r = parse_time_range_input("09:00-17:00")
  45. assert r["startTime"] == "09:00"
  46. assert r["endTime"] == "17:00"
  47. assert r["crossDay"] is False
  48. assert r["value"] == "09:00|17:00|0"
  49. assert r["label"] == "09:00–17:00"
  50. def test_parse_time_range_input_cross_day():
  51. from app.services.duty_schedule import parse_time_range_input
  52. r = parse_time_range_input("18:00-次日07:00")
  53. assert r["startTime"] == "18:00"
  54. assert r["endTime"] == "07:00"
  55. assert r["crossDay"] is True
  56. assert r["value"] == "18:00|07:00|1"
  57. assert "次日" in r["label"]
  58. def test_format_time_label_same_day():
  59. assert format_time_label(parse_hhmm("08:00"), parse_hhmm("18:00"), False) == "08:00–18:00"
  60. def test_format_time_label_cross_day():
  61. assert format_time_label(parse_hhmm("18:00"), parse_hhmm("07:00"), True) == "18:00–次日07:00"
  62. def test_format_time_label_empty():
  63. assert format_time_label(None, None, False) is None
  64. def test_work_plans_join_split():
  65. assert join_work_plans(["peisuan", "zhuangxie"]) == "peisuan,zhuangxie"
  66. assert split_work_plans("peisuan,zhuangxie") == ["peisuan", "zhuangxie"]
  67. assert split_work_plans(None) == []
  68. def test_build_dict_value_from_label():
  69. assert build_dict_value_from_label("配酸") == "peisuan"
  70. assert build_dict_value_from_label("装卸") == "zhuangxie"
  71. assert build_dict_value_from_label(" ") == "item"
  72. def test_ensure_dict_data_reuses_existing_label():
  73. db = MagicMock()
  74. existing = SimpleNamespace(dict_label="配酸", dict_value="peisuan")
  75. q = MagicMock()
  76. q.filter.return_value.first.return_value = existing
  77. db.query.return_value = q
  78. result = ensure_dict_data(
  79. db, tenant_no="t1", dict_type="duty_work_plan", label="配酸", user_id="u1"
  80. )
  81. assert result == {"label": "配酸", "value": "peisuan"}
  82. db.add.assert_not_called()
  83. def test_ensure_dict_data_inserts_new_without_commit():
  84. db = MagicMock()
  85. q = MagicMock()
  86. q.filter.return_value.first.side_effect = [None, None]
  87. db.query.return_value = q
  88. result = ensure_dict_data(
  89. db, tenant_no="t1", dict_type="duty_work_plan", label="巡检", user_id="u1"
  90. )
  91. assert result["label"] == "巡检"
  92. assert result["value"] == "xunjian"
  93. db.add.assert_called_once()
  94. db.flush.assert_called_once()
  95. db.commit.assert_not_called()
  96. added = db.add.call_args[0][0]
  97. assert isinstance(added, SysDictData)
  98. assert added.dict_type == "duty_work_plan"
  99. assert added.tenant_no == "t1"
  100. def test_normalize_person_name_ok():
  101. assert normalize_person_name(" 张三 ") == "张三"
  102. def test_normalize_person_name_empty():
  103. with pytest.raises(DutyBizError, match="姓名"):
  104. normalize_person_name(" ")
  105. def test_normalize_person_name_too_long():
  106. with pytest.raises(DutyBizError, match="姓名"):
  107. normalize_person_name("啊" * 21)
  108. def test_shift_labels():
  109. assert SHIFT_LABELS[SHIFT_MORNING] == "早班"
  110. def test_grab_rejects_when_full():
  111. db = MagicMock()
  112. day = SimpleNamespace(
  113. id="day1",
  114. tenant_no="t1",
  115. link_status=LINK_OPEN,
  116. duty_date=date(2026, 8, 20),
  117. access_token="tok",
  118. del_flag=0,
  119. )
  120. slot = SimpleNamespace(
  121. id="slot1",
  122. duty_day_id="day1",
  123. shift_type=SHIFT_EVENING,
  124. capacity=1,
  125. del_flag=0,
  126. )
  127. user = SimpleNamespace(
  128. id="u1",
  129. tenant_no="t1",
  130. nick_name="李四",
  131. user_no="lisi",
  132. status="1",
  133. del_flag=0,
  134. )
  135. day_q = MagicMock()
  136. day_q.filter.return_value.with_for_update.return_value.first.return_value = day
  137. slot_q = MagicMock()
  138. slot_q.filter.return_value.with_for_update.return_value.first.return_value = slot
  139. user_q = MagicMock()
  140. user_q.filter.return_value.first.return_value = user
  141. role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
  142. role_q = MagicMock()
  143. role_q.filter.return_value.first.return_value = role
  144. ur_q = MagicMock()
  145. ur_q.filter.return_value.first.return_value = SimpleNamespace(user_id="u1", role_id=DUTY_STAFF_ROLE_ID)
  146. def query_side_effect(model):
  147. name = getattr(model, "__name__", str(model))
  148. if name == "DutyDay":
  149. return day_q
  150. if name == "DutySlot":
  151. return slot_q
  152. if name == "SysUser":
  153. return user_q
  154. if name == "SysRole":
  155. return role_q
  156. if name == "SysUserRole":
  157. return ur_q
  158. # DutyAssignment count / duplicate
  159. aq = MagicMock()
  160. aq.filter.return_value.count.return_value = 1
  161. aq.filter.return_value.first.return_value = None
  162. return aq
  163. db.query.side_effect = query_side_effect
  164. with pytest.raises(DutyBizError, match="名额已满"):
  165. grab(db, "tok", SHIFT_EVENING, "u1")
  166. def test_grab_allows_custom_shift_type_when_slot_exists():
  167. """自定义 shift_type 只要当日存在 slot 即可,不必在 SHIFT_LABELS。"""
  168. db = MagicMock()
  169. day = SimpleNamespace(
  170. id="day1",
  171. tenant_no="t1",
  172. link_status=LINK_OPEN,
  173. duty_date=date(2026, 8, 20),
  174. access_token="tok",
  175. del_flag=0,
  176. )
  177. slot = SimpleNamespace(
  178. id="slot1",
  179. duty_day_id="day1",
  180. shift_type="zhongban",
  181. capacity=2,
  182. del_flag=0,
  183. )
  184. user = SimpleNamespace(
  185. id="u1",
  186. tenant_no="t1",
  187. nick_name="王五",
  188. user_no="wangwu",
  189. status="1",
  190. del_flag=0,
  191. )
  192. day_q = MagicMock()
  193. day_q.filter.return_value.with_for_update.return_value.first.return_value = day
  194. slot_q = MagicMock()
  195. slot_q.filter.return_value.with_for_update.return_value.first.return_value = slot
  196. user_q = MagicMock()
  197. user_q.filter.return_value.first.return_value = user
  198. role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
  199. role_q = MagicMock()
  200. role_q.filter.return_value.first.return_value = role
  201. ur_q = MagicMock()
  202. ur_q.filter.return_value.first.return_value = SimpleNamespace(
  203. user_id="u1", role_id=DUTY_STAFF_ROLE_ID
  204. )
  205. def query_side_effect(model):
  206. name = getattr(model, "__name__", str(model))
  207. if name == "DutyDay":
  208. return day_q
  209. if name == "DutySlot":
  210. return slot_q
  211. if name == "SysUser":
  212. return user_q
  213. if name == "SysRole":
  214. return role_q
  215. if name == "SysUserRole":
  216. return ur_q
  217. if name == "SysDictData":
  218. dq = MagicMock()
  219. dq.filter.return_value.all.return_value = []
  220. dq.filter.return_value.first.return_value = None
  221. return dq
  222. aq = MagicMock()
  223. aq.filter.return_value.count.return_value = 0
  224. aq.filter.return_value.first.return_value = None
  225. return aq
  226. db.query.side_effect = query_side_effect
  227. result = grab(db, "tok", "zhongban", "u1")
  228. assert result["shiftType"] == "zhongban"
  229. assert result["status"] == "ok"
  230. def test_grab_rejects_when_closed():
  231. db = MagicMock()
  232. day = SimpleNamespace(
  233. id="day1",
  234. tenant_no="t1",
  235. link_status=LINK_CLOSED,
  236. duty_date=date(2026, 8, 20),
  237. access_token="tok",
  238. del_flag=0,
  239. )
  240. day_q = MagicMock()
  241. day_q.filter.return_value.with_for_update.return_value.first.return_value = day
  242. db.query.return_value = day_q
  243. with pytest.raises(DutyBizError, match="关闭"):
  244. grab(db, "tok", SHIFT_EVENING, "u1")
  245. def test_grab_rejects_invalid_user():
  246. db = MagicMock()
  247. day = SimpleNamespace(
  248. id="day1",
  249. tenant_no="t1",
  250. link_status=LINK_OPEN,
  251. duty_date=date(2026, 8, 20),
  252. access_token="tok",
  253. del_flag=0,
  254. )
  255. day_q = MagicMock()
  256. day_q.filter.return_value.with_for_update.return_value.first.return_value = day
  257. user_q = MagicMock()
  258. user_q.filter.return_value.first.return_value = None
  259. def query_side_effect(model):
  260. name = getattr(model, "__name__", str(model))
  261. if name == "DutyDay":
  262. return day_q
  263. if name == "SysUser":
  264. return user_q
  265. return MagicMock()
  266. db.query.side_effect = query_side_effect
  267. with pytest.raises(DutyBizError, match="用户|无效|不存在"):
  268. grab(db, "tok", SHIFT_EVENING, "bad-user")
  269. def test_grab_rejects_duplicate_same_shift():
  270. db = MagicMock()
  271. day = SimpleNamespace(
  272. id="day1",
  273. tenant_no="t1",
  274. link_status=LINK_OPEN,
  275. duty_date=date(2026, 8, 20),
  276. access_token="tok",
  277. del_flag=0,
  278. )
  279. slot = SimpleNamespace(
  280. id="slot1",
  281. duty_day_id="day1",
  282. shift_type=SHIFT_EVENING,
  283. capacity=2,
  284. del_flag=0,
  285. )
  286. user = SimpleNamespace(
  287. id="u1",
  288. tenant_no="t1",
  289. nick_name="李四",
  290. user_no="lisi",
  291. status="1",
  292. del_flag=0,
  293. )
  294. existing = SimpleNamespace(id="a1", user_id="u1", del_flag=0)
  295. day_q = MagicMock()
  296. day_q.filter.return_value.with_for_update.return_value.first.return_value = day
  297. slot_q = MagicMock()
  298. slot_q.filter.return_value.with_for_update.return_value.first.return_value = slot
  299. user_q = MagicMock()
  300. user_q.filter.return_value.first.return_value = user
  301. role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
  302. role_q = MagicMock()
  303. role_q.filter.return_value.first.return_value = role
  304. ur_q = MagicMock()
  305. ur_q.filter.return_value.first.return_value = SimpleNamespace(user_id="u1", role_id=DUTY_STAFF_ROLE_ID)
  306. assign_calls = {"n": 0}
  307. def query_side_effect(model):
  308. name = getattr(model, "__name__", str(model))
  309. if name == "DutyDay":
  310. return day_q
  311. if name == "DutySlot":
  312. return slot_q
  313. if name == "SysUser":
  314. return user_q
  315. if name == "SysRole":
  316. return role_q
  317. if name == "SysUserRole":
  318. return ur_q
  319. aq = MagicMock()
  320. # first: duplicate check .first(); later: count
  321. assign_calls["n"] += 1
  322. aq.filter.return_value.first.return_value = existing
  323. aq.filter.return_value.count.return_value = 1
  324. return aq
  325. db.query.side_effect = query_side_effect
  326. with pytest.raises(DutyBizError, match="已报名"):
  327. grab(db, "tok", SHIFT_EVENING, "u1")
  328. def test_save_day_revives_soft_deleted_slot_instead_of_insert():
  329. """同日同班次曾软删后再启用:应复活旧 slot,避免 uk_day_shift 堆积。"""
  330. db = MagicMock()
  331. day = SimpleNamespace(
  332. id="day1",
  333. duty_date=date(2026, 9, 14),
  334. access_token="tok",
  335. link_status=LINK_OPEN,
  336. tenant_no="t1",
  337. del_flag=0,
  338. update_by=None,
  339. update_time=None,
  340. )
  341. soft = SimpleNamespace(
  342. id="slot_soft",
  343. duty_day_id="day1",
  344. shift_type=SHIFT_MORNING,
  345. capacity=1,
  346. work_plans=None,
  347. start_time=None,
  348. end_time=None,
  349. cross_day=0,
  350. remark=None,
  351. order_num=1,
  352. del_flag=1,
  353. update_by=None,
  354. update_time=None,
  355. create_by=None,
  356. create_dept_id="",
  357. create_time=None,
  358. tenant_no="t1",
  359. )
  360. day_q = MagicMock()
  361. day_q.filter.return_value.first.return_value = day
  362. # _load_slots(活跃)空;_load_all_slots 含软删;序列化再查活跃
  363. slot_alls = [[], [soft], [soft]]
  364. slot_idx = {"i": 0}
  365. def query_side_effect(model):
  366. name = getattr(model, "__name__", "")
  367. if name == "DutyDay":
  368. return day_q
  369. if name == "DutySlot":
  370. m = MagicMock()
  371. def all_rows():
  372. i = slot_idx["i"]
  373. slot_idx["i"] += 1
  374. return slot_alls[i] if i < len(slot_alls) else []
  375. m.filter.return_value.all.side_effect = all_rows
  376. return m
  377. if name == "DutyAssignment":
  378. m = MagicMock()
  379. m.filter.return_value.count.return_value = 0
  380. m.filter.return_value.all.return_value = []
  381. return m
  382. if name == "SysDictData":
  383. m = MagicMock()
  384. m.filter.return_value.first.return_value = SimpleNamespace(
  385. dict_label="早班", dict_value=SHIFT_MORNING
  386. )
  387. return m
  388. return MagicMock()
  389. db.query.side_effect = query_side_effect
  390. body = DutyDaySaveBody(
  391. dutyDate=date(2026, 9, 14),
  392. slots=[DutySlotSaveIn(shiftType=SHIFT_MORNING, capacity=2, assignments=[])],
  393. )
  394. save_day(db, "t1", {"user_id": "op1"}, body)
  395. assert soft.del_flag == 0
  396. assert soft.capacity == 2
  397. added_slots = [
  398. c.args[0]
  399. for c in db.add.call_args_list
  400. if c.args and getattr(c.args[0], "shift_type", None) == SHIFT_MORNING
  401. ]
  402. assert added_slots == []
  403. def test_save_day_retire_frees_existing_soft_deleted_uk():
  404. """再次清空班次:若已有同类型 del_flag=1 墓碑,须先改写其 shift_type 再软删活跃行。"""
  405. db = MagicMock()
  406. day = SimpleNamespace(
  407. id="day1",
  408. duty_date=date(2026, 9, 14),
  409. access_token="tok",
  410. link_status=LINK_OPEN,
  411. tenant_no="t1",
  412. del_flag=0,
  413. update_by=None,
  414. update_time=None,
  415. )
  416. active = SimpleNamespace(
  417. id="slot_active",
  418. duty_day_id="day1",
  419. shift_type=SHIFT_MORNING,
  420. capacity=1,
  421. del_flag=0,
  422. order_num=1,
  423. update_by=None,
  424. update_time=None,
  425. )
  426. tombstone = SimpleNamespace(
  427. id="slot_old",
  428. duty_day_id="day1",
  429. shift_type=SHIFT_MORNING,
  430. capacity=1,
  431. del_flag=1,
  432. order_num=1,
  433. update_by=None,
  434. update_time=None,
  435. )
  436. day_q = MagicMock()
  437. day_q.filter.return_value.first.return_value = day
  438. slot_alls = [
  439. [active], # _load_slots
  440. [active, tombstone], # _load_all_slots
  441. [tombstone], # _free_soft_deleted_uk
  442. [], # serialize
  443. ]
  444. slot_idx = {"i": 0}
  445. def query_side_effect(model):
  446. name = getattr(model, "__name__", "")
  447. if name == "DutyDay":
  448. return day_q
  449. if name == "DutySlot":
  450. m = MagicMock()
  451. def all_rows():
  452. i = slot_idx["i"]
  453. slot_idx["i"] += 1
  454. return slot_alls[i] if i < len(slot_alls) else []
  455. m.filter.return_value.all.side_effect = all_rows
  456. return m
  457. if name == "DutyAssignment":
  458. m = MagicMock()
  459. m.filter.return_value.count.return_value = 0
  460. m.filter.return_value.all.return_value = []
  461. return m
  462. return MagicMock()
  463. db.query.side_effect = query_side_effect
  464. body = DutyDaySaveBody(dutyDate=date(2026, 9, 14), slots=[])
  465. save_day(db, "t1", {"user_id": "op1"}, body)
  466. assert active.del_flag == 1
  467. assert tombstone.shift_type != SHIFT_MORNING
  468. assert "~" in tombstone.shift_type
  469. db.flush.assert_called()
  470. db.commit.assert_called()
  471. def test_tombstone_shift_type_keeps_unique_suffix():
  472. from app.services.duty_schedule import _tombstone_shift_type
  473. v = _tombstone_shift_type(SHIFT_MORNING, "ABCDEFGH12345678")
  474. assert v.startswith("morning~")
  475. assert len(v) <= 32
  476. assert v != SHIFT_MORNING
  477. def test_save_day_clears_all_slots_when_empty():
  478. """提交空 slots:无报名的既有班次应被软删,允许清空当日排班。"""
  479. db = MagicMock()
  480. day = SimpleNamespace(
  481. id="day1",
  482. duty_date=date(2026, 8, 20),
  483. access_token="tok",
  484. link_status=LINK_OPEN,
  485. tenant_no="t1",
  486. del_flag=0,
  487. update_by=None,
  488. update_time=None,
  489. )
  490. slot = SimpleNamespace(
  491. id="slot1",
  492. duty_day_id="day1",
  493. shift_type=SHIFT_MORNING,
  494. capacity=2,
  495. del_flag=0,
  496. order_num=1,
  497. update_by=None,
  498. update_time=None,
  499. )
  500. day_q = MagicMock()
  501. day_q.filter.return_value.first.return_value = day
  502. slot_alls = [
  503. [slot], # _load_slots
  504. [slot], # _load_all_slots
  505. [], # _free_soft_deleted_uk
  506. [], # serialize
  507. ]
  508. slot_idx = {"i": 0}
  509. def query_side_effect(model):
  510. name = getattr(model, "__name__", "")
  511. if name == "DutyDay":
  512. return day_q
  513. if name == "DutySlot":
  514. m = MagicMock()
  515. def all_rows():
  516. i = slot_idx["i"]
  517. slot_idx["i"] += 1
  518. return slot_alls[i] if i < len(slot_alls) else []
  519. m.filter.return_value.all.side_effect = all_rows
  520. return m
  521. if name == "DutyAssignment":
  522. m = MagicMock()
  523. m.filter.return_value.count.return_value = 0
  524. m.filter.return_value.all.return_value = []
  525. return m
  526. return MagicMock()
  527. db.query.side_effect = query_side_effect
  528. body = DutyDaySaveBody(dutyDate=date(2026, 8, 20), slots=[])
  529. result = save_day(db, "t1", {"user_id": "op1"}, body)
  530. assert result is not None
  531. assert slot.del_flag == 1
  532. db.commit.assert_called()
  533. def test_save_day_rejects_clear_slot_with_assignments():
  534. """提交空 slots:仍有报名的班次禁止移除。"""
  535. db = MagicMock()
  536. day = SimpleNamespace(
  537. id="day1",
  538. duty_date=date(2026, 8, 20),
  539. access_token="tok",
  540. link_status=LINK_OPEN,
  541. tenant_no="t1",
  542. del_flag=0,
  543. update_by=None,
  544. update_time=None,
  545. )
  546. slot = SimpleNamespace(
  547. id="slot1",
  548. duty_day_id="day1",
  549. shift_type=SHIFT_MORNING,
  550. capacity=2,
  551. del_flag=0,
  552. order_num=1,
  553. update_by=None,
  554. update_time=None,
  555. )
  556. day_q = MagicMock()
  557. day_q.filter.return_value.first.return_value = day
  558. slot_alls = [[slot], [slot]]
  559. slot_idx = {"i": 0}
  560. def query_side_effect(model):
  561. name = getattr(model, "__name__", "")
  562. if name == "DutyDay":
  563. return day_q
  564. if name == "DutySlot":
  565. m = MagicMock()
  566. def all_rows():
  567. i = slot_idx["i"]
  568. slot_idx["i"] += 1
  569. return slot_alls[i] if i < len(slot_alls) else []
  570. m.filter.return_value.all.side_effect = all_rows
  571. return m
  572. if name == "DutyAssignment":
  573. m = MagicMock()
  574. m.filter.return_value.count.return_value = 1
  575. return m
  576. return MagicMock()
  577. db.query.side_effect = query_side_effect
  578. body = DutyDaySaveBody(dutyDate=date(2026, 8, 20), slots=[])
  579. with pytest.raises(DutyBizError, match="仍有报名"):
  580. save_day(db, "t1", {"user_id": "op1"}, body)
  581. def test_save_day_rejects_shrink_below_grab_count():
  582. """缩容:已有 1 人抢班时 capacity=1 且再指定 1 人应失败。"""
  583. db = MagicMock()
  584. day = SimpleNamespace(
  585. id="day1",
  586. duty_date=date(2026, 8, 20),
  587. access_token="tok",
  588. link_status=LINK_OPEN,
  589. tenant_no="t1",
  590. del_flag=0,
  591. update_by=None,
  592. update_time=None,
  593. )
  594. slot = SimpleNamespace(
  595. id="slot1",
  596. duty_day_id="day1",
  597. shift_type=SHIFT_EVENING,
  598. capacity=2,
  599. del_flag=0,
  600. order_num=1,
  601. update_by=None,
  602. update_time=None,
  603. )
  604. grab_row = SimpleNamespace(
  605. id="a1",
  606. duty_slot_id="slot1",
  607. source=SOURCE_GRAB,
  608. del_flag=0,
  609. update_by=None,
  610. update_time=None,
  611. )
  612. day_q = MagicMock()
  613. day_q.filter.return_value.first.return_value = day
  614. slot_list_q = MagicMock()
  615. slot_list_q.filter.return_value.all.return_value = [slot]
  616. assign_q = MagicMock()
  617. assign_q.filter.return_value.all.return_value = [grab_row]
  618. count_q = MagicMock()
  619. count_q.filter.return_value.count.return_value = 1
  620. def query_side_effect(model):
  621. name = getattr(model, "__name__", "")
  622. if name == "DutyDay":
  623. return day_q
  624. if name == "DutySlot":
  625. return slot_list_q
  626. if name == "DutyAssignment":
  627. # first call in loop may be count, then all
  628. m = MagicMock()
  629. m.filter.return_value.count.return_value = 1
  630. m.filter.return_value.all.return_value = [grab_row]
  631. return m
  632. return MagicMock()
  633. db.query.side_effect = query_side_effect
  634. body = DutyDaySaveBody(
  635. dutyDate=date(2026, 8, 20),
  636. slots=[
  637. DutySlotSaveIn(
  638. shiftType=SHIFT_EVENING,
  639. capacity=1,
  640. assignments=[
  641. DutyAssignmentIn(source="assign", userId="u1", personName="张三")
  642. ],
  643. )
  644. ],
  645. )
  646. with pytest.raises(DutyBizError, match="超过名额|缩容|报名"):
  647. save_day(db, "t1", {"user_id": "op1"}, body)
  648. def test_resolve_duty_staff_role_id_by_fixed_id():
  649. db = MagicMock()
  650. role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID, role_name=DUTY_STAFF_ROLE_NAME, del_flag=0)
  651. q = MagicMock()
  652. q.filter.return_value.first.return_value = role
  653. db.query.return_value = q
  654. assert resolve_duty_staff_role_id(db, "shanxia2.0") == DUTY_STAFF_ROLE_ID
  655. def test_list_duty_staff_users_empty_when_no_role():
  656. db = MagicMock()
  657. q = MagicMock()
  658. q.filter.return_value.first.return_value = None # resolve role → None twice
  659. db.query.return_value = q
  660. assert list_duty_staff_users(db, "t1") == []
  661. def test_assert_user_is_duty_staff_rejects_without_role():
  662. db = MagicMock()
  663. role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
  664. role_q = MagicMock()
  665. role_q.filter.return_value.first.return_value = role
  666. ur_q = MagicMock()
  667. ur_q.filter.return_value.first.return_value = None
  668. def query_side_effect(model):
  669. name = getattr(model, "__name__", "")
  670. if name == "SysRole":
  671. return role_q
  672. if name == "SysUserRole":
  673. return ur_q
  674. return MagicMock()
  675. db.query.side_effect = query_side_effect
  676. with pytest.raises(DutyBizError, match="值班人员"):
  677. assert_user_is_duty_staff(db, "t1", "u1")
  678. def test_grab_rejects_non_duty_staff():
  679. db = MagicMock()
  680. day = SimpleNamespace(
  681. id="day1",
  682. tenant_no="t1",
  683. link_status=LINK_OPEN,
  684. duty_date=date(2026, 8, 20),
  685. access_token="tok",
  686. del_flag=0,
  687. )
  688. user = SimpleNamespace(
  689. id="u1",
  690. tenant_no="t1",
  691. nick_name="李四",
  692. user_no="lisi",
  693. status="1",
  694. del_flag=0,
  695. )
  696. day_q = MagicMock()
  697. day_q.filter.return_value.with_for_update.return_value.first.return_value = day
  698. user_q = MagicMock()
  699. user_q.filter.return_value.first.return_value = user
  700. role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
  701. role_q = MagicMock()
  702. role_q.filter.return_value.first.return_value = role
  703. ur_q = MagicMock()
  704. ur_q.filter.return_value.first.return_value = None
  705. def query_side_effect(model):
  706. name = getattr(model, "__name__", "")
  707. if name == "DutyDay":
  708. return day_q
  709. if name == "SysUser":
  710. return user_q
  711. if name == "SysRole":
  712. return role_q
  713. if name == "SysUserRole":
  714. return ur_q
  715. return MagicMock()
  716. db.query.side_effect = query_side_effect
  717. with pytest.raises(DutyBizError, match="值班人员"):
  718. grab(db, "tok", SHIFT_EVENING, "u1")
  719. def test_save_day_grandfathers_existing_assign_without_role():
  720. """历史指定用户无值班人员角色时,原样再保存不得因角色校验失败。"""
  721. db = MagicMock()
  722. day = SimpleNamespace(
  723. id="day1",
  724. duty_date=date(2026, 8, 20),
  725. access_token="tok",
  726. link_status=LINK_OPEN,
  727. tenant_no="t1",
  728. del_flag=0,
  729. update_by=None,
  730. update_time=None,
  731. )
  732. slot = SimpleNamespace(
  733. id="slot1",
  734. duty_day_id="day1",
  735. shift_type=SHIFT_EVENING,
  736. capacity=2,
  737. del_flag=0,
  738. order_num=1,
  739. update_by=None,
  740. update_time=None,
  741. )
  742. old_assign = SimpleNamespace(
  743. id="a_old",
  744. duty_slot_id="slot1",
  745. source=SOURCE_ASSIGN,
  746. user_id="u_old",
  747. person_name="老员工",
  748. del_flag=0,
  749. update_by=None,
  750. update_time=None,
  751. )
  752. day_q = MagicMock()
  753. day_q.filter.return_value.first.return_value = day
  754. slot_list_q = MagicMock()
  755. slot_list_q.filter.return_value.all.return_value = [slot]
  756. role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
  757. role_q = MagicMock()
  758. role_q.filter.return_value.first.return_value = role
  759. ur_q = MagicMock()
  760. ur_q.filter.return_value.first.return_value = None # 无角色关联
  761. user_q = MagicMock()
  762. user_q.filter.return_value.first.return_value = SimpleNamespace(
  763. id="u_old", tenant_no="t1", del_flag=0, status="1"
  764. )
  765. def query_side_effect(model):
  766. name = getattr(model, "__name__", "")
  767. if name == "DutyDay":
  768. return day_q
  769. if name == "DutySlot":
  770. return slot_list_q
  771. if name == "DutyAssignment":
  772. m = MagicMock()
  773. m.filter.return_value.count.return_value = 1
  774. m.filter.return_value.all.return_value = [old_assign]
  775. return m
  776. if name == "SysRole":
  777. return role_q
  778. if name == "SysUserRole":
  779. return ur_q
  780. if name == "SysUser":
  781. return user_q
  782. return MagicMock()
  783. db.query.side_effect = query_side_effect
  784. body = DutyDaySaveBody(
  785. dutyDate=date(2026, 8, 20),
  786. slots=[
  787. DutySlotSaveIn(
  788. shiftType=SHIFT_EVENING,
  789. capacity=2,
  790. assignments=[
  791. DutyAssignmentIn(source="assign", userId="u_old", personName="老员工")
  792. ],
  793. )
  794. ],
  795. )
  796. # 不得因「无值班人员角色」抛错;序列化侧 mock 足够跑完即可
  797. result = save_day(db, "t1", {"user_id": "op1"}, body)
  798. assert result is not None
  799. db.commit.assert_called()
  800. def test_save_day_rejects_new_assign_without_role():
  801. """新指定用户无值班人员角色时必须拒绝。"""
  802. db = MagicMock()
  803. day = SimpleNamespace(
  804. id="day1",
  805. duty_date=date(2026, 8, 20),
  806. access_token="tok",
  807. link_status=LINK_OPEN,
  808. tenant_no="t1",
  809. del_flag=0,
  810. update_by=None,
  811. update_time=None,
  812. )
  813. slot = SimpleNamespace(
  814. id="slot1",
  815. duty_day_id="day1",
  816. shift_type=SHIFT_EVENING,
  817. capacity=2,
  818. del_flag=0,
  819. order_num=1,
  820. update_by=None,
  821. update_time=None,
  822. )
  823. old_assign = SimpleNamespace(
  824. id="a_old",
  825. duty_slot_id="slot1",
  826. source=SOURCE_ASSIGN,
  827. user_id="u_old",
  828. person_name="老员工",
  829. del_flag=0,
  830. update_by=None,
  831. update_time=None,
  832. )
  833. day_q = MagicMock()
  834. day_q.filter.return_value.first.return_value = day
  835. slot_list_q = MagicMock()
  836. slot_list_q.filter.return_value.all.return_value = [slot]
  837. role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
  838. role_q = MagicMock()
  839. role_q.filter.return_value.first.return_value = role
  840. ur_q = MagicMock()
  841. ur_q.filter.return_value.first.return_value = None
  842. user_q = MagicMock()
  843. user_q.filter.return_value.first.return_value = SimpleNamespace(
  844. id="u_new", tenant_no="t1", del_flag=0, status="1"
  845. )
  846. def query_side_effect(model):
  847. name = getattr(model, "__name__", "")
  848. if name == "DutyDay":
  849. return day_q
  850. if name == "DutySlot":
  851. return slot_list_q
  852. if name == "DutyAssignment":
  853. m = MagicMock()
  854. m.filter.return_value.count.return_value = 1
  855. m.filter.return_value.all.return_value = [old_assign]
  856. return m
  857. if name == "SysRole":
  858. return role_q
  859. if name == "SysUserRole":
  860. return ur_q
  861. if name == "SysUser":
  862. return user_q
  863. return MagicMock()
  864. db.query.side_effect = query_side_effect
  865. body = DutyDaySaveBody(
  866. dutyDate=date(2026, 8, 20),
  867. slots=[
  868. DutySlotSaveIn(
  869. shiftType=SHIFT_EVENING,
  870. capacity=2,
  871. assignments=[
  872. DutyAssignmentIn(source="assign", userId="u_new", personName="新员工")
  873. ],
  874. )
  875. ],
  876. )
  877. with pytest.raises(DutyBizError, match="值班人员"):
  878. save_day(db, "t1", {"user_id": "op1"}, body)