|
@@ -3,17 +3,20 @@ from __future__ import annotations
|
|
|
|
|
|
|
|
import calendar
|
|
import calendar
|
|
|
import secrets
|
|
import secrets
|
|
|
-from datetime import date, datetime
|
|
|
|
|
|
|
+from datetime import date, datetime, time
|
|
|
from typing import Any, Dict, List, Optional, Sequence
|
|
from typing import Any, Dict, List, Optional, Sequence
|
|
|
|
|
|
|
|
|
|
+from pypinyin import Style, lazy_pinyin
|
|
|
from sqlalchemy import and_
|
|
from sqlalchemy import and_
|
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
|
from app.core.config import settings
|
|
from app.core.config import settings
|
|
|
-from app.core.utils import generate_id
|
|
|
|
|
|
|
+from app.core.utils import generate_id, generate_uuid
|
|
|
from app.models.duty_models import (
|
|
from app.models.duty_models import (
|
|
|
DUTY_STAFF_ROLE_ID,
|
|
DUTY_STAFF_ROLE_ID,
|
|
|
DUTY_STAFF_ROLE_NAME,
|
|
DUTY_STAFF_ROLE_NAME,
|
|
|
|
|
+ DICT_SHIFT_TYPE,
|
|
|
|
|
+ DICT_WORK_PLAN,
|
|
|
LINK_CLOSED,
|
|
LINK_CLOSED,
|
|
|
LINK_OPEN,
|
|
LINK_OPEN,
|
|
|
SHIFT_LABELS,
|
|
SHIFT_LABELS,
|
|
@@ -23,7 +26,7 @@ from app.models.duty_models import (
|
|
|
DutyDay,
|
|
DutyDay,
|
|
|
DutySlot,
|
|
DutySlot,
|
|
|
)
|
|
)
|
|
|
-from app.models.models import SysRole, SysUser, SysUserRole
|
|
|
|
|
|
|
+from app.models.models import SysDictData, SysRole, SysUser, SysUserRole
|
|
|
from app.schemas.duty import DutyDaySaveBody, DutySlotSaveIn
|
|
from app.schemas.duty import DutyDaySaveBody, DutySlotSaveIn
|
|
|
|
|
|
|
|
PERSON_NAME_MAX_LEN = 20
|
|
PERSON_NAME_MAX_LEN = 20
|
|
@@ -33,6 +36,141 @@ class DutyBizError(ValueError):
|
|
|
"""业务错误(可映射为 HTTP 400)"""
|
|
"""业务错误(可映射为 HTTP 400)"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def parse_hhmm(value: Optional[str]) -> Optional[time]:
|
|
|
|
|
+ raw = (value or "").strip()
|
|
|
|
|
+ if not raw:
|
|
|
|
|
+ return None
|
|
|
|
|
+ parts = raw.split(":")
|
|
|
|
|
+ if len(parts) < 2:
|
|
|
|
|
+ raise DutyBizError("时间格式须为 HH:mm")
|
|
|
|
|
+ h, m = int(parts[0]), int(parts[1])
|
|
|
|
|
+ if not (0 <= h <= 23 and 0 <= m <= 59):
|
|
|
|
|
+ raise DutyBizError("时间格式须为 HH:mm")
|
|
|
|
|
+ return time(hour=h, minute=m)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def format_hhmm(t: Optional[time]) -> Optional[str]:
|
|
|
|
|
+ if t is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+ return f"{t.hour:02d}:{t.minute:02d}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def format_time_label(
|
|
|
|
|
+ start: Optional[time], end: Optional[time], cross_day: bool
|
|
|
|
|
+) -> Optional[str]:
|
|
|
|
|
+ if start is None or end is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+ s, e = format_hhmm(start), format_hhmm(end)
|
|
|
|
|
+ if cross_day:
|
|
|
|
|
+ return f"{s}–次日{e}"
|
|
|
|
|
+ return f"{s}–{e}"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def split_work_plans(raw: Optional[str]) -> List[str]:
|
|
|
|
|
+ if not raw:
|
|
|
|
|
+ return []
|
|
|
|
|
+ return [p.strip() for p in str(raw).replace(",", ",").split(",") if p.strip()]
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def join_work_plans(values: Sequence[str]) -> Optional[str]:
|
|
|
|
|
+ parts = [v.strip() for v in values if (v or "").strip()]
|
|
|
|
|
+ return ",".join(parts) if parts else None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def build_dict_value_from_label(label: str) -> str:
|
|
|
|
|
+ text = (label or "").strip()
|
|
|
|
|
+ parts: List[str] = []
|
|
|
|
|
+ for ch in text:
|
|
|
|
|
+ if "\u4e00" <= ch <= "\u9fff":
|
|
|
|
|
+ pys = lazy_pinyin(ch, style=Style.NORMAL)
|
|
|
|
|
+ if pys and pys[0]:
|
|
|
|
|
+ parts.append(str(pys[0]).lower())
|
|
|
|
|
+ elif ch.isalnum():
|
|
|
|
|
+ parts.append(ch.lower())
|
|
|
|
|
+ base = "".join(parts)[:32] or "item"
|
|
|
|
|
+ return base
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def ensure_dict_data(
|
|
|
|
|
+ db: Session,
|
|
|
|
|
+ *,
|
|
|
|
|
+ tenant_no: str,
|
|
|
|
|
+ dict_type: str,
|
|
|
|
|
+ label: str,
|
|
|
|
|
+ user_id: str = "",
|
|
|
|
|
+ preferred_value: Optional[str] = None,
|
|
|
|
|
+) -> Dict[str, str]:
|
|
|
|
|
+ label = (label or "").strip()
|
|
|
|
|
+ if not label:
|
|
|
|
|
+ raise DutyBizError("字典标签不能为空")
|
|
|
|
|
+ if dict_type not in (DICT_SHIFT_TYPE, DICT_WORK_PLAN):
|
|
|
|
|
+ raise DutyBizError("不支持的字典类型")
|
|
|
|
|
+
|
|
|
|
|
+ existing_label = (
|
|
|
|
|
+ db.query(SysDictData)
|
|
|
|
|
+ .filter(
|
|
|
|
|
+ SysDictData.tenant_no == tenant_no,
|
|
|
|
|
+ SysDictData.dict_type == dict_type,
|
|
|
|
|
+ SysDictData.dict_label == label,
|
|
|
|
|
+ SysDictData.del_flag == 0,
|
|
|
|
|
+ )
|
|
|
|
|
+ .first()
|
|
|
|
|
+ )
|
|
|
|
|
+ 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)
|
|
|
|
|
+ for _ in range(8):
|
|
|
|
|
+ clash = (
|
|
|
|
|
+ db.query(SysDictData)
|
|
|
|
|
+ .filter(
|
|
|
|
|
+ SysDictData.tenant_no == tenant_no,
|
|
|
|
|
+ SysDictData.dict_type == dict_type,
|
|
|
|
|
+ SysDictData.dict_value == value,
|
|
|
|
|
+ SysDictData.del_flag == 0,
|
|
|
|
|
+ )
|
|
|
|
|
+ .first()
|
|
|
|
|
+ )
|
|
|
|
|
+ if not clash:
|
|
|
|
|
+ break
|
|
|
|
|
+ value = f"{value[:27]}_{secrets.token_hex(2)}"
|
|
|
|
|
+ else:
|
|
|
|
|
+ raise DutyBizError("无法生成唯一字典值")
|
|
|
|
|
+
|
|
|
|
|
+ obj = SysDictData(
|
|
|
|
|
+ id=generate_uuid(),
|
|
|
|
|
+ dict_label=label,
|
|
|
|
|
+ dict_value=value,
|
|
|
|
|
+ dict_type=dict_type,
|
|
|
|
|
+ tag_type="",
|
|
|
|
|
+ tenant_no=tenant_no,
|
|
|
|
|
+ order_num=0,
|
|
|
|
|
+ remark=None,
|
|
|
|
|
+ create_by=user_id or None,
|
|
|
|
|
+ create_dept_id="",
|
|
|
|
|
+ create_time=datetime.now(),
|
|
|
|
|
+ update_by=user_id or None,
|
|
|
|
|
+ update_time=datetime.now(),
|
|
|
|
|
+ del_flag=0,
|
|
|
|
|
+ )
|
|
|
|
|
+ db.add(obj)
|
|
|
|
|
+ db.flush()
|
|
|
|
|
+ return {"label": obj.dict_label, "value": obj.dict_value}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _load_dict_label_map(db: Session, tenant_no: str, dict_type: str) -> Dict[str, str]:
|
|
|
|
|
+ rows = (
|
|
|
|
|
+ db.query(SysDictData)
|
|
|
|
|
+ .filter(
|
|
|
|
|
+ SysDictData.tenant_no == tenant_no,
|
|
|
|
|
+ SysDictData.dict_type == dict_type,
|
|
|
|
|
+ SysDictData.del_flag == 0,
|
|
|
|
|
+ )
|
|
|
|
|
+ .all()
|
|
|
|
|
+ )
|
|
|
|
|
+ return {r.dict_value: r.dict_label for r in rows}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def normalize_person_name(name: str) -> str:
|
|
def normalize_person_name(name: str) -> str:
|
|
|
raw = (name or "").strip()
|
|
raw = (name or "").strip()
|
|
|
if not raw:
|
|
if not raw:
|
|
@@ -612,6 +750,13 @@ def grab(db: Session, token: str, shift_type: str, user_id: str) -> Dict[str, An
|
|
|
__all__ = [
|
|
__all__ = [
|
|
|
"SHIFT_LABELS",
|
|
"SHIFT_LABELS",
|
|
|
"DutyBizError",
|
|
"DutyBizError",
|
|
|
|
|
+ "parse_hhmm",
|
|
|
|
|
+ "format_hhmm",
|
|
|
|
|
+ "format_time_label",
|
|
|
|
|
+ "split_work_plans",
|
|
|
|
|
+ "join_work_plans",
|
|
|
|
|
+ "build_dict_value_from_label",
|
|
|
|
|
+ "ensure_dict_data",
|
|
|
"normalize_person_name",
|
|
"normalize_person_name",
|
|
|
"new_access_token",
|
|
"new_access_token",
|
|
|
"build_public_url",
|
|
"build_public_url",
|