feat(duty): persist work plans and times; relax grab shift types

Map invalid HH:mm to DutyBizError, serialize workPlans/timeLabel from dict,
ensure dict items on save, and allow custom shift types on public grab.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-20 21:14:36 +08:00
1 parent 4447a1e8e6
commit cd0491e601
3 files changed
+202 -8

No files matched your search

+23 -1
View File
@@ -11,7 +11,7 @@ from app.core.database import get_db
from app.core.menu_guard import require_admin_or_super_admin
from app.core.response import error_response, success_response
from app.crud import user as user_crud
from app.schemas.duty import DutyDaySaveBody, DutyLinkBody
from app.schemas.duty import DutyDaySaveBody, DutyDictEnsureBody, DutyLinkBody
from app.services import duty_schedule as duty_svc
from app.services.duty_schedule import DutyBizError
@@ -73,6 +73,28 @@ def duty_user_options(
return success_response(out)
@router.post("/dict/ensure")
def ensure_duty_dict(
body: DutyDictEnsureBody,
_user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
tenant_no = _user.get("tenant_no", "") or ""
user_id = _user.get("user_id") or _user.get("id", "") or ""
try:
data = duty_svc.ensure_dict_data(
db,
tenant_no=tenant_no,
dict_type=body.dictType,
label=body.label,
user_id=user_id,
)
db.commit()
return success_response(data, message="已保存")
except DutyBizError as exc:
return error_response(400, str(exc))
@router.post("/day")
def save_day(
body: DutyDaySaveBody,
+106 -7
View File
@@ -43,7 +43,10 @@ def parse_hhmm(value: Optional[str]) -> Optional[time]:
parts = raw.split(":")
if len(parts) < 2:
raise DutyBizError("时间格式须为 HH:mm")
h, m = int(parts[0]), int(parts[1])
try:
h, m = int(parts[0]), int(parts[1])
except ValueError as exc:
raise DutyBizError("时间格式须为 HH:mm") from exc
if not (0 <= h <= 23 and 0 <= m <= 59):
raise DutyBizError("时间格式须为 HH:mm")
return time(hour=h, minute=m)
@@ -198,15 +201,32 @@ def _assignment_dict(row: DutyAssignment) -> Dict[str, Any]:
}
def _slot_dict(slot: DutySlot, assignments: Sequence[DutyAssignment]) -> Dict[str, Any]:
def _slot_dict(
slot: DutySlot,
assignments: Sequence[DutyAssignment],
*,
shift_labels: Dict[str, str],
work_plan_labels: Dict[str, str],
) -> Dict[str, Any]:
active = [a for a in assignments if a.del_flag == 0]
plans = split_work_plans(getattr(slot, "work_plans", None))
cross = bool(getattr(slot, "cross_day", 0) or 0)
start = getattr(slot, "start_time", None)
end = getattr(slot, "end_time", None)
return {
"id": slot.id,
"shiftType": slot.shift_type,
"shiftLabel": SHIFT_LABELS.get(slot.shift_type, slot.shift_type),
"shiftLabel": shift_labels.get(slot.shift_type)
or SHIFT_LABELS.get(slot.shift_type, slot.shift_type),
"capacity": slot.capacity,
"taken": len(active),
"workPlans": plans,
"workPlanLabels": [work_plan_labels.get(v, v) for v in plans],
"remark": (slot.remark or "").strip() or None,
"startTime": format_hhmm(start),
"endTime": format_hhmm(end),
"crossDay": cross,
"timeLabel": format_time_label(start, end, cross),
"assignments": [_assignment_dict(a) for a in active],
}
@@ -215,9 +235,17 @@ def _day_dict(
day: DutyDay,
slots: Sequence[DutySlot],
assignments_by_slot: Dict[str, List[DutyAssignment]],
*,
shift_labels: Dict[str, str],
work_plan_labels: Dict[str, str],
) -> Dict[str, Any]:
slot_payload = [
_slot_dict(s, assignments_by_slot.get(s.id, []))
_slot_dict(
s,
assignments_by_slot.get(s.id, []),
shift_labels=shift_labels,
work_plan_labels=work_plan_labels,
)
for s in sorted(slots, key=lambda x: (x.order_num or 0, x.shift_type or ""))
if s.del_flag == 0
]
@@ -278,7 +306,15 @@ def _load_assignments_for_slots(
def _serialize_day(db: Session, day: DutyDay) -> Dict[str, Any]:
slots = _load_slots(db, day.id)
assigns = _load_assignments_for_slots(db, [s.id for s in slots])
return _day_dict(day, slots, assigns)
shift_labels = _load_dict_label_map(db, day.tenant_no, DICT_SHIFT_TYPE)
work_plan_labels = _load_dict_label_map(db, day.tenant_no, DICT_WORK_PLAN)
return _day_dict(
day,
slots,
assigns,
shift_labels=shift_labels,
work_plan_labels=work_plan_labels,
)
def empty_day_skeleton(duty_date: date) -> Dict[str, Any]:
@@ -430,6 +466,59 @@ def save_day(
order += 1
slot = existing_by_type.get(slot_in.shiftType)
slot_remark = (slot_in.remark or "").strip() or None
plans_raw = list(slot_in.workPlans or [])
for v in plans_raw:
v = (v or "").strip()
if not v:
continue
row = (
db.query(SysDictData)
.filter(
SysDictData.tenant_no == tenant_no,
SysDictData.dict_type == DICT_WORK_PLAN,
SysDictData.dict_value == v,
SysDictData.del_flag == 0,
)
.first()
)
if not row:
ensure_dict_data(
db,
tenant_no=tenant_no,
dict_type=DICT_WORK_PLAN,
label=v,
preferred_value=v,
user_id=user_id,
)
slot_work_plans = join_work_plans(plans_raw)
if slot_in.shiftType not in SHIFT_LABELS:
row = (
db.query(SysDictData)
.filter(
SysDictData.tenant_no == tenant_no,
SysDictData.dict_type == DICT_SHIFT_TYPE,
SysDictData.dict_value == slot_in.shiftType,
SysDictData.del_flag == 0,
)
.first()
)
if not row:
ensure_dict_data(
db,
tenant_no=tenant_no,
dict_type=DICT_SHIFT_TYPE,
label=SHIFT_LABELS.get(slot_in.shiftType, slot_in.shiftType),
preferred_value=slot_in.shiftType,
user_id=user_id,
)
start_t = parse_hhmm(slot_in.startTime)
end_t = parse_hhmm(slot_in.endTime)
if (start_t is None) ^ (end_t is None):
raise DutyBizError("值班时间须同时填写开始与结束,或全部留空")
cross = 1 if slot_in.crossDay else 0
# 已有 slot 上历史 assign 用户可再保存(D4);新建 slot 必须具备值班人员角色(D7)
grandfathered_user_ids: set = set()
if slot is None:
@@ -438,6 +527,10 @@ def save_day(
duty_day_id=day.id,
shift_type=slot_in.shiftType,
capacity=slot_in.capacity,
work_plans=slot_work_plans,
start_time=start_t,
end_time=end_t,
cross_day=cross,
remark=slot_remark,
tenant_no=tenant_no,
order_num=order,
@@ -476,6 +569,10 @@ def save_day(
f"超过名额 {slot_in.capacity},请先删人再缩容"
)
slot.capacity = slot_in.capacity
slot.work_plans = slot_work_plans
slot.start_time = start_t
slot.end_time = end_t
slot.cross_day = cross
slot.remark = slot_remark
slot.order_num = order
slot.update_by = user_id
@@ -673,7 +770,7 @@ def get_public_page(db: Session, token: str) -> Dict[str, Any]:
def grab(db: Session, token: str, shift_type: str, user_id: str) -> Dict[str, Any]:
shift_type = (shift_type or "").strip()
if shift_type not in SHIFT_LABELS:
if not shift_type:
raise DutyBizError("班次类型无效")
day = (
@@ -736,10 +833,12 @@ def grab(db: Session, token: str, shift_type: str, user_id: str) -> Dict[str, An
)
db.add(row)
db.commit()
shift_labels = _load_dict_label_map(db, day.tenant_no, DICT_SHIFT_TYPE)
return {
"dutyDate": day.duty_date.isoformat(),
"shiftType": shift_type,
"shiftLabel": SHIFT_LABELS.get(shift_type, shift_type),
"shiftLabel": shift_labels.get(shift_type)
or SHIFT_LABELS.get(shift_type, shift_type),
"personName": name,
"userId": user.id,
"status": "ok",
+73
View File
@@ -42,6 +42,11 @@ def test_parse_hhmm():
assert parse_hhmm("") is None
def test_parse_hhmm_invalid_raises():
with pytest.raises(DutyBizError, match="时间格式"):
parse_hhmm("08:xx")
def test_format_time_label_same_day():
assert format_time_label(parse_hhmm("08:00"), parse_hhmm("18:00"), False) == "08:00–18:00"
@@ -180,6 +185,74 @@ def test_grab_rejects_when_full():
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(