| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007 |
- """值班排班服务单测"""
- from datetime import date
- from types import SimpleNamespace
- from unittest.mock import MagicMock
- import pytest
- from app.models.duty_models import (
- DUTY_STAFF_ROLE_ID,
- DUTY_STAFF_ROLE_NAME,
- LINK_CLOSED,
- LINK_OPEN,
- SHIFT_EVENING,
- SHIFT_LABELS,
- SHIFT_MORNING,
- SOURCE_ASSIGN,
- SOURCE_GRAB,
- )
- from app.schemas.duty import DutyAssignmentIn, DutyDaySaveBody, DutySlotSaveIn
- from app.models.models import SysDictData
- from app.services.duty_schedule import (
- DutyBizError,
- assert_user_is_duty_staff,
- build_dict_value_from_label,
- ensure_dict_data,
- format_time_label,
- grab,
- join_work_plans,
- list_duty_staff_users,
- normalize_person_name,
- parse_hhmm,
- resolve_duty_staff_role_id,
- save_day,
- split_work_plans,
- )
- def test_parse_hhmm():
- assert parse_hhmm("08:00").hour == 8
- assert parse_hhmm("18:30").minute == 30
- assert parse_hhmm(None) is None
- assert parse_hhmm("") is None
- def test_parse_hhmm_invalid_raises():
- with pytest.raises(DutyBizError, match="时间格式"):
- parse_hhmm("08:xx")
- def test_parse_time_range_input_day():
- from app.services.duty_schedule import parse_time_range_input
- r = parse_time_range_input("09:00-17:00")
- assert r["startTime"] == "09:00"
- assert r["endTime"] == "17:00"
- assert r["crossDay"] is False
- assert r["value"] == "09:00|17:00|0"
- assert r["label"] == "09:00–17:00"
- def test_parse_time_range_input_cross_day():
- from app.services.duty_schedule import parse_time_range_input
- r = parse_time_range_input("18:00-次日07:00")
- assert r["startTime"] == "18:00"
- assert r["endTime"] == "07:00"
- assert r["crossDay"] is True
- assert r["value"] == "18:00|07:00|1"
- assert "次日" in r["label"]
- def test_format_time_label_same_day():
- assert format_time_label(parse_hhmm("08:00"), parse_hhmm("18:00"), False) == "08:00–18:00"
- def test_format_time_label_cross_day():
- assert format_time_label(parse_hhmm("18:00"), parse_hhmm("07:00"), True) == "18:00–次日07:00"
- def test_format_time_label_empty():
- assert format_time_label(None, None, False) is None
- def test_work_plans_join_split():
- assert join_work_plans(["peisuan", "zhuangxie"]) == "peisuan,zhuangxie"
- assert split_work_plans("peisuan,zhuangxie") == ["peisuan", "zhuangxie"]
- assert split_work_plans(None) == []
- def test_build_dict_value_from_label():
- assert build_dict_value_from_label("配酸") == "peisuan"
- assert build_dict_value_from_label("装卸") == "zhuangxie"
- assert build_dict_value_from_label(" ") == "item"
- def test_ensure_dict_data_reuses_existing_label():
- db = MagicMock()
- existing = SimpleNamespace(dict_label="配酸", dict_value="peisuan")
- q = MagicMock()
- q.filter.return_value.first.return_value = existing
- db.query.return_value = q
- result = ensure_dict_data(
- db, tenant_no="t1", dict_type="duty_work_plan", label="配酸", user_id="u1"
- )
- assert result == {"label": "配酸", "value": "peisuan"}
- db.add.assert_not_called()
- def test_ensure_dict_data_inserts_new_without_commit():
- db = MagicMock()
- q = MagicMock()
- q.filter.return_value.first.side_effect = [None, None]
- db.query.return_value = q
- result = ensure_dict_data(
- db, tenant_no="t1", dict_type="duty_work_plan", label="巡检", user_id="u1"
- )
- assert result["label"] == "巡检"
- assert result["value"] == "xunjian"
- db.add.assert_called_once()
- db.flush.assert_called_once()
- db.commit.assert_not_called()
- added = db.add.call_args[0][0]
- assert isinstance(added, SysDictData)
- assert added.dict_type == "duty_work_plan"
- assert added.tenant_no == "t1"
- def test_normalize_person_name_ok():
- assert normalize_person_name(" 张三 ") == "张三"
- def test_normalize_person_name_empty():
- with pytest.raises(DutyBizError, match="姓名"):
- normalize_person_name(" ")
- def test_normalize_person_name_too_long():
- with pytest.raises(DutyBizError, match="姓名"):
- normalize_person_name("啊" * 21)
- def test_shift_labels():
- assert SHIFT_LABELS[SHIFT_MORNING] == "早班"
- def test_grab_rejects_when_full():
- db = MagicMock()
- day = SimpleNamespace(
- id="day1",
- tenant_no="t1",
- link_status=LINK_OPEN,
- duty_date=date(2026, 8, 20),
- access_token="tok",
- del_flag=0,
- )
- slot = SimpleNamespace(
- id="slot1",
- duty_day_id="day1",
- shift_type=SHIFT_EVENING,
- capacity=1,
- del_flag=0,
- )
- user = SimpleNamespace(
- id="u1",
- tenant_no="t1",
- nick_name="李四",
- user_no="lisi",
- status="1",
- del_flag=0,
- )
- day_q = MagicMock()
- day_q.filter.return_value.with_for_update.return_value.first.return_value = day
- slot_q = MagicMock()
- slot_q.filter.return_value.with_for_update.return_value.first.return_value = slot
- user_q = MagicMock()
- user_q.filter.return_value.first.return_value = user
- role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
- role_q = MagicMock()
- role_q.filter.return_value.first.return_value = role
- ur_q = MagicMock()
- ur_q.filter.return_value.first.return_value = SimpleNamespace(user_id="u1", role_id=DUTY_STAFF_ROLE_ID)
- def query_side_effect(model):
- name = getattr(model, "__name__", str(model))
- if name == "DutyDay":
- return day_q
- if name == "DutySlot":
- return slot_q
- if name == "SysUser":
- return user_q
- if name == "SysRole":
- return role_q
- if name == "SysUserRole":
- return ur_q
- # DutyAssignment count / duplicate
- aq = MagicMock()
- aq.filter.return_value.count.return_value = 1
- aq.filter.return_value.first.return_value = None
- return aq
- db.query.side_effect = query_side_effect
- with pytest.raises(DutyBizError, match="名额已满"):
- grab(db, "tok", SHIFT_EVENING, "u1")
- def test_grab_allows_custom_shift_type_when_slot_exists():
- """自定义 shift_type 只要当日存在 slot 即可,不必在 SHIFT_LABELS。"""
- db = MagicMock()
- day = SimpleNamespace(
- id="day1",
- tenant_no="t1",
- link_status=LINK_OPEN,
- duty_date=date(2026, 8, 20),
- access_token="tok",
- del_flag=0,
- )
- slot = SimpleNamespace(
- id="slot1",
- duty_day_id="day1",
- shift_type="zhongban",
- capacity=2,
- del_flag=0,
- )
- user = SimpleNamespace(
- id="u1",
- tenant_no="t1",
- nick_name="王五",
- user_no="wangwu",
- status="1",
- del_flag=0,
- )
- day_q = MagicMock()
- day_q.filter.return_value.with_for_update.return_value.first.return_value = day
- slot_q = MagicMock()
- slot_q.filter.return_value.with_for_update.return_value.first.return_value = slot
- user_q = MagicMock()
- user_q.filter.return_value.first.return_value = user
- role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
- role_q = MagicMock()
- role_q.filter.return_value.first.return_value = role
- ur_q = MagicMock()
- ur_q.filter.return_value.first.return_value = SimpleNamespace(
- user_id="u1", role_id=DUTY_STAFF_ROLE_ID
- )
- def query_side_effect(model):
- name = getattr(model, "__name__", str(model))
- if name == "DutyDay":
- return day_q
- if name == "DutySlot":
- return slot_q
- if name == "SysUser":
- return user_q
- if name == "SysRole":
- return role_q
- if name == "SysUserRole":
- return ur_q
- if name == "SysDictData":
- dq = MagicMock()
- dq.filter.return_value.all.return_value = []
- dq.filter.return_value.first.return_value = None
- return dq
- aq = MagicMock()
- aq.filter.return_value.count.return_value = 0
- aq.filter.return_value.first.return_value = None
- return aq
- db.query.side_effect = query_side_effect
- result = grab(db, "tok", "zhongban", "u1")
- assert result["shiftType"] == "zhongban"
- assert result["status"] == "ok"
- def test_grab_rejects_when_closed():
- db = MagicMock()
- day = SimpleNamespace(
- id="day1",
- tenant_no="t1",
- link_status=LINK_CLOSED,
- duty_date=date(2026, 8, 20),
- access_token="tok",
- del_flag=0,
- )
- day_q = MagicMock()
- day_q.filter.return_value.with_for_update.return_value.first.return_value = day
- db.query.return_value = day_q
- with pytest.raises(DutyBizError, match="关闭"):
- grab(db, "tok", SHIFT_EVENING, "u1")
- def test_grab_rejects_invalid_user():
- db = MagicMock()
- day = SimpleNamespace(
- id="day1",
- tenant_no="t1",
- link_status=LINK_OPEN,
- duty_date=date(2026, 8, 20),
- access_token="tok",
- del_flag=0,
- )
- day_q = MagicMock()
- day_q.filter.return_value.with_for_update.return_value.first.return_value = day
- user_q = MagicMock()
- user_q.filter.return_value.first.return_value = None
- def query_side_effect(model):
- name = getattr(model, "__name__", str(model))
- if name == "DutyDay":
- return day_q
- if name == "SysUser":
- return user_q
- return MagicMock()
- db.query.side_effect = query_side_effect
- with pytest.raises(DutyBizError, match="用户|无效|不存在"):
- grab(db, "tok", SHIFT_EVENING, "bad-user")
- def test_grab_rejects_duplicate_same_shift():
- db = MagicMock()
- day = SimpleNamespace(
- id="day1",
- tenant_no="t1",
- link_status=LINK_OPEN,
- duty_date=date(2026, 8, 20),
- access_token="tok",
- del_flag=0,
- )
- slot = SimpleNamespace(
- id="slot1",
- duty_day_id="day1",
- shift_type=SHIFT_EVENING,
- capacity=2,
- del_flag=0,
- )
- user = SimpleNamespace(
- id="u1",
- tenant_no="t1",
- nick_name="李四",
- user_no="lisi",
- status="1",
- del_flag=0,
- )
- existing = SimpleNamespace(id="a1", user_id="u1", del_flag=0)
- day_q = MagicMock()
- day_q.filter.return_value.with_for_update.return_value.first.return_value = day
- slot_q = MagicMock()
- slot_q.filter.return_value.with_for_update.return_value.first.return_value = slot
- user_q = MagicMock()
- user_q.filter.return_value.first.return_value = user
- role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
- role_q = MagicMock()
- role_q.filter.return_value.first.return_value = role
- ur_q = MagicMock()
- ur_q.filter.return_value.first.return_value = SimpleNamespace(user_id="u1", role_id=DUTY_STAFF_ROLE_ID)
- assign_calls = {"n": 0}
- def query_side_effect(model):
- name = getattr(model, "__name__", str(model))
- if name == "DutyDay":
- return day_q
- if name == "DutySlot":
- return slot_q
- if name == "SysUser":
- return user_q
- if name == "SysRole":
- return role_q
- if name == "SysUserRole":
- return ur_q
- aq = MagicMock()
- # first: duplicate check .first(); later: count
- assign_calls["n"] += 1
- aq.filter.return_value.first.return_value = existing
- aq.filter.return_value.count.return_value = 1
- return aq
- db.query.side_effect = query_side_effect
- with pytest.raises(DutyBizError, match="已报名"):
- grab(db, "tok", SHIFT_EVENING, "u1")
- def test_save_day_revives_soft_deleted_slot_instead_of_insert():
- """同日同班次曾软删后再启用:应复活旧 slot,避免 uk_day_shift 堆积。"""
- db = MagicMock()
- day = SimpleNamespace(
- id="day1",
- duty_date=date(2026, 9, 14),
- access_token="tok",
- link_status=LINK_OPEN,
- tenant_no="t1",
- del_flag=0,
- update_by=None,
- update_time=None,
- )
- soft = SimpleNamespace(
- id="slot_soft",
- duty_day_id="day1",
- shift_type=SHIFT_MORNING,
- capacity=1,
- work_plans=None,
- start_time=None,
- end_time=None,
- cross_day=0,
- remark=None,
- order_num=1,
- del_flag=1,
- update_by=None,
- update_time=None,
- create_by=None,
- create_dept_id="",
- create_time=None,
- tenant_no="t1",
- )
- day_q = MagicMock()
- day_q.filter.return_value.first.return_value = day
- # _load_slots(活跃)空;_load_all_slots 含软删;序列化再查活跃
- slot_alls = [[], [soft], [soft]]
- slot_idx = {"i": 0}
- def query_side_effect(model):
- name = getattr(model, "__name__", "")
- if name == "DutyDay":
- return day_q
- if name == "DutySlot":
- m = MagicMock()
- def all_rows():
- i = slot_idx["i"]
- slot_idx["i"] += 1
- return slot_alls[i] if i < len(slot_alls) else []
- m.filter.return_value.all.side_effect = all_rows
- return m
- if name == "DutyAssignment":
- m = MagicMock()
- m.filter.return_value.count.return_value = 0
- m.filter.return_value.all.return_value = []
- return m
- if name == "SysDictData":
- m = MagicMock()
- m.filter.return_value.first.return_value = SimpleNamespace(
- dict_label="早班", dict_value=SHIFT_MORNING
- )
- return m
- return MagicMock()
- db.query.side_effect = query_side_effect
- body = DutyDaySaveBody(
- dutyDate=date(2026, 9, 14),
- slots=[DutySlotSaveIn(shiftType=SHIFT_MORNING, capacity=2, assignments=[])],
- )
- save_day(db, "t1", {"user_id": "op1"}, body)
- assert soft.del_flag == 0
- assert soft.capacity == 2
- added_slots = [
- c.args[0]
- for c in db.add.call_args_list
- if c.args and getattr(c.args[0], "shift_type", None) == SHIFT_MORNING
- ]
- assert added_slots == []
- def test_save_day_retire_frees_existing_soft_deleted_uk():
- """再次清空班次:若已有同类型 del_flag=1 墓碑,须先改写其 shift_type 再软删活跃行。"""
- db = MagicMock()
- day = SimpleNamespace(
- id="day1",
- duty_date=date(2026, 9, 14),
- access_token="tok",
- link_status=LINK_OPEN,
- tenant_no="t1",
- del_flag=0,
- update_by=None,
- update_time=None,
- )
- active = SimpleNamespace(
- id="slot_active",
- duty_day_id="day1",
- shift_type=SHIFT_MORNING,
- capacity=1,
- del_flag=0,
- order_num=1,
- update_by=None,
- update_time=None,
- )
- tombstone = SimpleNamespace(
- id="slot_old",
- duty_day_id="day1",
- shift_type=SHIFT_MORNING,
- capacity=1,
- del_flag=1,
- order_num=1,
- update_by=None,
- update_time=None,
- )
- day_q = MagicMock()
- day_q.filter.return_value.first.return_value = day
- slot_alls = [
- [active], # _load_slots
- [active, tombstone], # _load_all_slots
- [tombstone], # _free_soft_deleted_uk
- [], # serialize
- ]
- slot_idx = {"i": 0}
- def query_side_effect(model):
- name = getattr(model, "__name__", "")
- if name == "DutyDay":
- return day_q
- if name == "DutySlot":
- m = MagicMock()
- def all_rows():
- i = slot_idx["i"]
- slot_idx["i"] += 1
- return slot_alls[i] if i < len(slot_alls) else []
- m.filter.return_value.all.side_effect = all_rows
- return m
- if name == "DutyAssignment":
- m = MagicMock()
- m.filter.return_value.count.return_value = 0
- m.filter.return_value.all.return_value = []
- return m
- return MagicMock()
- db.query.side_effect = query_side_effect
- body = DutyDaySaveBody(dutyDate=date(2026, 9, 14), slots=[])
- save_day(db, "t1", {"user_id": "op1"}, body)
- assert active.del_flag == 1
- assert tombstone.shift_type != SHIFT_MORNING
- assert "~" in tombstone.shift_type
- db.flush.assert_called()
- db.commit.assert_called()
- def test_tombstone_shift_type_keeps_unique_suffix():
- from app.services.duty_schedule import _tombstone_shift_type
- v = _tombstone_shift_type(SHIFT_MORNING, "ABCDEFGH12345678")
- assert v.startswith("morning~")
- assert len(v) <= 32
- assert v != SHIFT_MORNING
- def test_save_day_clears_all_slots_when_empty():
- """提交空 slots:无报名的既有班次应被软删,允许清空当日排班。"""
- db = MagicMock()
- day = SimpleNamespace(
- id="day1",
- duty_date=date(2026, 8, 20),
- access_token="tok",
- link_status=LINK_OPEN,
- tenant_no="t1",
- del_flag=0,
- update_by=None,
- update_time=None,
- )
- slot = SimpleNamespace(
- id="slot1",
- duty_day_id="day1",
- shift_type=SHIFT_MORNING,
- capacity=2,
- del_flag=0,
- order_num=1,
- update_by=None,
- update_time=None,
- )
- day_q = MagicMock()
- day_q.filter.return_value.first.return_value = day
- slot_alls = [
- [slot], # _load_slots
- [slot], # _load_all_slots
- [], # _free_soft_deleted_uk
- [], # serialize
- ]
- slot_idx = {"i": 0}
- def query_side_effect(model):
- name = getattr(model, "__name__", "")
- if name == "DutyDay":
- return day_q
- if name == "DutySlot":
- m = MagicMock()
- def all_rows():
- i = slot_idx["i"]
- slot_idx["i"] += 1
- return slot_alls[i] if i < len(slot_alls) else []
- m.filter.return_value.all.side_effect = all_rows
- return m
- if name == "DutyAssignment":
- m = MagicMock()
- m.filter.return_value.count.return_value = 0
- m.filter.return_value.all.return_value = []
- return m
- return MagicMock()
- db.query.side_effect = query_side_effect
- body = DutyDaySaveBody(dutyDate=date(2026, 8, 20), slots=[])
- result = save_day(db, "t1", {"user_id": "op1"}, body)
- assert result is not None
- assert slot.del_flag == 1
- db.commit.assert_called()
- def test_save_day_rejects_clear_slot_with_assignments():
- """提交空 slots:仍有报名的班次禁止移除。"""
- db = MagicMock()
- day = SimpleNamespace(
- id="day1",
- duty_date=date(2026, 8, 20),
- access_token="tok",
- link_status=LINK_OPEN,
- tenant_no="t1",
- del_flag=0,
- update_by=None,
- update_time=None,
- )
- slot = SimpleNamespace(
- id="slot1",
- duty_day_id="day1",
- shift_type=SHIFT_MORNING,
- capacity=2,
- del_flag=0,
- order_num=1,
- update_by=None,
- update_time=None,
- )
- day_q = MagicMock()
- day_q.filter.return_value.first.return_value = day
- slot_alls = [[slot], [slot]]
- slot_idx = {"i": 0}
- def query_side_effect(model):
- name = getattr(model, "__name__", "")
- if name == "DutyDay":
- return day_q
- if name == "DutySlot":
- m = MagicMock()
- def all_rows():
- i = slot_idx["i"]
- slot_idx["i"] += 1
- return slot_alls[i] if i < len(slot_alls) else []
- m.filter.return_value.all.side_effect = all_rows
- return m
- if name == "DutyAssignment":
- m = MagicMock()
- m.filter.return_value.count.return_value = 1
- return m
- return MagicMock()
- db.query.side_effect = query_side_effect
- body = DutyDaySaveBody(dutyDate=date(2026, 8, 20), slots=[])
- with pytest.raises(DutyBizError, match="仍有报名"):
- save_day(db, "t1", {"user_id": "op1"}, body)
- def test_save_day_rejects_shrink_below_grab_count():
- """缩容:已有 1 人抢班时 capacity=1 且再指定 1 人应失败。"""
- db = MagicMock()
- day = SimpleNamespace(
- id="day1",
- duty_date=date(2026, 8, 20),
- access_token="tok",
- link_status=LINK_OPEN,
- tenant_no="t1",
- del_flag=0,
- update_by=None,
- update_time=None,
- )
- slot = SimpleNamespace(
- id="slot1",
- duty_day_id="day1",
- shift_type=SHIFT_EVENING,
- capacity=2,
- del_flag=0,
- order_num=1,
- update_by=None,
- update_time=None,
- )
- grab_row = SimpleNamespace(
- id="a1",
- duty_slot_id="slot1",
- source=SOURCE_GRAB,
- del_flag=0,
- update_by=None,
- update_time=None,
- )
- day_q = MagicMock()
- day_q.filter.return_value.first.return_value = day
- slot_list_q = MagicMock()
- slot_list_q.filter.return_value.all.return_value = [slot]
- assign_q = MagicMock()
- assign_q.filter.return_value.all.return_value = [grab_row]
- count_q = MagicMock()
- count_q.filter.return_value.count.return_value = 1
- def query_side_effect(model):
- name = getattr(model, "__name__", "")
- if name == "DutyDay":
- return day_q
- if name == "DutySlot":
- return slot_list_q
- if name == "DutyAssignment":
- # first call in loop may be count, then all
- m = MagicMock()
- m.filter.return_value.count.return_value = 1
- m.filter.return_value.all.return_value = [grab_row]
- return m
- return MagicMock()
- db.query.side_effect = query_side_effect
- body = DutyDaySaveBody(
- dutyDate=date(2026, 8, 20),
- slots=[
- DutySlotSaveIn(
- shiftType=SHIFT_EVENING,
- capacity=1,
- assignments=[
- DutyAssignmentIn(source="assign", userId="u1", personName="张三")
- ],
- )
- ],
- )
- with pytest.raises(DutyBizError, match="超过名额|缩容|报名"):
- save_day(db, "t1", {"user_id": "op1"}, body)
- def test_resolve_duty_staff_role_id_by_fixed_id():
- db = MagicMock()
- role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID, role_name=DUTY_STAFF_ROLE_NAME, del_flag=0)
- q = MagicMock()
- q.filter.return_value.first.return_value = role
- db.query.return_value = q
- assert resolve_duty_staff_role_id(db, "shanxia2.0") == DUTY_STAFF_ROLE_ID
- def test_list_duty_staff_users_empty_when_no_role():
- db = MagicMock()
- q = MagicMock()
- q.filter.return_value.first.return_value = None # resolve role → None twice
- db.query.return_value = q
- assert list_duty_staff_users(db, "t1") == []
- def test_assert_user_is_duty_staff_rejects_without_role():
- db = MagicMock()
- role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
- role_q = MagicMock()
- role_q.filter.return_value.first.return_value = role
- ur_q = MagicMock()
- ur_q.filter.return_value.first.return_value = None
- def query_side_effect(model):
- name = getattr(model, "__name__", "")
- if name == "SysRole":
- return role_q
- if name == "SysUserRole":
- return ur_q
- return MagicMock()
- db.query.side_effect = query_side_effect
- with pytest.raises(DutyBizError, match="值班人员"):
- assert_user_is_duty_staff(db, "t1", "u1")
- def test_grab_rejects_non_duty_staff():
- db = MagicMock()
- day = SimpleNamespace(
- id="day1",
- tenant_no="t1",
- link_status=LINK_OPEN,
- duty_date=date(2026, 8, 20),
- access_token="tok",
- del_flag=0,
- )
- user = SimpleNamespace(
- id="u1",
- tenant_no="t1",
- nick_name="李四",
- user_no="lisi",
- status="1",
- del_flag=0,
- )
- day_q = MagicMock()
- day_q.filter.return_value.with_for_update.return_value.first.return_value = day
- user_q = MagicMock()
- user_q.filter.return_value.first.return_value = user
- role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
- role_q = MagicMock()
- role_q.filter.return_value.first.return_value = role
- ur_q = MagicMock()
- ur_q.filter.return_value.first.return_value = None
- def query_side_effect(model):
- name = getattr(model, "__name__", "")
- if name == "DutyDay":
- return day_q
- if name == "SysUser":
- return user_q
- if name == "SysRole":
- return role_q
- if name == "SysUserRole":
- return ur_q
- return MagicMock()
- db.query.side_effect = query_side_effect
- with pytest.raises(DutyBizError, match="值班人员"):
- grab(db, "tok", SHIFT_EVENING, "u1")
- def test_save_day_grandfathers_existing_assign_without_role():
- """历史指定用户无值班人员角色时,原样再保存不得因角色校验失败。"""
- db = MagicMock()
- day = SimpleNamespace(
- id="day1",
- duty_date=date(2026, 8, 20),
- access_token="tok",
- link_status=LINK_OPEN,
- tenant_no="t1",
- del_flag=0,
- update_by=None,
- update_time=None,
- )
- slot = SimpleNamespace(
- id="slot1",
- duty_day_id="day1",
- shift_type=SHIFT_EVENING,
- capacity=2,
- del_flag=0,
- order_num=1,
- update_by=None,
- update_time=None,
- )
- old_assign = SimpleNamespace(
- id="a_old",
- duty_slot_id="slot1",
- source=SOURCE_ASSIGN,
- user_id="u_old",
- person_name="老员工",
- del_flag=0,
- update_by=None,
- update_time=None,
- )
- day_q = MagicMock()
- day_q.filter.return_value.first.return_value = day
- slot_list_q = MagicMock()
- slot_list_q.filter.return_value.all.return_value = [slot]
- role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
- role_q = MagicMock()
- role_q.filter.return_value.first.return_value = role
- ur_q = MagicMock()
- ur_q.filter.return_value.first.return_value = None # 无角色关联
- user_q = MagicMock()
- user_q.filter.return_value.first.return_value = SimpleNamespace(
- id="u_old", tenant_no="t1", del_flag=0, status="1"
- )
- def query_side_effect(model):
- name = getattr(model, "__name__", "")
- if name == "DutyDay":
- return day_q
- if name == "DutySlot":
- return slot_list_q
- if name == "DutyAssignment":
- m = MagicMock()
- m.filter.return_value.count.return_value = 1
- m.filter.return_value.all.return_value = [old_assign]
- return m
- if name == "SysRole":
- return role_q
- if name == "SysUserRole":
- return ur_q
- if name == "SysUser":
- return user_q
- return MagicMock()
- db.query.side_effect = query_side_effect
- body = DutyDaySaveBody(
- dutyDate=date(2026, 8, 20),
- slots=[
- DutySlotSaveIn(
- shiftType=SHIFT_EVENING,
- capacity=2,
- assignments=[
- DutyAssignmentIn(source="assign", userId="u_old", personName="老员工")
- ],
- )
- ],
- )
- # 不得因「无值班人员角色」抛错;序列化侧 mock 足够跑完即可
- result = save_day(db, "t1", {"user_id": "op1"}, body)
- assert result is not None
- db.commit.assert_called()
- def test_save_day_rejects_new_assign_without_role():
- """新指定用户无值班人员角色时必须拒绝。"""
- db = MagicMock()
- day = SimpleNamespace(
- id="day1",
- duty_date=date(2026, 8, 20),
- access_token="tok",
- link_status=LINK_OPEN,
- tenant_no="t1",
- del_flag=0,
- update_by=None,
- update_time=None,
- )
- slot = SimpleNamespace(
- id="slot1",
- duty_day_id="day1",
- shift_type=SHIFT_EVENING,
- capacity=2,
- del_flag=0,
- order_num=1,
- update_by=None,
- update_time=None,
- )
- old_assign = SimpleNamespace(
- id="a_old",
- duty_slot_id="slot1",
- source=SOURCE_ASSIGN,
- user_id="u_old",
- person_name="老员工",
- del_flag=0,
- update_by=None,
- update_time=None,
- )
- day_q = MagicMock()
- day_q.filter.return_value.first.return_value = day
- slot_list_q = MagicMock()
- slot_list_q.filter.return_value.all.return_value = [slot]
- role = SimpleNamespace(id=DUTY_STAFF_ROLE_ID)
- role_q = MagicMock()
- role_q.filter.return_value.first.return_value = role
- ur_q = MagicMock()
- ur_q.filter.return_value.first.return_value = None
- user_q = MagicMock()
- user_q.filter.return_value.first.return_value = SimpleNamespace(
- id="u_new", tenant_no="t1", del_flag=0, status="1"
- )
- def query_side_effect(model):
- name = getattr(model, "__name__", "")
- if name == "DutyDay":
- return day_q
- if name == "DutySlot":
- return slot_list_q
- if name == "DutyAssignment":
- m = MagicMock()
- m.filter.return_value.count.return_value = 1
- m.filter.return_value.all.return_value = [old_assign]
- return m
- if name == "SysRole":
- return role_q
- if name == "SysUserRole":
- return ur_q
- if name == "SysUser":
- return user_q
- return MagicMock()
- db.query.side_effect = query_side_effect
- body = DutyDaySaveBody(
- dutyDate=date(2026, 8, 20),
- slots=[
- DutySlotSaveIn(
- shiftType=SHIFT_EVENING,
- capacity=2,
- assignments=[
- DutyAssignmentIn(source="assign", userId="u_new", personName="新员工")
- ],
- )
- ],
- )
- with pytest.raises(DutyBizError, match="值班人员"):
- save_day(db, "t1", {"user_id": "op1"}, body)
|