Files
shanxia2.0/backend/tests/test_duty_schedule.py
T
2026-09-09 13:03:02 +08:00

1008 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""值班排班服务单测"""
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)