feat(oa): add trip kit day calc and submit validation
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
14219b9a52
commit
33bf0c32a5
4 files changed
+372
No files matched your search
@@ -1722,12 +1722,18 @@ def start_instance(
|
||||
raise OaEngineError(f"公式计算失败: {e}") from e
|
||||
|
||||
from app.services.oa_leave_hours import apply_leave_kit_hours, validate_leave_kit_form_values
|
||||
from app.services.oa_trip_days import apply_trip_kit_days, validate_trip_kit_form_values
|
||||
|
||||
data = apply_leave_kit_hours(schema, data)
|
||||
leave_errs = validate_leave_kit_form_values(schema, data)
|
||||
if leave_errs:
|
||||
raise OaEngineError(leave_errs[0])
|
||||
|
||||
data = apply_trip_kit_days(schema, data)
|
||||
trip_errs = validate_trip_kit_form_values(schema, data)
|
||||
if trip_errs:
|
||||
raise OaEngineError(trip_errs[0])
|
||||
|
||||
user = (
|
||||
db.query(SysUser)
|
||||
.filter(SysUser.id == initiator_user_id, SysUser.del_flag == 0)
|
||||
|
||||
@@ -41,6 +41,7 @@ FILLABLE_TYPES = frozenset(
|
||||
"rate",
|
||||
"slider",
|
||||
"cascader",
|
||||
"tripItinerary",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -62,6 +63,7 @@ LAYOUT_TYPES = frozenset(
|
||||
"confirmationKit",
|
||||
"overtimeKit",
|
||||
"resignationKit",
|
||||
"tripKit",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""出差套件:半日制行程时长(与前端 oa-trip-days.ts 对齐)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
_DATE_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})$")
|
||||
|
||||
MANUAL_PREFIX = "__manual_"
|
||||
|
||||
_LEG_REQUIRED_FIELDS = (
|
||||
"transport",
|
||||
"tripType",
|
||||
"fromCity",
|
||||
"toCity",
|
||||
"startDate",
|
||||
"startHalf",
|
||||
"endDate",
|
||||
"endHalf",
|
||||
)
|
||||
|
||||
|
||||
def _day_index(date_str: Any) -> Optional[float]:
|
||||
m = _DATE_RE.match(str(date_str or "").strip())
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
dt = datetime(y, mo, d, tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return None
|
||||
return dt.timestamp() / 86400.0
|
||||
|
||||
|
||||
def _start_offset(half: Any) -> Optional[float]:
|
||||
h = str(half or "").strip()
|
||||
if h == "上午":
|
||||
return 0.0
|
||||
if h == "下午":
|
||||
return 0.5
|
||||
return None
|
||||
|
||||
|
||||
def _end_offset(half: Any) -> Optional[float]:
|
||||
h = str(half or "").strip()
|
||||
if h == "上午":
|
||||
return 0.5
|
||||
if h == "下午":
|
||||
return 1.0
|
||||
return None
|
||||
|
||||
|
||||
def calc_trip_leg_days(
|
||||
start_date: Any,
|
||||
start_half: Any,
|
||||
end_date: Any,
|
||||
end_half: Any,
|
||||
) -> Optional[float]:
|
||||
"""返回 0.5 步进天数;非法或 end<=start 返回 None。"""
|
||||
ds = _day_index(start_date)
|
||||
de = _day_index(end_date)
|
||||
so = _start_offset(start_half)
|
||||
eo = _end_offset(end_half)
|
||||
if ds is None or de is None or so is None or eo is None:
|
||||
return None
|
||||
duration = de + eo - (ds + so)
|
||||
if not (duration > 0):
|
||||
return None
|
||||
return round(duration * 2) / 2
|
||||
|
||||
|
||||
def sum_trip_leg_days(legs: Any) -> float:
|
||||
total = 0.0
|
||||
for leg in legs or []:
|
||||
if not isinstance(leg, dict):
|
||||
continue
|
||||
try:
|
||||
n = float(leg.get("duration"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if n >= 0:
|
||||
total += n
|
||||
return round(total * 2) / 2
|
||||
|
||||
|
||||
def is_half_day_multiple(n: Any) -> bool:
|
||||
try:
|
||||
v = float(n)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if v < 0:
|
||||
return False
|
||||
return abs(v * 2 - round(v * 2)) < 1e-9
|
||||
|
||||
|
||||
def _extract_rules(form_schema: Any) -> List[Dict[str, Any]]:
|
||||
if isinstance(form_schema, list):
|
||||
return [r for r in form_schema if isinstance(r, dict)]
|
||||
if isinstance(form_schema, dict):
|
||||
rules = form_schema.get("rule") or form_schema.get("rules") or form_schema.get("fields")
|
||||
if isinstance(rules, list):
|
||||
return [r for r in rules if isinstance(r, dict)]
|
||||
if form_schema.get("type") or form_schema.get("field"):
|
||||
return [form_schema]
|
||||
return []
|
||||
|
||||
|
||||
def _walk(rules: List[Dict[str, Any]], out: List[Dict[str, Any]]) -> None:
|
||||
for r in rules:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
out.append(r)
|
||||
children = r.get("children")
|
||||
if isinstance(children, list):
|
||||
_walk([c for c in children if isinstance(c, dict)], out)
|
||||
props = r.get("props") if isinstance(r.get("props"), dict) else {}
|
||||
nested = props.get("rule")
|
||||
if isinstance(nested, list):
|
||||
_walk([c for c in nested if isinstance(c, dict)], out)
|
||||
|
||||
|
||||
def _kit_trip_fields(kit: Dict[str, Any]) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""返回 (trip_list_field, trip_days_field)。"""
|
||||
props = kit.get("props") if isinstance(kit.get("props"), dict) else {}
|
||||
list_f = props.get("tripListField")
|
||||
days_f = props.get("tripDaysField")
|
||||
if list_f and days_f:
|
||||
return str(list_f), str(days_f)
|
||||
|
||||
children = kit.get("children")
|
||||
if not isinstance(children, list):
|
||||
children = []
|
||||
p_rule = props.get("rule")
|
||||
if isinstance(p_rule, list):
|
||||
children = p_rule
|
||||
|
||||
list_field: Optional[str] = None
|
||||
days_field: Optional[str] = None
|
||||
for c in children:
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
field = c.get("field")
|
||||
if not field:
|
||||
continue
|
||||
c_props = c.get("props") if isinstance(c.get("props"), dict) else {}
|
||||
kit_key = str(c_props.get("_fc_kit_field") or "")
|
||||
if kit_key == "tripList":
|
||||
list_field = str(field)
|
||||
elif kit_key == "tripDays":
|
||||
days_field = str(field)
|
||||
|
||||
return list_field, days_field
|
||||
|
||||
|
||||
def _walk_trip_kits(form_schema: Any) -> List[Tuple[Optional[str], Optional[str]]]:
|
||||
flat: List[Dict[str, Any]] = []
|
||||
_walk(_extract_rules(form_schema), flat)
|
||||
kits: List[Tuple[Optional[str], Optional[str]]] = []
|
||||
for rule in flat:
|
||||
if str(rule.get("type") or "") != "tripKit":
|
||||
continue
|
||||
kits.append(_kit_trip_fields(rule))
|
||||
return kits
|
||||
|
||||
|
||||
def apply_trip_kit_days(form_schema: Any, form_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
遍历 tripKit:行程未手改则覆盖 duration;出差天数未手改则覆盖为各行程时长之和。
|
||||
"""
|
||||
data = dict(form_data or {})
|
||||
for list_f, days_f in _walk_trip_kits(form_schema):
|
||||
if not list_f or not days_f:
|
||||
continue
|
||||
legs_raw = data.get(list_f)
|
||||
if not isinstance(legs_raw, list):
|
||||
continue
|
||||
legs: List[Dict[str, Any]] = []
|
||||
for leg in legs_raw:
|
||||
if not isinstance(leg, dict):
|
||||
legs.append(leg) # type: ignore[arg-type]
|
||||
continue
|
||||
row = dict(leg)
|
||||
if not row.get("durationManual"):
|
||||
days = calc_trip_leg_days(
|
||||
row.get("startDate"),
|
||||
row.get("startHalf"),
|
||||
row.get("endDate"),
|
||||
row.get("endHalf"),
|
||||
)
|
||||
if days is not None:
|
||||
row["duration"] = days
|
||||
legs.append(row)
|
||||
data[list_f] = legs
|
||||
if not data.get(MANUAL_PREFIX + days_f):
|
||||
data[days_f] = sum_trip_leg_days(legs)
|
||||
return data
|
||||
|
||||
|
||||
def validate_trip_kit_form_values(form_schema: Any, form_data: Dict[str, Any]) -> List[str]:
|
||||
"""提交校验:行程必填、结束晚于开始;时长/天数合法。"""
|
||||
errors: List[str] = []
|
||||
data = form_data or {}
|
||||
for list_f, days_f in _walk_trip_kits(form_schema):
|
||||
if not list_f or not days_f:
|
||||
continue
|
||||
legs_raw = data.get(list_f)
|
||||
if not isinstance(legs_raw, list) or len(legs_raw) < 1:
|
||||
errors.append("行程至少须保留 1 条")
|
||||
continue
|
||||
for i, leg in enumerate(legs_raw):
|
||||
if not isinstance(leg, dict):
|
||||
errors.append(f"行程第 {i + 1} 条格式无效")
|
||||
continue
|
||||
for key in _LEG_REQUIRED_FIELDS:
|
||||
val = leg.get(key)
|
||||
if val is None or str(val).strip() == "":
|
||||
errors.append(f"行程第 {i + 1} 条必填项未填写完整")
|
||||
break
|
||||
else:
|
||||
calc = calc_trip_leg_days(
|
||||
leg.get("startDate"),
|
||||
leg.get("startHalf"),
|
||||
leg.get("endDate"),
|
||||
leg.get("endHalf"),
|
||||
)
|
||||
if calc is None:
|
||||
errors.append("行程结束时间须晚于开始时间")
|
||||
dur = leg.get("duration")
|
||||
if dur is not None and dur != "":
|
||||
try:
|
||||
dv = float(dur)
|
||||
except (TypeError, ValueError):
|
||||
errors.append("行程时长须为非负数字(0.5 天步进)")
|
||||
else:
|
||||
if dv < 0 or not is_half_day_multiple(dv):
|
||||
errors.append("行程时长须为非负数字(0.5 天步进)")
|
||||
days_val = data.get(days_f)
|
||||
if days_val is not None and days_val != "":
|
||||
try:
|
||||
dv = float(days_val)
|
||||
except (TypeError, ValueError):
|
||||
errors.append("出差天数须为非负数字(0.5 天步进)")
|
||||
else:
|
||||
if dv < 0 or not is_half_day_multiple(dv):
|
||||
errors.append("出差天数须为非负数字(0.5 天步进)")
|
||||
return errors
|
||||
@@ -0,0 +1,117 @@
|
||||
"""oa_trip_days unit tests"""
|
||||
from app.services.oa_trip_days import (
|
||||
apply_trip_kit_days,
|
||||
calc_trip_leg_days,
|
||||
sum_trip_leg_days,
|
||||
validate_trip_kit_form_values,
|
||||
)
|
||||
|
||||
|
||||
def test_half_day_samples():
|
||||
assert calc_trip_leg_days("2026-08-01", "上午", "2026-08-01", "上午") == 0.5
|
||||
assert calc_trip_leg_days("2026-08-01", "上午", "2026-08-01", "下午") == 1.0
|
||||
assert calc_trip_leg_days("2026-08-01", "下午", "2026-08-01", "上午") is None
|
||||
assert calc_trip_leg_days("2026-08-01", "下午", "2026-08-02", "上午") == 1.0
|
||||
|
||||
|
||||
def test_sum():
|
||||
assert sum_trip_leg_days([{"duration": 0.5}, {"duration": 2}]) == 2.5
|
||||
|
||||
|
||||
def test_apply_overwrites_when_not_manual():
|
||||
schema = [
|
||||
{
|
||||
"type": "tripKit",
|
||||
"props": {"tripListField": "L1", "tripDaysField": "D1"},
|
||||
"children": [
|
||||
{"type": "tripItinerary", "field": "L1", "props": {"_fc_kit_field": "tripList"}},
|
||||
{"type": "inputNumber", "field": "D1", "props": {"_fc_kit_field": "tripDays"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
data = {
|
||||
"L1": [
|
||||
{
|
||||
"transport": "飞机",
|
||||
"tripType": "单程",
|
||||
"fromCity": "合肥",
|
||||
"toCity": "上海",
|
||||
"startDate": "2026-08-01",
|
||||
"startHalf": "上午",
|
||||
"endDate": "2026-08-01",
|
||||
"endHalf": "下午",
|
||||
"duration": 9,
|
||||
"durationManual": False,
|
||||
}
|
||||
],
|
||||
"D1": 9,
|
||||
}
|
||||
out = apply_trip_kit_days(schema, data)
|
||||
assert out["L1"][0]["duration"] == 1.0
|
||||
assert out["D1"] == 1.0
|
||||
|
||||
|
||||
def test_respect_manual_flags():
|
||||
schema = [
|
||||
{
|
||||
"type": "tripKit",
|
||||
"props": {"tripListField": "L1", "tripDaysField": "D1"},
|
||||
"children": [
|
||||
{"type": "tripItinerary", "field": "L1", "props": {"_fc_kit_field": "tripList"}},
|
||||
{"type": "inputNumber", "field": "D1", "props": {"_fc_kit_field": "tripDays"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
data = {
|
||||
"L1": [
|
||||
{
|
||||
"startDate": "2026-08-01",
|
||||
"startHalf": "上午",
|
||||
"endDate": "2026-08-01",
|
||||
"endHalf": "下午",
|
||||
"duration": 3,
|
||||
"durationManual": True,
|
||||
"transport": "火车",
|
||||
"tripType": "往返",
|
||||
"fromCity": "a",
|
||||
"toCity": "b",
|
||||
}
|
||||
],
|
||||
"D1": 7,
|
||||
"__manual_D1": True,
|
||||
}
|
||||
out = apply_trip_kit_days(schema, data)
|
||||
assert out["L1"][0]["duration"] == 3
|
||||
assert out["D1"] == 7
|
||||
|
||||
|
||||
def test_validate_end_before_start():
|
||||
schema = [
|
||||
{
|
||||
"type": "tripKit",
|
||||
"props": {"tripListField": "L1", "tripDaysField": "D1"},
|
||||
"children": [
|
||||
{"type": "tripItinerary", "field": "L1", "props": {"_fc_kit_field": "tripList"}},
|
||||
{"type": "inputNumber", "field": "D1", "props": {"_fc_kit_field": "tripDays"}},
|
||||
],
|
||||
}
|
||||
]
|
||||
data = {
|
||||
"L1": [
|
||||
{
|
||||
"transport": "飞机",
|
||||
"tripType": "单程",
|
||||
"fromCity": "a",
|
||||
"toCity": "b",
|
||||
"startDate": "2026-08-01",
|
||||
"startHalf": "下午",
|
||||
"endDate": "2026-08-01",
|
||||
"endHalf": "上午",
|
||||
"duration": 0.5,
|
||||
"durationManual": True,
|
||||
}
|
||||
],
|
||||
"D1": 0.5,
|
||||
}
|
||||
errs = validate_trip_kit_form_values(schema, data)
|
||||
assert errs
|
||||
Reference in new issue
Block a user