test_duty_schedule.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
  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_rejects_shrink_below_grab_count():
  329. """缩容:已有 1 人抢班时 capacity=1 且再指定 1 人应失败。"""
  330. db = MagicMock()
  331. day = SimpleNamespace(
  332. id="day1",
  333. duty_date=date(2026, 8, 20),
  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. slot = SimpleNamespace(
  342. id="slot1",
  343. duty_day_id="day1",
  344. shift_type=SHIFT_EVENING,
  345. capacity=2,
  346. del_flag=0,
  347. order_num=1,
  348. update_by=None,
  349. update_time=None,
  350. )
  351. grab_row = SimpleNamespace(
  352. id="a1",
  353. duty_slot_id="slot1",
  354. source=SOURCE_GRAB,
  355. del_flag=0,
  356. update_by=None,
  357. update_time=None,
  358. )
  359. day_q = MagicMock()
  360. day_q.filter.return_value.first.return_value = day
  361. slot_list_q = MagicMock()
  362. slot_list_q.filter.return_value.all.return_value = [slot]
  363. assign_q = MagicMock()
  364. assign_q.filter.return_value.all.return_value = [grab_row]
  365. count_q = MagicMock()
  366. count_q.filter.return_value.count.return_value = 1
  367. def query_side_effect(model):
  368. name = getattr(model, "__name__", "")
  369. if name == "DutyDay":
  370. return day_q
  371. if name == "DutySlot":
  372. return slot_list_q
  373. if name == "DutyAssignment":
  374. # first call in loop may be count, then all
  375. m = MagicMock()
  376. m.filter.return_value.count.return_value = 1
  377. m.filter.return_value.all.return_value = [grab_row]
  378. return m
  379. return MagicMock()
  380. db.query.side_effect = query_side_effect
  381. body = DutyDaySaveBody(
  382. dutyDate=date(2026, 8, 20),
  383. slots=[
  384. DutySlotSaveIn(
  385. shiftType=SHIFT_EVENING,
  386. capacity=1,
  387. assignments=[
  388. DutyAssignmentIn(source="assign", userId="u1", personName="张三")
  389. ],
  390. )
  391. ],
  392. )
  393. with pytest.raises(DutyBizError, match="超过名额|缩容|报名"):
  394. save_day(db, "t1", {"user_id": "op1"}, body)
  395. def test_resolve_duty_staff_role_id_by_fixed_id():
  396. db = MagicMock()
  397. role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID, role_name=DUTY_STAFF_ROLE_NAME, del_flag=0)
  398. q = MagicMock()
  399. q.filter.return_value.first.return_value = role
  400. db.query.return_value = q
  401. assert resolve_duty_staff_role_id(db, "shanxia2.0") == DUTY_STAFF_ROLE_ID
  402. def test_list_duty_staff_users_empty_when_no_role():
  403. db = MagicMock()
  404. q = MagicMock()
  405. q.filter.return_value.first.return_value = None # resolve role → None twice
  406. db.query.return_value = q
  407. assert list_duty_staff_users(db, "t1") == []
  408. def test_assert_user_is_duty_staff_rejects_without_role():
  409. db = MagicMock()
  410. role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
  411. role_q = MagicMock()
  412. role_q.filter.return_value.first.return_value = role
  413. ur_q = MagicMock()
  414. ur_q.filter.return_value.first.return_value = None
  415. def query_side_effect(model):
  416. name = getattr(model, "__name__", "")
  417. if name == "SysRole":
  418. return role_q
  419. if name == "SysUserRole":
  420. return ur_q
  421. return MagicMock()
  422. db.query.side_effect = query_side_effect
  423. with pytest.raises(DutyBizError, match="值班人员"):
  424. assert_user_is_duty_staff(db, "t1", "u1")
  425. def test_grab_rejects_non_duty_staff():
  426. db = MagicMock()
  427. day = SimpleNamespace(
  428. id="day1",
  429. tenant_no="t1",
  430. link_status=LINK_OPEN,
  431. duty_date=date(2026, 8, 20),
  432. access_token="tok",
  433. del_flag=0,
  434. )
  435. user = SimpleNamespace(
  436. id="u1",
  437. tenant_no="t1",
  438. nick_name="李四",
  439. user_no="lisi",
  440. status="1",
  441. del_flag=0,
  442. )
  443. day_q = MagicMock()
  444. day_q.filter.return_value.with_for_update.return_value.first.return_value = day
  445. user_q = MagicMock()
  446. user_q.filter.return_value.first.return_value = user
  447. role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
  448. role_q = MagicMock()
  449. role_q.filter.return_value.first.return_value = role
  450. ur_q = MagicMock()
  451. ur_q.filter.return_value.first.return_value = None
  452. def query_side_effect(model):
  453. name = getattr(model, "__name__", "")
  454. if name == "DutyDay":
  455. return day_q
  456. if name == "SysUser":
  457. return user_q
  458. if name == "SysRole":
  459. return role_q
  460. if name == "SysUserRole":
  461. return ur_q
  462. return MagicMock()
  463. db.query.side_effect = query_side_effect
  464. with pytest.raises(DutyBizError, match="值班人员"):
  465. grab(db, "tok", SHIFT_EVENING, "u1")
  466. def test_save_day_grandfathers_existing_assign_without_role():
  467. """历史指定用户无值班人员角色时,原样再保存不得因角色校验失败。"""
  468. db = MagicMock()
  469. day = SimpleNamespace(
  470. id="day1",
  471. duty_date=date(2026, 8, 20),
  472. access_token="tok",
  473. link_status=LINK_OPEN,
  474. tenant_no="t1",
  475. del_flag=0,
  476. update_by=None,
  477. update_time=None,
  478. )
  479. slot = SimpleNamespace(
  480. id="slot1",
  481. duty_day_id="day1",
  482. shift_type=SHIFT_EVENING,
  483. capacity=2,
  484. del_flag=0,
  485. order_num=1,
  486. update_by=None,
  487. update_time=None,
  488. )
  489. old_assign = SimpleNamespace(
  490. id="a_old",
  491. duty_slot_id="slot1",
  492. source=SOURCE_ASSIGN,
  493. user_id="u_old",
  494. person_name="老员工",
  495. del_flag=0,
  496. update_by=None,
  497. update_time=None,
  498. )
  499. day_q = MagicMock()
  500. day_q.filter.return_value.first.return_value = day
  501. slot_list_q = MagicMock()
  502. slot_list_q.filter.return_value.all.return_value = [slot]
  503. role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
  504. role_q = MagicMock()
  505. role_q.filter.return_value.first.return_value = role
  506. ur_q = MagicMock()
  507. ur_q.filter.return_value.first.return_value = None # 无角色关联
  508. user_q = MagicMock()
  509. user_q.filter.return_value.first.return_value = SimpleNamespace(
  510. id="u_old", tenant_no="t1", del_flag=0, status="1"
  511. )
  512. def query_side_effect(model):
  513. name = getattr(model, "__name__", "")
  514. if name == "DutyDay":
  515. return day_q
  516. if name == "DutySlot":
  517. return slot_list_q
  518. if name == "DutyAssignment":
  519. m = MagicMock()
  520. m.filter.return_value.count.return_value = 1
  521. m.filter.return_value.all.return_value = [old_assign]
  522. return m
  523. if name == "SysRole":
  524. return role_q
  525. if name == "SysUserRole":
  526. return ur_q
  527. if name == "SysUser":
  528. return user_q
  529. return MagicMock()
  530. db.query.side_effect = query_side_effect
  531. body = DutyDaySaveBody(
  532. dutyDate=date(2026, 8, 20),
  533. slots=[
  534. DutySlotSaveIn(
  535. shiftType=SHIFT_EVENING,
  536. capacity=2,
  537. assignments=[
  538. DutyAssignmentIn(source="assign", userId="u_old", personName="老员工")
  539. ],
  540. )
  541. ],
  542. )
  543. # 不得因「无值班人员角色」抛错;序列化侧 mock 足够跑完即可
  544. result = save_day(db, "t1", {"user_id": "op1"}, body)
  545. assert result is not None
  546. db.commit.assert_called()
  547. def test_save_day_rejects_new_assign_without_role():
  548. """新指定用户无值班人员角色时必须拒绝。"""
  549. db = MagicMock()
  550. day = SimpleNamespace(
  551. id="day1",
  552. duty_date=date(2026, 8, 20),
  553. access_token="tok",
  554. link_status=LINK_OPEN,
  555. tenant_no="t1",
  556. del_flag=0,
  557. update_by=None,
  558. update_time=None,
  559. )
  560. slot = SimpleNamespace(
  561. id="slot1",
  562. duty_day_id="day1",
  563. shift_type=SHIFT_EVENING,
  564. capacity=2,
  565. del_flag=0,
  566. order_num=1,
  567. update_by=None,
  568. update_time=None,
  569. )
  570. old_assign = SimpleNamespace(
  571. id="a_old",
  572. duty_slot_id="slot1",
  573. source=SOURCE_ASSIGN,
  574. user_id="u_old",
  575. person_name="老员工",
  576. del_flag=0,
  577. update_by=None,
  578. update_time=None,
  579. )
  580. day_q = MagicMock()
  581. day_q.filter.return_value.first.return_value = day
  582. slot_list_q = MagicMock()
  583. slot_list_q.filter.return_value.all.return_value = [slot]
  584. role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
  585. role_q = MagicMock()
  586. role_q.filter.return_value.first.return_value = role
  587. ur_q = MagicMock()
  588. ur_q.filter.return_value.first.return_value = None
  589. user_q = MagicMock()
  590. user_q.filter.return_value.first.return_value = SimpleNamespace(
  591. id="u_new", tenant_no="t1", del_flag=0, status="1"
  592. )
  593. def query_side_effect(model):
  594. name = getattr(model, "__name__", "")
  595. if name == "DutyDay":
  596. return day_q
  597. if name == "DutySlot":
  598. return slot_list_q
  599. if name == "DutyAssignment":
  600. m = MagicMock()
  601. m.filter.return_value.count.return_value = 1
  602. m.filter.return_value.all.return_value = [old_assign]
  603. return m
  604. if name == "SysRole":
  605. return role_q
  606. if name == "SysUserRole":
  607. return ur_q
  608. if name == "SysUser":
  609. return user_q
  610. return MagicMock()
  611. db.query.side_effect = query_side_effect
  612. body = DutyDaySaveBody(
  613. dutyDate=date(2026, 8, 20),
  614. slots=[
  615. DutySlotSaveIn(
  616. shiftType=SHIFT_EVENING,
  617. capacity=2,
  618. assignments=[
  619. DutyAssignmentIn(source="assign", userId="u_new", personName="新员工")
  620. ],
  621. )
  622. ],
  623. )
  624. with pytest.raises(DutyBizError, match="值班人员"):
  625. save_day(db, "t1", {"user_id": "op1"}, body)