Przeglądaj źródła

feat(oa): sync initiator user from confirmation kit on approval complete

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 miesięcy temu
rodzic
commit
fa13440f18

+ 228 - 0
backend/app/services/oa_confirmation_kit_sync.py

@@ -0,0 +1,228 @@
+"""转正套件:审批通过后回写发起人 sys_user。"""
+from __future__ import annotations
+
+import json
+import logging
+import re
+from datetime import date, datetime
+from typing import Any, Dict, List, Optional
+
+from sqlalchemy.orm import Session
+
+from app.models.models import OaAppVersion, OaInstance, SysUser
+
+logger = logging.getLogger(__name__)
+
+WRITEBACK_KEYS = {
+    "hireDate": "hire_date",
+    "probationMonths": "probation_months",
+    "name": "nick_name",
+    "email": "email",
+    "dept": "dept_id",
+    "phone": "phone_no",
+}
+
+_PHONE_RE = re.compile(r"^1[3-9]\d{9}$")
+
+
+def _loads(raw: Any, default: Any = None) -> Any:
+    if raw is None or raw == "":
+        return default
+    if isinstance(raw, (dict, list)):
+        return raw
+    try:
+        return json.loads(raw)
+    except Exception:
+        return default
+
+
+def _extract_rules(form_schema: Any) -> List[Dict[str, Any]]:
+    if form_schema is None:
+        return []
+    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 rule in rules:
+        out.append(rule)
+        children = rule.get("children") or (rule.get("props") or {}).get("rule") or []
+        if isinstance(children, list):
+            _walk([c for c in children if isinstance(c, dict)], out)
+
+
+def _kit_field_map(kit: Dict[str, Any]) -> Dict[str, str]:
+    """semantic -> form field id"""
+    props = kit.get("props") if isinstance(kit.get("props"), dict) else {}
+    mapping: Dict[str, str] = {}
+    prop_keys = {
+        "hireDateField": "hireDate",
+        "probationMonthsField": "probationMonths",
+        "confirmationDateField": "confirmationDate",
+        "postField": "post",
+        "jobLevelField": "jobLevel",
+        "nameField": "name",
+        "emailField": "email",
+        "deptField": "dept",
+        "phoneField": "phone",
+    }
+    for pk, sem in prop_keys.items():
+        fid = props.get(pk)
+        if fid:
+            mapping[sem] = str(fid)
+
+    children = kit.get("children") or props.get("rule") or []
+    if isinstance(children, list):
+        for c in children:
+            if not isinstance(c, dict) or not c.get("field"):
+                continue
+            cprops = c.get("props") if isinstance(c.get("props"), dict) else {}
+            sem = str(cprops.get("_fc_kit_field") or "").strip()
+            if sem and sem not in mapping:
+                mapping[sem] = str(c["field"])
+    return mapping
+
+
+def _parse_hire_date(value: Any) -> Optional[date]:
+    if value is None or value == "":
+        return None
+    if isinstance(value, datetime):
+        return value.date()
+    if isinstance(value, date):
+        return value
+    s = str(value).strip()[:10]
+    try:
+        return datetime.strptime(s, "%Y-%m-%d").date()
+    except ValueError:
+        return None
+
+
+def _dept_id(value: Any) -> Optional[str]:
+    if value is None or value == "":
+        return None
+    if isinstance(value, dict):
+        sid = str(value.get("id") or "").strip()
+        return sid or None
+    if isinstance(value, list) and value:
+        return _dept_id(value[0])
+    sid = str(value).strip()
+    return sid or None
+
+
+def _is_empty(value: Any) -> bool:
+    if value is None or value == "":
+        return True
+    if isinstance(value, list) and not value:
+        return True
+    if isinstance(value, dict) and not str(value.get("id") or "").strip():
+        return True
+    return False
+
+
+def extract_writeback_values(schema: Any, form_data: Dict[str, Any]) -> Dict[str, Any]:
+    """从 schema+form_data 提取可回写列值(列名 -> 值)。空字段不进入结果。"""
+    flat: List[Dict[str, Any]] = []
+    _walk(_extract_rules(schema), flat)
+    out: Dict[str, Any] = {}
+    for rule in flat:
+        if str(rule.get("type") or "") != "confirmationKit":
+            continue
+        fmap = _kit_field_map(rule)
+        for sem, col in WRITEBACK_KEYS.items():
+            field = fmap.get(sem)
+            if not field:
+                continue
+            raw = form_data.get(field)
+            if _is_empty(raw):
+                continue
+            if col == "hire_date":
+                parsed = _parse_hire_date(raw)
+                if parsed is None:
+                    logger.warning("confirmation kit skip invalid hire_date=%r", raw)
+                    continue
+                out[col] = parsed
+            elif col == "probation_months":
+                try:
+                    months = int(raw)
+                except (TypeError, ValueError):
+                    logger.warning("confirmation kit skip invalid probation_months=%r", raw)
+                    continue
+                if months < 0:
+                    continue
+                out[col] = months
+            elif col == "dept_id":
+                did = _dept_id(raw)
+                if not did:
+                    continue
+                out[col] = did
+            elif col == "phone_no":
+                phone = str(raw).strip()
+                if not _PHONE_RE.match(phone):
+                    logger.warning("confirmation kit skip invalid phone=%r", phone)
+                    continue
+                out[col] = phone
+            elif col == "email":
+                email = str(raw).strip()
+                if "@" not in email:
+                    logger.warning("confirmation kit skip invalid email=%r", email)
+                    continue
+                out[col] = email
+            elif col == "nick_name":
+                name = str(raw).strip()
+                if not name:
+                    continue
+                out[col] = name
+    return out
+
+
+def apply_confirmation_kit_user_sync(db: Session, inst: OaInstance) -> None:
+    """扫描 confirmationKit,有值则更新发起人 sys_user;异常只打日志。"""
+    try:
+        if not inst or not getattr(inst, "id", None):
+            return
+        form_data = _loads(getattr(inst, "form_data_json", None), {}) or {}
+        if not isinstance(form_data, dict):
+            form_data = {}
+
+        ver = (
+            db.query(OaAppVersion)
+            .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0)
+            .first()
+        )
+        schema = _loads(getattr(ver, "form_schema_json", None) if ver else None, {}) or {}
+        values = extract_writeback_values(schema, form_data)
+        if not values:
+            return
+
+        user = (
+            db.query(SysUser)
+            .filter(
+                SysUser.id == inst.initiator_user_id,
+                SysUser.tenant_no == inst.tenant_no,
+                SysUser.del_flag == 0,
+            )
+            .first()
+        )
+        if not user:
+            logger.warning(
+                "confirmation kit sync: initiator not found id=%s tenant=%s",
+                inst.initiator_user_id,
+                inst.tenant_no,
+            )
+            return
+
+        for col, val in values.items():
+            setattr(user, col, val)
+        user.update_time = datetime.now()
+        db.add(user)
+    except Exception:
+        logger.exception(
+            "confirmation kit user sync failed instance=%s", getattr(inst, "id", None)
+        )

