diff --git a/backend/app/api/duty.py b/backend/app/api/duty.py index 33c1993ce..f137f42f4 100644 --- a/backend/app/api/duty.py +++ b/backend/app/api/duty.py @@ -88,6 +88,7 @@ def ensure_duty_dict( dict_type=body.dictType, label=body.label, user_id=user_id, + preferred_value=body.preferredValue, ) db.commit() return success_response(data, message="已保存") diff --git a/backend/app/models/duty_models.py b/backend/app/models/duty_models.py index 35607ba16..1ff67a375 100644 --- a/backend/app/models/duty_models.py +++ b/backend/app/models/duty_models.py @@ -15,10 +15,14 @@ SHIFT_LABELS = { } DICT_SHIFT_TYPE = "duty_shift_type" DICT_WORK_PLAN = "duty_work_plan" +DICT_TIME_RANGE = "duty_time_range" DEFAULT_SHIFT_TIMES = { SHIFT_MORNING: ("08:00", "18:00", 0), SHIFT_EVENING: ("18:00", "07:00", 1), } +# 预置时段 dict_value:start|end|cross_day +TIME_RANGE_DAY = "08:00|18:00|0" +TIME_RANGE_NIGHT = "18:00|07:00|1" LINK_OPEN = "open" LINK_CLOSED = "closed" SOURCE_ASSIGN = "assign" diff --git a/backend/app/schemas/duty.py b/backend/app/schemas/duty.py index 08602696a..e9434558b 100644 --- a/backend/app/schemas/duty.py +++ b/backend/app/schemas/duty.py @@ -52,5 +52,6 @@ class DutyGrabBody(BaseModel): class DutyDictEnsureBody(BaseModel): - dictType: Literal["duty_shift_type", "duty_work_plan"] + dictType: Literal["duty_shift_type", "duty_work_plan", "duty_time_range"] label: str = Field(min_length=1, max_length=100) + preferredValue: Optional[str] = Field(default=None, max_length=64) diff --git a/backend/app/services/duty_schedule.py b/backend/app/services/duty_schedule.py index b5cde08b9..ca42a6fe5 100644 --- a/backend/app/services/duty_schedule.py +++ b/backend/app/services/duty_schedule.py @@ -2,6 +2,7 @@ from __future__ import annotations import calendar +import re import secrets from datetime import date, datetime, time from typing import Any, Dict, List, Optional, Sequence @@ -16,6 +17,7 @@ from app.models.duty_models import ( DUTY_STAFF_ROLE_ID, DUTY_STAFF_ROLE_NAME, DICT_SHIFT_TYPE, + DICT_TIME_RANGE, DICT_WORK_PLAN, LINK_CLOSED, LINK_OPEN, @@ -94,6 +96,44 @@ def build_dict_value_from_label(label: str) -> str: return base +def _pad_hhmm(h: int, m: int) -> str: + return f"{h:02d}:{m:02d}" + + +def parse_time_range_input(raw: str) -> Dict[str, Any]: + """解析「09:00-17:00」「18:00-次日07:00」等,返回 start/end/crossDay/label/value。""" + text = (raw or "").strip().replace("—", "-").replace("–", "-").replace("~", "-") + text = re.sub(r"\s+", "", text) + if not text: + raise DutyBizError("时段不能为空") + m = re.match( + r"^(\d{1,2}):(\d{2})[-至到](次日)?(\d{1,2}):(\d{2})$", + text, + ) + if not m: + raise DutyBizError("时段格式须为 09:00-17:00 或 18:00-次日07:00") + sh, sm, cross_flag, eh, em = m.groups() + try: + start_h, start_m = int(sh), int(sm) + end_h, end_m = int(eh), int(em) + except ValueError as exc: + raise DutyBizError("时段格式须为 09:00-17:00 或 18:00-次日07:00") from exc + if not (0 <= start_h <= 23 and 0 <= start_m <= 59 and 0 <= end_h <= 23 and 0 <= end_m <= 59): + raise DutyBizError("时段格式须为 09:00-17:00 或 18:00-次日07:00") + start = _pad_hhmm(start_h, start_m) + end = _pad_hhmm(end_h, end_m) + cross = 1 if cross_flag or end < start else 0 + label = f"{start}–次日{end}" if cross else f"{start}–{end}" + value = f"{start}|{end}|{cross}" + return { + "startTime": start, + "endTime": end, + "crossDay": bool(cross), + "label": label, + "value": value, + } + + def ensure_dict_data( db: Session, *, @@ -106,9 +146,15 @@ def ensure_dict_data( label = (label or "").strip() if not label: raise DutyBizError("字典标签不能为空") - if dict_type not in (DICT_SHIFT_TYPE, DICT_WORK_PLAN): + if dict_type not in (DICT_SHIFT_TYPE, DICT_WORK_PLAN, DICT_TIME_RANGE): raise DutyBizError("不支持的字典类型") + preferred = (preferred_value or "").strip() or None + if dict_type == DICT_TIME_RANGE: + parsed = parse_time_range_input(label) + label = parsed["label"] + preferred = preferred or parsed["value"] + existing_label = ( db.query(SysDictData) .filter( @@ -122,7 +168,21 @@ def ensure_dict_data( if existing_label: return {"label": existing_label.dict_label, "value": existing_label.dict_value} - value = (preferred_value or "").strip() or build_dict_value_from_label(label) + if preferred: + by_value = ( + db.query(SysDictData) + .filter( + SysDictData.tenant_no == tenant_no, + SysDictData.dict_type == dict_type, + SysDictData.dict_value == preferred, + SysDictData.del_flag == 0, + ) + .first() + ) + if by_value: + return {"label": by_value.dict_label, "value": by_value.dict_value} + + value = preferred or build_dict_value_from_label(label) for _ in range(8): clash = ( db.query(SysDictData) @@ -850,6 +910,7 @@ __all__ = [ "SHIFT_LABELS", "DutyBizError", "parse_hhmm", + "parse_time_range_input", "format_hhmm", "format_time_label", "split_work_plans", diff --git a/backend/scripts/migrate_20260820_duty_time_range_dict.sql b/backend/scripts/migrate_20260820_duty_time_range_dict.sql new file mode 100644 index 000000000..256582bb2 --- /dev/null +++ b/backend/scripts/migrate_20260820_duty_time_range_dict.sql @@ -0,0 +1,74 @@ +-- backend/scripts/migrate_20260820_duty_time_range_dict.sql +-- 值班排班:值班时段字典(可单选 + allow-create) +-- 可重复执行 + +SET NAMES utf8mb4; + +INSERT INTO `sys_dict_type` ( + `id`, `dict_name`, `dict_type`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + 'DUTYDICTTYPE00000000000000000003', + '值班时段', + 'duty_time_range', + 'shanxia2.0', + 92, + '值班排班时段选项', + '', + '', + NOW(), + '', + NOW(), + 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_dict_type` + WHERE `dict_type` = 'duty_time_range' + AND `tenant_no` = 'shanxia2.0' + AND `del_flag` = 0 +); + +INSERT INTO `sys_dict_data` ( + `id`, `dict_label`, `dict_value`, `tag_type`, `dict_type`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT * FROM ( + SELECT + 'DUTYDICTDATA0000000000000000006' AS id, + '08:00–18:00' AS dict_label, + '08:00|18:00|0' AS dict_value, + '' AS tag_type, + 'duty_time_range' AS dict_type, + 'shanxia2.0' AS tenant_no, + 1 AS order_num, + NULL AS remark, + '' AS create_by, + '' AS create_dept_id, + NOW() AS create_time, + '' AS update_by, + NOW() AS update_time, + 0 AS del_flag + UNION ALL SELECT + 'DUTYDICTDATA0000000000000000007', + '18:00–次日07:00', + '18:00|07:00|1', + '', + 'duty_time_range', + 'shanxia2.0', + 2, + NULL, + '', + '', + NOW(), + '', + NOW(), + 0 +) AS t +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_dict_data` d + WHERE d.tenant_no = t.tenant_no + AND d.dict_type = t.dict_type + AND d.dict_value = t.dict_value + AND d.del_flag = 0 +); diff --git a/backend/tests/test_duty_schedule.py b/backend/tests/test_duty_schedule.py index fc6bc8b22..8d168e1ff 100644 --- a/backend/tests/test_duty_schedule.py +++ b/backend/tests/test_duty_schedule.py @@ -47,6 +47,28 @@ def test_parse_hhmm_invalid_raises(): 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" diff --git a/web-admin/src/api/duty.ts b/web-admin/src/api/duty.ts index 1d9ae3130..5773652df 100644 --- a/web-admin/src/api/duty.ts +++ b/web-admin/src/api/duty.ts @@ -91,8 +91,9 @@ export function fetchDutyUserOptions() { } export function ensureDutyDict(data: { - dictType: 'duty_shift_type' | 'duty_work_plan' + dictType: 'duty_shift_type' | 'duty_work_plan' | 'duty_time_range' label: string + preferredValue?: string }) { return request.post<{ label: string; value: string }>({ url: '/web/admin/duty/dict/ensure', diff --git a/web-admin/src/views/duty/schedule/index.vue b/web-admin/src/views/duty/schedule/index.vue index e90d834e1..c77c8ac1b 100644 --- a/web-admin/src/views/duty/schedule/index.vue +++ b/web-admin/src/views/duty/schedule/index.vue @@ -197,7 +197,7 @@ v-model="dialogVisible" class="dialog-form-layout duty-day-dialog" :title="`配置 · ${editingDate}`" - width="880px" + width="960px" align-center destroy-on-close :close-on-click-modal="false" @@ -221,63 +221,29 @@