oa_trip_days.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. """出差套件:半日制行程时长(与前端 oa-trip-days.ts 对齐)"""
  2. from __future__ import annotations
  3. import re
  4. from datetime import datetime, timezone
  5. from typing import Any, Dict, List, Optional, Tuple
  6. _DATE_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
  7. MANUAL_PREFIX = "__manual_"
  8. _LEG_REQUIRED_FIELDS = (
  9. "transport",
  10. "tripType",
  11. "fromCity",
  12. "toCity",
  13. "startDate",
  14. "startHalf",
  15. "endDate",
  16. "endHalf",
  17. )
  18. def _day_index(date_str: Any) -> Optional[float]:
  19. m = _DATE_RE.match(str(date_str or "").strip())
  20. if not m:
  21. return None
  22. try:
  23. y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
  24. dt = datetime(y, mo, d, tzinfo=timezone.utc)
  25. except ValueError:
  26. return None
  27. return dt.timestamp() / 86400.0
  28. def _start_offset(half: Any) -> Optional[float]:
  29. h = str(half or "").strip()
  30. if h == "上午":
  31. return 0.0
  32. if h == "下午":
  33. return 0.5
  34. return None
  35. def _end_offset(half: Any) -> Optional[float]:
  36. h = str(half or "").strip()
  37. if h == "上午":
  38. return 0.5
  39. if h == "下午":
  40. return 1.0
  41. return None
  42. def calc_trip_leg_days(
  43. start_date: Any,
  44. start_half: Any,
  45. end_date: Any,
  46. end_half: Any,
  47. ) -> Optional[float]:
  48. """返回 0.5 步进天数;非法或 end<=start 返回 None。"""
  49. ds = _day_index(start_date)
  50. de = _day_index(end_date)
  51. so = _start_offset(start_half)
  52. eo = _end_offset(end_half)
  53. if ds is None or de is None or so is None or eo is None:
  54. return None
  55. duration = de + eo - (ds + so)
  56. if not (duration > 0):
  57. return None
  58. return round(duration * 2) / 2
  59. def sum_trip_leg_days(legs: Any) -> float:
  60. total = 0.0
  61. for leg in legs or []:
  62. if not isinstance(leg, dict):
  63. continue
  64. try:
  65. n = float(leg.get("duration"))
  66. except (TypeError, ValueError):
  67. continue
  68. if n >= 0:
  69. total += n
  70. return round(total * 2) / 2
  71. def is_half_day_multiple(n: Any) -> bool:
  72. try:
  73. v = float(n)
  74. except (TypeError, ValueError):
  75. return False
  76. if v < 0:
  77. return False
  78. return abs(v * 2 - round(v * 2)) < 1e-9
  79. def _extract_rules(form_schema: Any) -> List[Dict[str, Any]]:
  80. if isinstance(form_schema, list):
  81. return [r for r in form_schema if isinstance(r, dict)]
  82. if isinstance(form_schema, dict):
  83. rules = form_schema.get("rule") or form_schema.get("rules") or form_schema.get("fields")
  84. if isinstance(rules, list):
  85. return [r for r in rules if isinstance(r, dict)]
  86. if form_schema.get("type") or form_schema.get("field"):
  87. return [form_schema]
  88. return []
  89. def _walk(rules: List[Dict[str, Any]], out: List[Dict[str, Any]]) -> None:
  90. for r in rules:
  91. if not isinstance(r, dict):
  92. continue
  93. out.append(r)
  94. children = r.get("children")
  95. if isinstance(children, list):
  96. _walk([c for c in children if isinstance(c, dict)], out)
  97. props = r.get("props") if isinstance(r.get("props"), dict) else {}
  98. nested = props.get("rule")
  99. if isinstance(nested, list):
  100. _walk([c for c in nested if isinstance(c, dict)], out)
  101. def _kit_trip_fields(kit: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
  102. """返回 (trip_list_field, trip_days_field)。"""
  103. props = kit.get("props") if isinstance(kit.get("props"), dict) else {}
  104. list_f = props.get("tripListField")
  105. days_f = props.get("tripDaysField")
  106. if list_f and days_f:
  107. return str(list_f), str(days_f)
  108. children = kit.get("children")
  109. if not isinstance(children, list):
  110. children = []
  111. p_rule = props.get("rule")
  112. if isinstance(p_rule, list):
  113. children = p_rule
  114. list_field: Optional[str] = None
  115. days_field: Optional[str] = None
  116. for c in children:
  117. if not isinstance(c, dict):
  118. continue
  119. field = c.get("field")
  120. if not field:
  121. continue
  122. c_props = c.get("props") if isinstance(c.get("props"), dict) else {}
  123. kit_key = str(c_props.get("_fc_kit_field") or "")
  124. if kit_key == "tripList":
  125. list_field = str(field)
  126. elif kit_key == "tripDays":
  127. days_field = str(field)
  128. return list_field, days_field
  129. def _walk_trip_kits(form_schema: Any) -> List[Tuple[Optional[str], Optional[str]]]:
  130. flat: List[Dict[str, Any]] = []
  131. _walk(_extract_rules(form_schema), flat)
  132. kits: List[Tuple[Optional[str], Optional[str]]] = []
  133. for rule in flat:
  134. if str(rule.get("type") or "") != "tripKit":
  135. continue
  136. kits.append(_kit_trip_fields(rule))
  137. return kits
  138. def apply_trip_kit_days(form_schema: Any, form_data: Dict[str, Any]) -> Dict[str, Any]:
  139. """
  140. 遍历 tripKit:行程未手改则覆盖 duration;出差天数未手改则覆盖为各行程时长之和。
  141. """
  142. data = dict(form_data or {})
  143. for list_f, days_f in _walk_trip_kits(form_schema):
  144. if not list_f or not days_f:
  145. continue
  146. legs_raw = data.get(list_f)
  147. if not isinstance(legs_raw, list):
  148. continue
  149. legs: List[Dict[str, Any]] = []
  150. for leg in legs_raw:
  151. if not isinstance(leg, dict):
  152. legs.append(leg) # type: ignore[arg-type]
  153. continue
  154. row = dict(leg)
  155. if not row.get("durationManual"):
  156. days = calc_trip_leg_days(
  157. row.get("startDate"),
  158. row.get("startHalf"),
  159. row.get("endDate"),
  160. row.get("endHalf"),
  161. )
  162. if days is not None:
  163. row["duration"] = days
  164. legs.append(row)
  165. data[list_f] = legs
  166. if not data.get(MANUAL_PREFIX + days_f):
  167. data[days_f] = sum_trip_leg_days(legs)
  168. return data
  169. def validate_trip_kit_form_values(form_schema: Any, form_data: Dict[str, Any]) -> List[str]:
  170. """提交校验:行程必填、结束晚于开始;时长/天数合法。"""
  171. errors: List[str] = []
  172. data = form_data or {}
  173. for list_f, days_f in _walk_trip_kits(form_schema):
  174. if not list_f or not days_f:
  175. continue
  176. legs_raw = data.get(list_f)
  177. if not isinstance(legs_raw, list) or len(legs_raw) < 1:
  178. errors.append("行程至少须保留 1 条")
  179. continue
  180. for i, leg in enumerate(legs_raw):
  181. if not isinstance(leg, dict):
  182. errors.append(f"行程第 {i + 1} 条格式无效")
  183. continue
  184. for key in _LEG_REQUIRED_FIELDS:
  185. val = leg.get(key)
  186. if val is None or str(val).strip() == "":
  187. errors.append(f"行程第 {i + 1} 条必填项未填写完整")
  188. break
  189. else:
  190. calc = calc_trip_leg_days(
  191. leg.get("startDate"),
  192. leg.get("startHalf"),
  193. leg.get("endDate"),
  194. leg.get("endHalf"),
  195. )
  196. if calc is None:
  197. errors.append("行程结束时间须晚于开始时间")
  198. dur = leg.get("duration")
  199. if dur is not None and dur != "":
  200. try:
  201. dv = float(dur)
  202. except (TypeError, ValueError):
  203. errors.append("行程时长须为非负数字(0.5 天步进)")
  204. else:
  205. if dv < 0 or not is_half_day_multiple(dv):
  206. errors.append("行程时长须为非负数字(0.5 天步进)")
  207. days_val = data.get(days_f)
  208. if days_val is not None and days_val != "":
  209. try:
  210. dv = float(days_val)
  211. except (TypeError, ValueError):
  212. errors.append("出差天数须为非负数字(0.5 天步进)")
  213. else:
  214. if dv < 0 or not is_half_day_multiple(dv):
  215. errors.append("出差天数须为非负数字(0.5 天步进)")
  216. return errors