+ 35 - 7
backend/app/services/oa_engine.py

@@ -29,6 +29,11 @@ from app.services.oa_assignee import (
     resolve_cc_assignees,
 )
 from app.services.oa_formula import FormulaError, apply_formulas_and_linkages, eval_formula
+from app.services.oa_confirmation_kit_sync import apply_confirmation_kit_user_sync
+
+import logging
+
+logger = logging.getLogger(__name__)
 
 
 class OaEngineError(ValueError):
@@ -1636,6 +1641,12 @@ def advance_from(
             inst.status = "completed"
             inst.update_time = datetime.now()
             db.add(inst)
+            try:
+                apply_confirmation_kit_user_sync(db, inst)
+            except Exception:
+                logger.exception(
+                    "confirmation kit user sync failed instance=%s", getattr(inst, "id", None)
+                )
             return
         if ntype == "condition":
             cur = next_node(cur)
@@ -1703,6 +1714,30 @@ def start_instance(
     except FormulaError as e:
         raise OaEngineError(f"公式计算失败: {e}") from e
 
+    from app.services.oa_leave_hours import apply_leave_kit_hours, validate_leave_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])
+
+    user = (
+        db.query(SysUser)
+        .filter(SysUser.id == initiator_user_id, SysUser.del_flag == 0)
+        .first()
+    )
+    dept_id = (initiator_dept_id or (user.dept_id if user else "") or "").strip() or None
+    from app.services.oa_owner_fields import apply_owner_field_defaults
+
+    data = apply_owner_field_defaults(
+        schema,
+        data,
+        user_id=initiator_user_id,
+        user_name=(user.nick_name if user else "") or "",
+        dept_id=dept_id,
+        dept_name=_dept_name(db, dept_id, tenant_no=tenant_no) if dept_id else None,
+    )
+
     from app.services.oa_dept_select import validate_dept_form_values
     from app.services.oa_money_field import validate_money_form_values
     from app.services.oa_person_select import validate_person_form_values
