| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719 |
- """值班排班服务单测"""
- 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_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)
|