oa_leave_hours.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. """请假套件:双段工时重叠小时(与前端 oa-leave-hours.ts 对齐)"""
  2. from __future__ import annotations
  3. import re
  4. from datetime import datetime, timedelta
  5. from typing import Any, Dict, List, Optional, Tuple
  6. _DT_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})$")
  7. _HM_RE = re.compile(r"^(\d{1,2}):(\d{2})$")
  8. DEFAULT_LEAVE_WORK_HOURS = {
  9. "morningStart": "08:30",
  10. "morningEnd": "12:00",
  11. "afternoonStart": "13:00",
  12. "afternoonEnd": "17:30",
  13. }
  14. MANUAL_PREFIX = "__manual_"
  15. def _parse_hm(hm: Any) -> Optional[int]:
  16. m = _HM_RE.match(str(hm or "").strip())
  17. if not m:
  18. return None
  19. h, mi = int(m.group(1)), int(m.group(2))
  20. if h < 0 or h > 23 or mi < 0 or mi > 59:
  21. return None
  22. return h * 60 + mi
  23. def _parse_dt(s: Any) -> Optional[datetime]:
  24. m = _DT_RE.match(str(s or "").strip())
  25. if not m:
  26. return None
  27. try:
  28. return datetime(
  29. int(m.group(1)),
  30. int(m.group(2)),
  31. int(m.group(3)),
  32. int(m.group(4)),
  33. int(m.group(5)),
  34. )
  35. except ValueError:
  36. return None
  37. def _overlap_minutes_on_day(
  38. day: datetime,
  39. range_start: datetime,
  40. range_end: datetime,
  41. work_hours: Dict[str, str],
  42. ) -> float:
  43. day0 = datetime(day.year, day.month, day.day)
  44. am_a = _parse_hm(work_hours.get("morningStart"))
  45. am_b = _parse_hm(work_hours.get("morningEnd"))
  46. pm_a = _parse_hm(work_hours.get("afternoonStart"))
  47. pm_b = _parse_hm(work_hours.get("afternoonEnd"))
  48. if None in (am_a, am_b, pm_a, pm_b):
  49. return 0.0
  50. if am_a >= am_b or pm_a >= pm_b:
  51. return 0.0
  52. segments = [
  53. (day0 + timedelta(minutes=am_a), day0 + timedelta(minutes=am_b)),
  54. (day0 + timedelta(minutes=pm_a), day0 + timedelta(minutes=pm_b)),
  55. ]
  56. total = 0.0
  57. for a, b in segments:
  58. lo = max(range_start, a)
  59. hi = min(range_end, b)
  60. if hi > lo:
  61. total += (hi - lo).total_seconds() / 60.0
  62. return total
  63. def calc_leave_hours(
  64. start_str: Any,
  65. end_str: Any,
  66. work_hours: Optional[Dict[str, str]] = None,
  67. ) -> Optional[float]:
  68. """返回 1 位小数小时;非法或 end<=start 返回 None。"""
  69. wh = dict(DEFAULT_LEAVE_WORK_HOURS)
  70. if isinstance(work_hours, dict):
  71. wh.update({k: str(work_hours.get(k) or wh[k]) for k in DEFAULT_LEAVE_WORK_HOURS})
  72. start = _parse_dt(start_str)
  73. end = _parse_dt(end_str)
  74. if not start or not end or end <= start:
  75. return None
  76. minutes = 0.0
  77. cursor = datetime(start.year, start.month, start.day)
  78. last = datetime(end.year, end.month, end.day)
  79. while cursor <= last:
  80. minutes += _overlap_minutes_on_day(cursor, start, end, wh)
  81. cursor += timedelta(days=1)
  82. return round(minutes / 60.0, 1)
  83. def _extract_rules(form_schema: Any) -> List[Dict[str, Any]]:
  84. if isinstance(form_schema, list):
  85. return [r for r in form_schema if isinstance(r, dict)]
  86. if isinstance(form_schema, dict):
  87. rules = form_schema.get("rule") or form_schema.get("rules") or form_schema.get("fields")
  88. if isinstance(rules, list):
  89. return [r for r in rules if isinstance(r, dict)]
  90. if form_schema.get("type") or form_schema.get("field"):
  91. return [form_schema]
  92. return []
  93. def _walk(rules: List[Dict[str, Any]], out: List[Dict[str, Any]]) -> None:
  94. for r in rules:
  95. if not isinstance(r, dict):
  96. continue
  97. out.append(r)
  98. children = r.get("children")
  99. if isinstance(children, list):
  100. _walk([c for c in children if isinstance(c, dict)], out)
  101. props = r.get("props") if isinstance(r.get("props"), dict) else {}
  102. nested = props.get("rule")
  103. if isinstance(nested, list):
  104. _walk([c for c in nested if isinstance(c, dict)], out)
  105. def _kit_child_fields(kit: Dict[str, Any]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
  106. """返回 (start_field, end_field, duration_field)。"""
  107. props = kit.get("props") if isinstance(kit.get("props"), dict) else {}
  108. start_f = props.get("startField")
  109. end_f = props.get("endField")
  110. dur_f = props.get("durationField")
  111. if start_f and end_f and dur_f:
  112. return str(start_f), str(end_f), str(dur_f)
  113. children = kit.get("children")
  114. if not isinstance(children, list):
  115. children = []
  116. p_rule = props.get("rule")
  117. if isinstance(p_rule, list):
  118. children = p_rule
  119. date_fields: List[str] = []
  120. duration_field: Optional[str] = None
  121. for c in children:
  122. if not isinstance(c, dict):
  123. continue
  124. field = c.get("field")
  125. if not field:
  126. continue
  127. t = str(c.get("type") or "")
  128. if t in ("datePicker", "date", "timePicker", "time"):
  129. date_fields.append(str(field))
  130. elif t in ("inputNumber", "number") and duration_field is None:
  131. duration_field = str(field)
  132. elif t in ("inputNumber", "number") and "时长" in str(c.get("title") or ""):
  133. duration_field = str(field)
  134. start_field = date_fields[0] if len(date_fields) >= 1 else None
  135. end_field = date_fields[1] if len(date_fields) >= 2 else None
  136. return start_field, end_field, duration_field
  137. def _round1(v: Any) -> Optional[float]:
  138. try:
  139. n = float(v)
  140. except (TypeError, ValueError):
  141. return None
  142. if n < 0:
  143. return None
  144. return round(n, 1)
  145. def apply_leave_kit_hours(form_schema: Any, form_data: Dict[str, Any]) -> Dict[str, Any]:
  146. """
  147. 遍历 leaveKit:未手改则覆盖时长;已手改(form_data[__manual_<durationField>])则仅规范化一位小数。
  148. """
  149. data = dict(form_data or {})
  150. flat: List[Dict[str, Any]] = []
  151. _walk(_extract_rules(form_schema), flat)
  152. for rule in flat:
  153. if str(rule.get("type") or "") != "leaveKit":
  154. continue
  155. start_f, end_f, dur_f = _kit_child_fields(rule)
  156. if not start_f or not end_f or not dur_f:
  157. continue
  158. props = rule.get("props") if isinstance(rule.get("props"), dict) else {}
  159. wh = {
  160. "morningStart": str(props.get("morningStart") or DEFAULT_LEAVE_WORK_HOURS["morningStart"]),
  161. "morningEnd": str(props.get("morningEnd") or DEFAULT_LEAVE_WORK_HOURS["morningEnd"]),
  162. "afternoonStart": str(
  163. props.get("afternoonStart") or DEFAULT_LEAVE_WORK_HOURS["afternoonStart"]
  164. ),
  165. "afternoonEnd": str(props.get("afternoonEnd") or DEFAULT_LEAVE_WORK_HOURS["afternoonEnd"]),
  166. }
  167. manual_key = MANUAL_PREFIX + dur_f
  168. is_manual = bool(data.get(manual_key))
  169. if is_manual:
  170. rounded = _round1(data.get(dur_f))
  171. if rounded is not None:
  172. data[dur_f] = rounded
  173. continue
  174. hours = calc_leave_hours(data.get(start_f), data.get(end_f), wh)
  175. if hours is not None:
  176. data[dur_f] = hours
  177. return data
  178. def validate_leave_kit_form_values(form_schema: Any, form_data: Dict[str, Any]) -> List[str]:
  179. """提交校验:结束>=开始;手改时长合法。"""
  180. errors: List[str] = []
  181. data = form_data or {}
  182. flat: List[Dict[str, Any]] = []
  183. _walk(_extract_rules(form_schema), flat)
  184. for rule in flat:
  185. if str(rule.get("type") or "") != "leaveKit":
  186. continue
  187. start_f, end_f, dur_f = _kit_child_fields(rule)
  188. if not start_f or not end_f or not dur_f:
  189. continue
  190. start = _parse_dt(data.get(start_f))
  191. end = _parse_dt(data.get(end_f))
  192. if data.get(start_f) and data.get(end_f):
  193. if not start or not end:
  194. errors.append("请假起止时间格式须为 YYYY-MM-DD HH:mm")
  195. elif end <= start:
  196. errors.append("请假结束时间须晚于开始时间")
  197. if MANUAL_PREFIX + dur_f in data or data.get(MANUAL_PREFIX + dur_f):
  198. if data.get(dur_f) is not None and data.get(dur_f) != "":
  199. if _round1(data.get(dur_f)) is None:
  200. errors.append("请假时长须为非负数字(1 位小数)")
  201. return errors