@@ -1729,13 +1764,6 @@ def start_instance(
     if recv_errs:
         raise OaEngineError(recv_errs[0])
 
-    user = (
-        db.query(SysUser)
-        .filter(SysUser.id == initiator_user_id, SysUser.del_flag == 0)
-        .first()
-    )
-    dept_id = (initiator_dept_id or (user.dept_id if user else "") or "").strip() or None
-
     now = datetime.now()
     title_text = (title or app.app_name or "").strip()
     normalized_select = _normalize_initiator_select_by_node(initiator_select_by_node)

+ 78 - 0
backend/tests/test_oa_confirmation_kit_sync.py

@@ -0,0 +1,78 @@
+from datetime import date
+
+from app.services.oa_confirmation_kit_sync import extract_writeback_values
+
+
+def _kit_schema(fields: dict):
+    children = []
+    props = {}
+    for sem, fid in fields.items():
+        props[f"{sem}Field" if not sem.endswith("Field") else sem] = fid
+        # normalize: hireDate -> hireDateField already handled below
+    # rebuild properly
+    props = {}
+    children = []
+    for sem, fid in fields.items():
+        props[sem + "Field"] = fid
+        children.append(
+            {
+                "type": "input",
+                "field": fid,
+                "props": {"_fc_kit_field": sem},
+            }
+        )
+    return {
+        "rule": [
+            {
+                "type": "confirmationKit",
+                "props": props,
+                "children": children,
+            }
+        ]
+    }
+
+
+def test_extract_updates_six_fields():
+    schema = _kit_schema(
+        {
+            "hireDate": "f_hire",
+            "probationMonths": "f_prob",
+            "name": "f_name",
+            "email": "f_email",
+            "dept": "f_dept",
+            "phone": "f_phone",
+        }
+    )
+    data = {
+        "f_hire": "2026-01-15",
+        "f_prob": 3,
+        "f_name": "张三",
+        "f_email": "a@b.com",
+        "f_dept": {"id": "d1", "name": "研发"},
+        "f_phone": "13800138000",
+    }
+    out = extract_writeback_values(schema, data)
+    assert out["hire_date"] == date(2026, 1, 15)
+    assert out["probation_months"] == 3
+    assert out["nick_name"] == "张三"
+    assert out["email"] == "a@b.com"
+    assert out["dept_id"] == "d1"
+    assert out["phone_no"] == "13800138000"
+
+
+def test_empty_fields_skipped():
+    schema = _kit_schema({"name": "f_name", "phone": "f_phone", "email": "f_email"})
+    data = {"f_name": "李四", "f_phone": "", "f_email": "bad"}
+    out = extract_writeback_values(schema, data)
+    assert out == {"nick_name": "李四"}
+
+
+def test_invalid_phone_skipped():
+    schema = _kit_schema({"phone": "f_phone", "name": "f_name"})
+    out = extract_writeback_values(schema, {"f_phone": "123", "f_name": "王五"})
+    assert "phone_no" not in out
+    assert out["nick_name"] == "王五"
+
+
+def test_no_kit_noop():
+    assert extract_writeback_values({"rule": [{"type": "input", "field": "x"}]}, {"x": 1}) == {}