From 26800865cd99f4bef846d4868a4d7dfdcbefa4f0 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Wed, 15 Jul 2026 14:53:52 +0800 Subject: [PATCH] feat(oa): add return, resubmit, and multi-round approval progress Align with DingTalk semantics: reject ends the instance, return keeps it for initiator edits, and revoke-and-resubmit creates a linked new instance. Progress timeline projects rounds via oa_instance_event. Co-authored-by: Cursor --- backend/app/api/oa_approval.py | 745 ++++++++++++++ backend/app/crud/oa_approval.py | 915 ++++++++++++++++++ backend/app/models/models.py | 217 +++++ backend/app/services/oa_engine.py | 426 ++++++-- backend/tests/test_oa_engine.py | 158 ++- .../patch_2026-07-15_oa_progress_return.sql | 34 + ...026-07-15-oa-approval-progress-timeline.md | 759 +++++++++++++++ ...15-oa-approval-progress-timeline-design.md | 206 ++++ web-admin/src/api/oaApproval.ts | 20 + .../oa-approval/_placeholder/RuntimeList.vue | 129 +++ .../src/views/oa-approval/designer/index.vue | 430 ++++++++ .../views/oa-approval/instances/detail.vue | 123 ++- .../src/views/oa-approval/start/index.vue | 646 +++++++++++++ 13 files changed, 4736 insertions(+), 72 deletions(-) create mode 100644 backend/app/api/oa_approval.py create mode 100644 backend/app/crud/oa_approval.py create mode 100644 docs/sql/patch_2026-07-15_oa_progress_return.sql create mode 100644 docs/superpowers/plans/2026-07-15-oa-approval-progress-timeline.md create mode 100644 docs/superpowers/specs/2026-07-15-oa-approval-progress-timeline-design.md create mode 100644 web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue create mode 100644 web-admin/src/views/oa-approval/designer/index.vue create mode 100644 web-admin/src/views/oa-approval/start/index.vue diff --git a/backend/app/api/oa_approval.py b/backend/app/api/oa_approval.py new file mode 100644 index 00000000..f48b8788 --- /dev/null +++ b/backend/app/api/oa_approval.py @@ -0,0 +1,745 @@ +"""OA 审批管理接口(钉钉交互)""" +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.menu_guard import require_admin_or_super_admin +from app.core.response import error_response, success_response +from app.crud import oa_approval as oa_crud +from app.models.models import SysUser + +router = APIRouter(prefix="/web/admin/oa-approval", tags=["OA审批"]) + + +def _get_tenant_no(db: Session, user: dict) -> str: + tenant = (user.get("tenant_no") or "").strip() + if tenant: + return tenant + uid = user.get("user_id") + if uid: + row = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first() + if row and row.tenant_no: + return row.tenant_no + return "zkxw" + + +# --------------------------------------------------------------------------- +# 工作流角色 +# --------------------------------------------------------------------------- + + +class OaRoleCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + role_name: str = Field(..., alias="roleName") + role_code: str = Field(..., alias="roleCode") + status: Optional[str] = Field("1") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +class OaRoleUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + role_name: Optional[str] = Field(None, alias="roleName") + status: Optional[str] = None + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +class OaRoleUsersBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + user_ids: List[str] = Field(default_factory=list, alias="userIds") + + +@router.get("/roles") +async def list_oa_roles( + keyword: Optional[str] = None, + status: Optional[str] = None, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + items = oa_crud.list_roles(db, tenant_no=tenant_no, keyword=keyword, status=status) + return success_response({"list": items}) + + +@router.post("/roles/ensure-presets") +async def ensure_oa_role_presets( + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + created = oa_crud.ensure_presets(db, tenant_no, user_id=user.get("user_id")) + return success_response( + {"createdCount": len(created)}, + message="预置角色已就绪", + ) + + +@router.post("/roles") +async def create_oa_role( + body: OaRoleCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = oa_crud.create_role( + db, + tenant_no=tenant_no, + role_name=body.role_name, + role_code=body.role_code, + status=body.status or "1", + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(oa_crud._role_to_dict(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/roles/{role_id}") +async def update_oa_role( + role_id: str, + body: OaRoleUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False) + obj = oa_crud.update_role( + db, + role_id, + tenant_no=tenant_no, + role_name=payload.get("role_name"), + status=payload.get("status"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + ) + return success_response(oa_crud._role_to_dict(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/roles/{role_id}") +async def delete_oa_role( + role_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + oa_crud.delete_role(db, role_id, tenant_no=tenant_no, user_id=user.get("user_id")) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.get("/roles/{role_id}/users") +async def list_oa_role_users( + role_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + if not oa_crud.get_role(db, role_id, tenant_no): + return error_response(404, "角色不存在") + return success_response({"list": oa_crud.list_role_users(db, role_id, tenant_no)}) + + +@router.put("/roles/{role_id}/users") +async def replace_oa_role_users( + role_id: str, + body: OaRoleUsersBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + oa_crud.replace_role_users( + db, + role_id, + tenant_no=tenant_no, + user_ids=body.user_ids, + operator_id=user.get("user_id"), + ) + return success_response( + {"list": oa_crud.list_role_users(db, role_id, tenant_no)}, + message="成员已更新", + ) + except ValueError as e: + return error_response(400, str(e)) + + +# --------------------------------------------------------------------------- +# 分组 +# --------------------------------------------------------------------------- + + +class OaGroupCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + group_name: str = Field(..., alias="groupName") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +class OaGroupUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + group_name: Optional[str] = Field(None, alias="groupName") + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +@router.get("/groups") +async def list_oa_groups( + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + return success_response({"list": oa_crud.list_groups(db, tenant_no=tenant_no)}) + + +@router.post("/groups") +async def create_oa_group( + body: OaGroupCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = oa_crud.create_group( + db, + tenant_no=tenant_no, + group_name=body.group_name, + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + return success_response(oa_crud._group_to_dict(obj), message="创建成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/groups/{group_id}") +async def update_oa_group( + group_id: str, + body: OaGroupUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload = body.model_dump(exclude_unset=True, by_alias=False) + obj = oa_crud.update_group( + db, + group_id, + tenant_no=tenant_no, + group_name=payload.get("group_name"), + order_num=payload.get("order_num"), + remark=payload.get("remark"), + user_id=user.get("user_id"), + ) + return success_response(oa_crud._group_to_dict(obj), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/groups/{group_id}") +async def delete_oa_group( + group_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + oa_crud.delete_group(db, group_id, tenant_no=tenant_no, user_id=user.get("user_id")) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +# --------------------------------------------------------------------------- +# 应用 / 草稿 +# --------------------------------------------------------------------------- + + +class OaAppCreateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + app_name: str = Field(..., alias="appName") + group_id: Optional[str] = Field(None, alias="groupId") + icon_url: Optional[str] = Field(None, alias="iconUrl") + description: Optional[str] = None + initiator_scope: Optional[str] = Field("all", alias="initiatorScope") + initiator_user_ids: Optional[List[str]] = Field(None, alias="initiatorUserIds") + admin_scope: Optional[str] = Field("all_oa_admin", alias="adminScope") + admin_user_ids: Optional[List[str]] = Field(None, alias="adminUserIds") + status: Optional[str] = "1" + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +class OaAppUpdateBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + app_name: Optional[str] = Field(None, alias="appName") + group_id: Optional[str] = Field(None, alias="groupId") + icon_url: Optional[str] = Field(None, alias="iconUrl") + description: Optional[str] = None + initiator_scope: Optional[str] = Field(None, alias="initiatorScope") + initiator_user_ids: Optional[List[str]] = Field(None, alias="initiatorUserIds") + admin_scope: Optional[str] = Field(None, alias="adminScope") + admin_user_ids: Optional[List[str]] = Field(None, alias="adminUserIds") + status: Optional[str] = None + order_num: Optional[int] = Field(None, alias="orderNum") + remark: Optional[str] = None + + +class OaDraftSaveBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + basic: Optional[Dict[str, Any]] = None + form_schema_json: Optional[Any] = Field(None, alias="formSchemaJson") + flow_tree_json: Optional[Any] = Field(None, alias="flowTreeJson") + advanced_settings_json: Optional[Any] = Field(None, alias="advancedSettingsJson") + + +@router.get("/apps") +async def list_oa_apps( + groupId: Optional[str] = None, + keyword: Optional[str] = None, + status: Optional[str] = None, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + tenant_no = _get_tenant_no(db, user) + items = oa_crud.list_apps( + db, tenant_no=tenant_no, group_id=groupId, keyword=keyword, status=status + ) + return success_response({"list": items}) + + +@router.post("/apps") +async def create_oa_app( + body: OaAppCreateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + obj = oa_crud.create_app( + db, + tenant_no=tenant_no, + app_name=body.app_name, + group_id=body.group_id, + icon_url=body.icon_url, + description=body.description, + initiator_scope=body.initiator_scope or "all", + initiator_user_ids=body.initiator_user_ids, + admin_scope=body.admin_scope or "all_oa_admin", + admin_user_ids=body.admin_user_ids, + status=body.status or "1", + order_num=body.order_num, + remark=body.remark, + user_id=user.get("user_id"), + ) + group_name = "" + if obj.group_id: + g = oa_crud.get_group(db, obj.group_id, tenant_no) + group_name = g.group_name if g else "" + return success_response( + oa_crud._app_to_dict(obj, group_name=group_name, has_draft=True), + message="创建成功", + ) + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/apps/{app_id}") +async def update_oa_app( + app_id: str, + body: OaAppUpdateBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + payload = body.model_dump(exclude_unset=True, by_alias=False) + obj = oa_crud.update_app(db, app_id, tenant_no=tenant_no, user_id=user.get("user_id"), **payload) + group_name = "" + if obj.group_id: + g = oa_crud.get_group(db, obj.group_id, tenant_no) + group_name = g.group_name if g else "" + return success_response(oa_crud._app_to_dict(obj, group_name=group_name), message="更新成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.delete("/apps/{app_id}") +async def delete_oa_app( + app_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + oa_crud.delete_app(db, app_id, tenant_no=tenant_no, user_id=user.get("user_id")) + return success_response(None, message="删除成功") + except ValueError as e: + return error_response(400, str(e)) + + +@router.get("/apps/{app_id}/draft") +async def get_oa_app_draft( + app_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + data = oa_crud.get_or_create_draft( + db, app_id, tenant_no=tenant_no, user_id=user.get("user_id") + ) + return success_response(data) + except ValueError as e: + return error_response(400, str(e)) + + +@router.put("/apps/{app_id}/draft") +async def save_oa_app_draft( + app_id: str, + body: OaDraftSaveBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + try: + tenant_no = _get_tenant_no(db, user) + data = oa_crud.save_draft( + db, + app_id, + tenant_no=tenant_no, + basic=body.basic, + form_schema_json=body.form_schema_json, + flow_tree_json=body.flow_tree_json, + advanced_settings_json=body.advanced_settings_json, + user_id=user.get("user_id"), + ) + return success_response(data, message="草稿已保存") + except ValueError as e: + return error_response(400, str(e)) + + +@router.post("/apps/{app_id}/publish") +async def publish_oa_app( + app_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.services.oa_publish import publish_app + + tenant_no = _get_tenant_no(db, user) + data, errors = publish_app( + db, app_id, tenant_no=tenant_no, user_id=user.get("user_id") + ) + if errors: + return error_response(400, "发布校验未通过", data={"errors": errors}) + return success_response(data, message="发布成功") + + +# --------------------------------------------------------------------------- +# 运行时:模板 / 实例 / 任务 +# --------------------------------------------------------------------------- + + +class OaStartInstanceBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + app_id: str = Field(..., alias="appId") + form_data: Optional[Dict[str, Any]] = Field(None, alias="formData") + title: Optional[str] = None + initiator_dept_id: Optional[str] = Field(None, alias="initiatorDeptId") + initiator_select_user_ids: Optional[List[str]] = Field(None, alias="initiatorSelectUserIds") + related_instance_ids: Optional[List[str]] = Field(None, alias="relatedInstanceIds") + source_instance_id: Optional[str] = Field(None, alias="sourceInstanceId") + + +class OaTaskActionBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + comment: Optional[str] = None + + +class OaAddSignBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + user_ids: List[str] = Field(default_factory=list, alias="userIds") + comment: Optional[str] = None + + +class OaResubmitBody(BaseModel): + model_config = ConfigDict(populate_by_name=True) + form_data: Optional[Dict[str, Any]] = Field(None, alias="formData") + initiator_select_user_ids: Optional[List[str]] = Field(None, alias="initiatorSelectUserIds") + + +@router.get("/templates") +async def list_oa_templates( + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.services import oa_engine + + tenant_no = _get_tenant_no(db, user) + items = oa_engine.list_templates(db, tenant_no=tenant_no, user_id=user.get("user_id") or "") + return success_response({"list": items}) + + +@router.get("/templates/{app_id}") +async def get_oa_template( + app_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.services.oa_engine import OaEngineError, get_template_detail + + try: + tenant_no = _get_tenant_no(db, user) + data = get_template_detail( + db, + tenant_no=tenant_no, + app_id=app_id, + user_id=user.get("user_id") or "", + ) + return success_response(data) + except OaEngineError as e: + return error_response(400, str(e)) + + +@router.get("/templates/{app_id}/flow-preview") +async def preview_oa_template_flow( + app_id: str, + initiator_select_user_ids: Optional[str] = Query(None, alias="initiatorSelectUserIds"), + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + """发起前预览审批链(解析具体审批人)。""" + from app.services.oa_engine import OaEngineError, preview_start_flow + + try: + tenant_no = _get_tenant_no(db, user) + uid = user.get("user_id") or "" + dept_id = None + if uid: + row = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first() + if row: + dept_id = row.dept_id + select_ids = None + if initiator_select_user_ids: + select_ids = [x.strip() for x in initiator_select_user_ids.split(",") if x.strip()] + data = preview_start_flow( + db, + tenant_no=tenant_no, + app_id=app_id, + initiator_user_id=uid, + initiator_dept_id=dept_id, + initiator_select_user_ids=select_ids, + ) + return success_response(data) + except OaEngineError as e: + return error_response(400, str(e)) + + +@router.post("/instances") +async def start_oa_instance( + body: OaStartInstanceBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.services.oa_engine import OaEngineError, start_instance + + try: + tenant_no = _get_tenant_no(db, user) + data = start_instance( + db, + tenant_no=tenant_no, + app_id=body.app_id, + initiator_user_id=user.get("user_id") or "", + initiator_dept_id=body.initiator_dept_id, + form_data=body.form_data, + title=body.title, + initiator_select_user_ids=body.initiator_select_user_ids, + related_instance_ids=body.related_instance_ids, + source_instance_id=body.source_instance_id, + ) + return success_response(data, message="发起成功") + except OaEngineError as e: + return error_response(400, str(e)) + + +@router.get("/instances/{instance_id}") +async def get_oa_instance( + instance_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.services.oa_engine import OaEngineError, get_instance_detail + + try: + tenant_no = _get_tenant_no(db, user) + return success_response(get_instance_detail(db, instance_id, tenant_no=tenant_no)) + except OaEngineError as e: + return error_response(400, str(e)) + + +@router.get("/tasks/{kind}") +async def list_oa_tasks( + kind: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.services.oa_engine import OaEngineError, list_tasks + + try: + tenant_no = _get_tenant_no(db, user) + items = list_tasks( + db, tenant_no=tenant_no, user_id=user.get("user_id") or "", kind=kind + ) + return success_response({"list": items}) + except OaEngineError as e: + return error_response(400, str(e)) + + +@router.post("/tasks/{task_id}/approve") +async def approve_oa_task( + task_id: str, + body: Optional[OaTaskActionBody] = None, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.services.oa_engine import OaEngineError, complete_task + + try: + tenant_no = _get_tenant_no(db, user) + data = complete_task( + db, + task_id, + tenant_no=tenant_no, + user_id=user.get("user_id") or "", + action="approve", + comment=(body.comment if body else None), + ) + return success_response(data, message="已同意") + except OaEngineError as e: + return error_response(400, str(e)) + + +@router.post("/tasks/{task_id}/reject") +async def reject_oa_task( + task_id: str, + body: Optional[OaTaskActionBody] = None, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.services.oa_engine import OaEngineError, complete_task + + try: + tenant_no = _get_tenant_no(db, user) + data = complete_task( + db, + task_id, + tenant_no=tenant_no, + user_id=user.get("user_id") or "", + action="reject", + comment=(body.comment if body else None), + ) + return success_response(data, message="已拒绝") + except OaEngineError as e: + return error_response(400, str(e)) + + +@router.post("/tasks/{task_id}/return") +async def return_oa_task( + task_id: str, + body: Optional[OaTaskActionBody] = None, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.services.oa_engine import OaEngineError, return_task + + try: + tenant_no = _get_tenant_no(db, user) + data = return_task( + db, + task_id, + tenant_no=tenant_no, + user_id=user.get("user_id") or "", + comment=(body.comment if body else None), + ) + return success_response(data, message="已打回") + except OaEngineError as e: + return error_response(400, str(e)) + + +@router.post("/instances/{instance_id}/resubmit") +async def resubmit_oa_instance( + instance_id: str, + body: Optional[OaResubmitBody] = None, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.services.oa_engine import OaEngineError, resubmit_instance + + try: + tenant_no = _get_tenant_no(db, user) + data = resubmit_instance( + db, + instance_id, + tenant_no=tenant_no, + user_id=user.get("user_id") or "", + form_data=(body.form_data if body else None), + initiator_select_user_ids=(body.initiator_select_user_ids if body else None), + ) + return success_response(data, message="已再次提交") + except OaEngineError as e: + return error_response(400, str(e)) + + +@router.post("/tasks/{task_id}/add-sign") +async def add_sign_oa_task( + task_id: str, + body: OaAddSignBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.services.oa_engine import OaEngineError, add_sign + + try: + tenant_no = _get_tenant_no(db, user) + data = add_sign( + db, + task_id, + tenant_no=tenant_no, + user_id=user.get("user_id") or "", + user_ids=body.user_ids, + comment=body.comment, + ) + return success_response(data, message="加签成功") + except OaEngineError as e: + return error_response(400, str(e)) + + +@router.post("/instances/{instance_id}/withdraw") +async def withdraw_oa_instance( + instance_id: str, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + from app.services.oa_engine import OaEngineError, withdraw_instance + + try: + tenant_no = _get_tenant_no(db, user) + data = withdraw_instance( + db, instance_id, tenant_no=tenant_no, user_id=user.get("user_id") or "" + ) + return success_response(data, message="已撤销") + except OaEngineError as e: + return error_response(400, str(e)) diff --git a/backend/app/crud/oa_approval.py b/backend/app/crud/oa_approval.py new file mode 100644 index 00000000..18b51af8 --- /dev/null +++ b/backend/app/crud/oa_approval.py @@ -0,0 +1,915 @@ +"""OA 审批 CRUD(工作流角色、分组、应用、草稿等)""" +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import and_ +from sqlalchemy.orm import Session + +from app.core.utils import generate_uuid +from app.models.models import OaApp, OaAppGroup, OaAppVersion, OaRole, OaRoleUser, SysUser + +DEFAULT_ADVANCED_SETTINGS: Dict[str, Any] = { + "supervisorFallbackToDeptLeader": True, + "allowWithdrawRunning": True, + "approverDedup": False, + "commentRequired": False, + "allowAddSign": True, + "allowReturn": True, +} + + +def _dumps(data: Any) -> Optional[str]: + if data is None: + return None + if isinstance(data, str): + return data + return json.dumps(data, ensure_ascii=False) + + +def _loads(text: Optional[str], default: Any = None) -> Any: + if text is None or text == "": + return default + if not isinstance(text, str): + return text + try: + return json.loads(text) + except (TypeError, ValueError): + return default + +PRESET_ROLES = ( + ("GM", "总经理"), + ("CHAIRMAN", "董事长"), +) + + +def _role_to_dict(r: OaRole, *, member_count: int = 0) -> Dict[str, Any]: + return { + "id": r.id, + "roleName": r.role_name, + "roleCode": r.role_code, + "roleKind": r.role_kind, + "status": r.status, + "tenantNo": r.tenant_no, + "orderNum": r.order_num if r.order_num is not None else 0, + "remark": r.remark or "", + "memberCount": member_count, + "createTime": r.create_time.strftime("%Y-%m-%d %H:%M:%S") if r.create_time else "", + "updateTime": r.update_time.strftime("%Y-%m-%d %H:%M:%S") if r.update_time else "", + } + + +def ensure_presets(db: Session, tenant_no: str, user_id: Optional[str] = None) -> List[OaRole]: + """幂等写入预置角色 GM / CHAIRMAN。""" + now = datetime.now() + created: List[OaRole] = [] + for code, name in PRESET_ROLES: + exists = ( + db.query(OaRole) + .filter( + OaRole.tenant_no == tenant_no, + OaRole.role_code == code, + OaRole.del_flag == 0, + ) + .first() + ) + if exists: + continue + obj = OaRole( + id=generate_uuid(), + role_name=name, + role_code=code, + role_kind="preset", + status="1", + tenant_no=tenant_no, + order_num=0 if code == "GM" else 1, + remark="系统预置工作流角色", + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(obj) + created.append(obj) + if created: + db.commit() + for obj in created: + db.refresh(obj) + return created + + +def _member_count_map(db: Session, role_ids: List[str], tenant_no: str) -> Dict[str, int]: + if not role_ids: + return {} + rows = ( + db.query(OaRoleUser.oa_role_id, OaRoleUser.id) + .filter( + OaRoleUser.oa_role_id.in_(role_ids), + OaRoleUser.tenant_no == tenant_no, + OaRoleUser.del_flag == 0, + ) + .all() + ) + counts: Dict[str, int] = {rid: 0 for rid in role_ids} + for rid, _ in rows: + counts[rid] = counts.get(rid, 0) + 1 + return counts + + +def list_roles( + db: Session, + *, + tenant_no: str, + keyword: Optional[str] = None, + status: Optional[str] = None, +) -> List[Dict[str, Any]]: + ensure_presets(db, tenant_no) + q = db.query(OaRole).filter(OaRole.tenant_no == tenant_no, OaRole.del_flag == 0) + kw = (keyword or "").strip() + if kw: + like = f"%{kw}%" + q = q.filter((OaRole.role_name.like(like)) | (OaRole.role_code.like(like))) + if status: + q = q.filter(OaRole.status == status) + rows = q.order_by(OaRole.role_kind.desc(), OaRole.order_num.asc(), OaRole.create_time.desc()).all() + counts = _member_count_map(db, [r.id for r in rows], tenant_no) + return [_role_to_dict(r, member_count=counts.get(r.id, 0)) for r in rows] + + +def get_role(db: Session, role_id: str, tenant_no: str) -> Optional[OaRole]: + return ( + db.query(OaRole) + .filter(OaRole.id == role_id, OaRole.tenant_no == tenant_no, OaRole.del_flag == 0) + .first() + ) + + +def create_role( + db: Session, + *, + tenant_no: str, + role_name: str, + role_code: str, + status: str = "1", + order_num: Optional[int] = None, + remark: Optional[str] = None, + user_id: Optional[str] = None, +) -> OaRole: + name = (role_name or "").strip() + code = (role_code or "").strip().upper() + if not name: + raise ValueError("角色名称不能为空") + if not code: + raise ValueError("角色编码不能为空") + if code in {c for c, _ in PRESET_ROLES}: + raise ValueError("该编码为系统预置,请勿占用") + exists = ( + db.query(OaRole) + .filter(OaRole.tenant_no == tenant_no, OaRole.role_code == code, OaRole.del_flag == 0) + .first() + ) + if exists: + raise ValueError("角色编码已存在") + now = datetime.now() + obj = OaRole( + id=generate_uuid(), + role_name=name, + role_code=code, + role_kind="custom", + status=status or "1", + tenant_no=tenant_no, + order_num=order_num if order_num is not None else 10, + remark=(remark or "").strip() or None, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def update_role( + db: Session, + role_id: str, + *, + tenant_no: str, + role_name: Optional[str] = None, + status: Optional[str] = None, + order_num: Optional[int] = None, + remark: Optional[str] = None, + user_id: Optional[str] = None, +) -> OaRole: + obj = get_role(db, role_id, tenant_no) + if not obj: + raise ValueError("角色不存在") + if role_name is not None: + name = (role_name or "").strip() + if not name: + raise ValueError("角色名称不能为空") + obj.role_name = name + if status is not None: + obj.status = status + if order_num is not None: + obj.order_num = order_num + if remark is not None: + obj.remark = (remark or "").strip() or None + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def delete_role(db: Session, role_id: str, *, tenant_no: str, user_id: Optional[str] = None) -> None: + obj = get_role(db, role_id, tenant_no) + if not obj: + raise ValueError("角色不存在") + if obj.role_kind == "preset": + raise ValueError("预置角色不可删除") + obj.del_flag = 1 + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +def list_role_users(db: Session, role_id: str, tenant_no: str) -> List[Dict[str, Any]]: + rows = ( + db.query(OaRoleUser, SysUser) + .outerjoin(SysUser, and_(SysUser.id == OaRoleUser.user_id, SysUser.del_flag == 0)) + .filter( + OaRoleUser.oa_role_id == role_id, + OaRoleUser.tenant_no == tenant_no, + OaRoleUser.del_flag == 0, + ) + .all() + ) + out: List[Dict[str, Any]] = [] + for ru, u in rows: + out.append( + { + "id": ru.id, + "userId": ru.user_id, + "nickName": (u.nick_name if u else "") or "", + "userNo": (u.user_no if u else "") or "", + } + ) + return out + + +def replace_role_users( + db: Session, + role_id: str, + *, + tenant_no: str, + user_ids: List[str], + operator_id: Optional[str] = None, +) -> None: + obj = get_role(db, role_id, tenant_no) + if not obj: + raise ValueError("角色不存在") + now = datetime.now() + existing = ( + db.query(OaRoleUser) + .filter( + OaRoleUser.oa_role_id == role_id, + OaRoleUser.tenant_no == tenant_no, + OaRoleUser.del_flag == 0, + ) + .all() + ) + for row in existing: + row.del_flag = 1 + row.update_by = operator_id + row.update_time = now + db.add(row) + seen = set() + for uid in user_ids or []: + uid = (uid or "").strip() + if not uid or uid in seen: + continue + seen.add(uid) + db.add( + OaRoleUser( + id=generate_uuid(), + oa_role_id=role_id, + user_id=uid, + tenant_no=tenant_no, + order_num=0, + remark=None, + create_by=operator_id, + create_dept_id="", + create_time=now, + update_by=operator_id, + update_time=now, + del_flag=0, + ) + ) + db.commit() + + +def list_oa_role_user_ids(db: Session, role_ids: List[str], tenant_no: str) -> List[str]: + if not role_ids: + return [] + enabled = { + r.id + for r in db.query(OaRole) + .filter( + OaRole.id.in_(role_ids), + OaRole.tenant_no == tenant_no, + OaRole.del_flag == 0, + OaRole.status == "1", + ) + .all() + } + if not enabled: + return [] + rows = ( + db.query(OaRoleUser.user_id, OaRoleUser.oa_role_id) + .filter( + OaRoleUser.oa_role_id.in_(list(enabled)), + OaRoleUser.tenant_no == tenant_no, + OaRoleUser.del_flag == 0, + ) + .all() + ) + out: List[str] = [] + seen = set() + for uid, rid in rows: + if rid in enabled and uid and uid not in seen: + seen.add(uid) + out.append(uid) + return out + + +# --------------------------------------------------------------------------- +# 分组 oa_app_group +# --------------------------------------------------------------------------- + + +def _group_to_dict(g: OaAppGroup) -> Dict[str, Any]: + return { + "id": g.id, + "groupName": g.group_name, + "tenantNo": g.tenant_no, + "orderNum": g.order_num if g.order_num is not None else 0, + "remark": g.remark or "", + "createTime": g.create_time.strftime("%Y-%m-%d %H:%M:%S") if g.create_time else "", + "updateTime": g.update_time.strftime("%Y-%m-%d %H:%M:%S") if g.update_time else "", + } + + +def list_groups(db: Session, *, tenant_no: str) -> List[Dict[str, Any]]: + rows = ( + db.query(OaAppGroup) + .filter(OaAppGroup.tenant_no == tenant_no, OaAppGroup.del_flag == 0) + .order_by(OaAppGroup.order_num.asc(), OaAppGroup.create_time.asc()) + .all() + ) + return [_group_to_dict(r) for r in rows] + + +def get_group(db: Session, group_id: str, tenant_no: str) -> Optional[OaAppGroup]: + return ( + db.query(OaAppGroup) + .filter(OaAppGroup.id == group_id, OaAppGroup.tenant_no == tenant_no, OaAppGroup.del_flag == 0) + .first() + ) + + +def create_group( + db: Session, + *, + tenant_no: str, + group_name: str, + order_num: Optional[int] = None, + remark: Optional[str] = None, + user_id: Optional[str] = None, +) -> OaAppGroup: + name = (group_name or "").strip() + if not name: + raise ValueError("分组名称不能为空") + now = datetime.now() + obj = OaAppGroup( + id=generate_uuid(), + group_name=name, + tenant_no=tenant_no, + order_num=order_num if order_num is not None else 0, + remark=(remark or "").strip() or None, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def update_group( + db: Session, + group_id: str, + *, + tenant_no: str, + group_name: Optional[str] = None, + order_num: Optional[int] = None, + remark: Optional[str] = None, + user_id: Optional[str] = None, +) -> OaAppGroup: + obj = get_group(db, group_id, tenant_no) + if not obj: + raise ValueError("分组不存在") + if group_name is not None: + name = (group_name or "").strip() + if not name: + raise ValueError("分组名称不能为空") + obj.group_name = name + if order_num is not None: + obj.order_num = order_num + if remark is not None: + obj.remark = (remark or "").strip() or None + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def delete_group(db: Session, group_id: str, *, tenant_no: str, user_id: Optional[str] = None) -> None: + obj = get_group(db, group_id, tenant_no) + if not obj: + raise ValueError("分组不存在") + used = ( + db.query(OaApp.id) + .filter(OaApp.group_id == group_id, OaApp.tenant_no == tenant_no, OaApp.del_flag == 0) + .first() + ) + if used: + raise ValueError("分组下仍有应用,无法删除") + obj.del_flag = 1 + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + + +# --------------------------------------------------------------------------- +# 应用 oa_app / 草稿 oa_app_version +# --------------------------------------------------------------------------- + + +def _app_to_dict(a: OaApp, *, group_name: str = "", has_draft: bool = False, latest_published: Optional[int] = None) -> Dict[str, Any]: + return { + "id": a.id, + "groupId": a.group_id or "", + "groupName": group_name or "", + "appName": a.app_name, + "iconUrl": a.icon_url or "", + "description": a.description or "", + "initiatorScope": a.initiator_scope or "all", + "initiatorUserIds": _loads(a.initiator_user_ids, []), + "adminScope": a.admin_scope or "all_oa_admin", + "adminUserIds": _loads(a.admin_user_ids, []), + "status": a.status, + "tenantNo": a.tenant_no, + "orderNum": a.order_num if a.order_num is not None else 0, + "remark": a.remark or "", + "hasDraft": has_draft, + "latestPublishedVersion": latest_published, + "createTime": a.create_time.strftime("%Y-%m-%d %H:%M:%S") if a.create_time else "", + "updateTime": a.update_time.strftime("%Y-%m-%d %H:%M:%S") if a.update_time else "", + } + + +def get_app(db: Session, app_id: str, tenant_no: str) -> Optional[OaApp]: + return ( + db.query(OaApp) + .filter(OaApp.id == app_id, OaApp.tenant_no == tenant_no, OaApp.del_flag == 0) + .first() + ) + + +def list_apps( + db: Session, + *, + tenant_no: str, + group_id: Optional[str] = None, + keyword: Optional[str] = None, + status: Optional[str] = None, +) -> List[Dict[str, Any]]: + q = db.query(OaApp).filter(OaApp.tenant_no == tenant_no, OaApp.del_flag == 0) + if group_id: + q = q.filter(OaApp.group_id == group_id) + if status: + q = q.filter(OaApp.status == status) + kw = (keyword or "").strip() + if kw: + like = f"%{kw}%" + q = q.filter(OaApp.app_name.like(like)) + rows = q.order_by(OaApp.order_num.asc(), OaApp.create_time.desc()).all() + group_ids = list({r.group_id for r in rows if r.group_id}) + group_names: Dict[str, str] = {} + if group_ids: + for g in ( + db.query(OaAppGroup) + .filter(OaAppGroup.id.in_(group_ids), OaAppGroup.del_flag == 0) + .all() + ): + group_names[g.id] = g.group_name + out: List[Dict[str, Any]] = [] + for r in rows: + draft = ( + db.query(OaAppVersion.id) + .filter( + OaAppVersion.app_id == r.id, + OaAppVersion.tenant_no == tenant_no, + OaAppVersion.status == "draft", + OaAppVersion.del_flag == 0, + ) + .first() + ) + pub = ( + db.query(OaAppVersion) + .filter( + OaAppVersion.app_id == r.id, + OaAppVersion.tenant_no == tenant_no, + OaAppVersion.is_latest_published == "1", + OaAppVersion.del_flag == 0, + ) + .first() + ) + out.append( + _app_to_dict( + r, + group_name=group_names.get(r.group_id or "", ""), + has_draft=bool(draft), + latest_published=pub.version_no if pub else None, + ) + ) + return out + + +def _create_version( + db: Session, + *, + app_id: str, + tenant_no: str, + version_no: int, + status: str, + form_schema: Any, + flow_tree: Any, + advanced: Any, + user_id: Optional[str], + is_latest_published: str = "0", +) -> OaAppVersion: + now = datetime.now() + ver = OaAppVersion( + id=generate_uuid(), + app_id=app_id, + version_no=version_no, + status=status, + is_latest_published=is_latest_published, + form_schema_json=_dumps(form_schema if form_schema is not None else {}), + flow_tree_json=_dumps(flow_tree if flow_tree is not None else {}), + advanced_settings_json=_dumps(advanced if advanced is not None else DEFAULT_ADVANCED_SETTINGS), + tenant_no=tenant_no, + order_num=0, + remark=None, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(ver) + return ver + + +def create_app( + db: Session, + *, + tenant_no: str, + app_name: str, + group_id: Optional[str] = None, + icon_url: Optional[str] = None, + description: Optional[str] = None, + initiator_scope: str = "all", + initiator_user_ids: Optional[List[str]] = None, + admin_scope: str = "all_oa_admin", + admin_user_ids: Optional[List[str]] = None, + status: str = "1", + order_num: Optional[int] = None, + remark: Optional[str] = None, + user_id: Optional[str] = None, +) -> OaApp: + name = (app_name or "").strip() + if not name: + raise ValueError("应用名称不能为空") + if len(name) > 50: + raise ValueError("应用名称不能超过50字") + gid = (group_id or "").strip() or None + if gid and not get_group(db, gid, tenant_no): + raise ValueError("分组不存在") + now = datetime.now() + obj = OaApp( + id=generate_uuid(), + group_id=gid, + app_name=name, + icon_url=(icon_url or "").strip() or None, + description=(description or "").strip() or None, + initiator_scope=initiator_scope or "all", + initiator_user_ids=_dumps(initiator_user_ids or []), + admin_scope=admin_scope or "all_oa_admin", + admin_user_ids=_dumps(admin_user_ids or []), + status=status or "1", + tenant_no=tenant_no, + order_num=order_num if order_num is not None else 0, + remark=(remark or "").strip() or None, + create_by=user_id, + create_dept_id="", + create_time=now, + update_by=user_id, + update_time=now, + del_flag=0, + ) + db.add(obj) + _create_version( + db, + app_id=obj.id, + tenant_no=tenant_no, + version_no=1, + status="draft", + form_schema={}, + flow_tree={}, + advanced=DEFAULT_ADVANCED_SETTINGS, + user_id=user_id, + ) + db.commit() + db.refresh(obj) + return obj + + +def update_app( + db: Session, + app_id: str, + *, + tenant_no: str, + app_name: Optional[str] = None, + group_id: Optional[str] = None, + icon_url: Optional[str] = None, + description: Optional[str] = None, + initiator_scope: Optional[str] = None, + initiator_user_ids: Optional[List[str]] = None, + admin_scope: Optional[str] = None, + admin_user_ids: Optional[List[str]] = None, + status: Optional[str] = None, + order_num: Optional[int] = None, + remark: Optional[str] = None, + user_id: Optional[str] = None, +) -> OaApp: + obj = get_app(db, app_id, tenant_no) + if not obj: + raise ValueError("应用不存在") + if app_name is not None: + name = (app_name or "").strip() + if not name: + raise ValueError("应用名称不能为空") + if len(name) > 50: + raise ValueError("应用名称不能超过50字") + obj.app_name = name + if group_id is not None: + gid = (group_id or "").strip() or None + if gid and not get_group(db, gid, tenant_no): + raise ValueError("分组不存在") + obj.group_id = gid + if icon_url is not None: + obj.icon_url = (icon_url or "").strip() or None + if description is not None: + desc = (description or "").strip() + if len(desc) > 100: + raise ValueError("表单说明不能超过100字") + obj.description = desc or None + if initiator_scope is not None: + obj.initiator_scope = initiator_scope + if initiator_user_ids is not None: + obj.initiator_user_ids = _dumps(initiator_user_ids) + if admin_scope is not None: + obj.admin_scope = admin_scope + if admin_user_ids is not None: + obj.admin_user_ids = _dumps(admin_user_ids) + if status is not None: + obj.status = status + if order_num is not None: + obj.order_num = order_num + if remark is not None: + obj.remark = (remark or "").strip() or None + obj.update_by = user_id + obj.update_time = datetime.now() + db.add(obj) + db.commit() + db.refresh(obj) + return obj + + +def delete_app(db: Session, app_id: str, *, tenant_no: str, user_id: Optional[str] = None) -> None: + obj = get_app(db, app_id, tenant_no) + if not obj: + raise ValueError("应用不存在") + now = datetime.now() + obj.del_flag = 1 + obj.update_by = user_id + obj.update_time = now + db.add(obj) + for ver in ( + db.query(OaAppVersion) + .filter(OaAppVersion.app_id == app_id, OaAppVersion.tenant_no == tenant_no, OaAppVersion.del_flag == 0) + .all() + ): + ver.del_flag = 1 + ver.update_by = user_id + ver.update_time = now + db.add(ver) + db.commit() + + +def _find_draft(db: Session, app_id: str, tenant_no: str) -> Optional[OaAppVersion]: + return ( + db.query(OaAppVersion) + .filter( + OaAppVersion.app_id == app_id, + OaAppVersion.tenant_no == tenant_no, + OaAppVersion.status == "draft", + OaAppVersion.del_flag == 0, + ) + .order_by(OaAppVersion.version_no.desc()) + .first() + ) + + +def _find_latest_published(db: Session, app_id: str, tenant_no: str) -> Optional[OaAppVersion]: + return ( + db.query(OaAppVersion) + .filter( + OaAppVersion.app_id == app_id, + OaAppVersion.tenant_no == tenant_no, + OaAppVersion.is_latest_published == "1", + OaAppVersion.del_flag == 0, + ) + .first() + ) + + +def _version_draft_to_dict(app: OaApp, ver: OaAppVersion) -> Dict[str, Any]: + advanced = _loads(ver.advanced_settings_json, None) + if not isinstance(advanced, dict): + advanced = dict(DEFAULT_ADVANCED_SETTINGS) + else: + merged = dict(DEFAULT_ADVANCED_SETTINGS) + merged.update(advanced) + advanced = merged + return { + "appId": app.id, + "versionId": ver.id, + "versionNo": ver.version_no, + "status": ver.status, + "basic": { + "groupId": app.group_id or "", + "appName": app.app_name, + "iconUrl": app.icon_url or "", + "description": app.description or "", + "initiatorScope": app.initiator_scope or "all", + "initiatorUserIds": _loads(app.initiator_user_ids, []), + "adminScope": app.admin_scope or "all_oa_admin", + "adminUserIds": _loads(app.admin_user_ids, []), + "status": app.status, + "orderNum": app.order_num if app.order_num is not None else 0, + "remark": app.remark or "", + }, + "formSchemaJson": _loads(ver.form_schema_json, {}), + "flowTreeJson": _loads(ver.flow_tree_json, {}), + "advancedSettingsJson": advanced, + "updateTime": ver.update_time.strftime("%Y-%m-%d %H:%M:%S") if ver.update_time else "", + } + + +def get_or_create_draft( + db: Session, app_id: str, *, tenant_no: str, user_id: Optional[str] = None +) -> Dict[str, Any]: + app = get_app(db, app_id, tenant_no) + if not app: + raise ValueError("应用不存在") + draft = _find_draft(db, app_id, tenant_no) + if draft: + return _version_draft_to_dict(app, draft) + published = _find_latest_published(db, app_id, tenant_no) + if published: + next_no = (published.version_no or 0) + 1 + draft = _create_version( + db, + app_id=app_id, + tenant_no=tenant_no, + version_no=next_no, + status="draft", + form_schema=_loads(published.form_schema_json, {}), + flow_tree=_loads(published.flow_tree_json, {}), + advanced=_loads(published.advanced_settings_json, DEFAULT_ADVANCED_SETTINGS), + user_id=user_id, + ) + else: + max_row = ( + db.query(OaAppVersion) + .filter(OaAppVersion.app_id == app_id, OaAppVersion.tenant_no == tenant_no, OaAppVersion.del_flag == 0) + .order_by(OaAppVersion.version_no.desc()) + .first() + ) + next_no = (max_row.version_no if max_row else 0) + 1 + draft = _create_version( + db, + app_id=app_id, + tenant_no=tenant_no, + version_no=next_no or 1, + status="draft", + form_schema={}, + flow_tree={}, + advanced=DEFAULT_ADVANCED_SETTINGS, + user_id=user_id, + ) + db.commit() + db.refresh(draft) + return _version_draft_to_dict(app, draft) + + +def save_draft( + db: Session, + app_id: str, + *, + tenant_no: str, + basic: Optional[Dict[str, Any]] = None, + form_schema_json: Any = None, + flow_tree_json: Any = None, + advanced_settings_json: Any = None, + user_id: Optional[str] = None, +) -> Dict[str, Any]: + app = get_app(db, app_id, tenant_no) + if not app: + raise ValueError("应用不存在") + # 确保草稿存在 + get_or_create_draft(db, app_id, tenant_no=tenant_no, user_id=user_id) + draft = _find_draft(db, app_id, tenant_no) + if not draft: + raise ValueError("草稿不存在") + if basic: + field_map = { + "appName": "app_name", + "groupId": "group_id", + "iconUrl": "icon_url", + "description": "description", + "initiatorScope": "initiator_scope", + "initiatorUserIds": "initiator_user_ids", + "adminScope": "admin_scope", + "adminUserIds": "admin_user_ids", + "status": "status", + "orderNum": "order_num", + "remark": "remark", + } + kwargs = {field_map[k]: basic[k] for k in field_map if k in basic} + if kwargs: + update_app(db, app_id, tenant_no=tenant_no, user_id=user_id, **kwargs) + app = get_app(db, app_id, tenant_no) + draft = _find_draft(db, app_id, tenant_no) + if not draft: + raise ValueError("草稿不存在") + now = datetime.now() + if form_schema_json is not None: + draft.form_schema_json = _dumps(form_schema_json) + if flow_tree_json is not None: + draft.flow_tree_json = _dumps(flow_tree_json) + if advanced_settings_json is not None: + if isinstance(advanced_settings_json, dict): + merged = dict(DEFAULT_ADVANCED_SETTINGS) + merged.update(advanced_settings_json) + draft.advanced_settings_json = _dumps(merged) + else: + draft.advanced_settings_json = _dumps(advanced_settings_json) + draft.update_by = user_id + draft.update_time = now + db.add(draft) + db.commit() + db.refresh(draft) + assert app is not None + return _version_draft_to_dict(app, draft) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index e6a73d6f..bacfb1d1 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -26,6 +26,7 @@ class SysUser(Base): id = Column(String(64), primary_key=True, comment="主键ID") dept_id = Column(String(64), nullable=True, comment="部门ID") + supervisor_user_id = Column(String(64), nullable=True, comment="直属主管用户ID") user_no = Column(String(30), nullable=False, comment="用户账号") nick_name = Column(String(30), nullable=False, comment="用户名") email = Column(String(50), nullable=False, default="", comment="用户邮箱") @@ -159,6 +160,7 @@ class SysDept(Base): dept_name = Column(String(200), nullable=False, comment="组织名称") dept_category_code = Column(String(64), nullable=True, comment="组织类别编码") status = Column(CHAR(1), nullable=False, default="1", comment="部门状态") + leader_user_id = Column(String(64), nullable=True, comment="部门负责人用户ID") leader = Column(String(100), nullable=True, comment="部门负责人") leader_phone = Column(String(32), nullable=True, comment="部门负责人联系电话") tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") @@ -728,6 +730,221 @@ class WfTask(Base): del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记:0-正常 1-已删除") +# ============================================================================= +# OA 审批(钉钉交互,oa_*) +# ============================================================================= + + +class OaAppGroup(Base): + """OA审批分组 oa_app_group""" + + __tablename__ = "oa_app_group" + + id = Column(String(64), primary_key=True, comment="主键ID") + group_name = Column(String(100), nullable=False, comment="分组名称") + tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") + order_num = Column(Integer, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") + + +class OaApp(Base): + """OA审批应用 oa_app""" + + __tablename__ = "oa_app" + + id = Column(String(64), primary_key=True, comment="主键ID") + group_id = Column(String(64), nullable=True, comment="分组ID") + app_name = Column(String(50), nullable=False, comment="表单/应用名称") + icon_url = Column(String(512), nullable=True, comment="图标") + description = Column(String(100), nullable=True, comment="表单说明") + initiator_scope = Column(String(32), nullable=False, default="all", comment="发起范围") + initiator_user_ids = Column(Text, nullable=True, comment="指定发起人JSON") + admin_scope = Column(String(32), nullable=False, default="all_oa_admin", comment="管理员范围") + admin_user_ids = Column(Text, nullable=True, comment="指定管理员JSON") + status = Column(CHAR(1), nullable=False, default="1", comment="1启用 0停用") + tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") + order_num = Column(Integer, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") + + +class OaAppVersion(Base): + """OA审批应用版本 oa_app_version""" + + __tablename__ = "oa_app_version" + + id = Column(String(64), primary_key=True, comment="主键ID") + app_id = Column(String(64), nullable=False, comment="应用ID") + version_no = Column(Integer, nullable=False, comment="版本号") + status = Column(String(16), nullable=False, default="draft", comment="draft/published") + is_latest_published = Column(CHAR(1), nullable=False, default="0", comment="是否最新已发布") + form_schema_json = Column(Text, nullable=True, comment="表单Schema JSON") + flow_tree_json = Column(Text, nullable=True, comment="流程树 JSON") + advanced_settings_json = Column(Text, nullable=True, comment="高级设置 JSON") + tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") + order_num = Column(Integer, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") + + +class OaRole(Base): + """OA工作流角色 oa_role""" + + __tablename__ = "oa_role" + + id = Column(String(64), primary_key=True, comment="主键ID") + role_name = Column(String(100), nullable=False, comment="角色名称") + role_code = Column(String(64), nullable=False, comment="角色编码") + role_kind = Column(String(16), nullable=False, default="custom", comment="custom/preset") + status = Column(CHAR(1), nullable=False, default="1", comment="1启用 0停用") + tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") + order_num = Column(Integer, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") + + +class OaRoleUser(Base): + """OA工作流角色成员 oa_role_user""" + + __tablename__ = "oa_role_user" + + id = Column(String(64), primary_key=True, comment="主键ID") + oa_role_id = Column(String(64), nullable=False, comment="工作流角色ID") + user_id = Column(String(64), nullable=False, comment="用户ID") + tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") + order_num = Column(Integer, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") + + +class OaInstance(Base): + """OA审批实例 oa_instance""" + + __tablename__ = "oa_instance" + + id = Column(String(64), primary_key=True, comment="主键ID") + app_id = Column(String(64), nullable=False, comment="应用ID") + app_version_id = Column(String(64), nullable=False, comment="锁定版本ID") + title = Column(String(200), nullable=True, comment="实例标题") + initiator_user_id = Column(String(64), nullable=False, comment="发起人") + initiator_dept_id = Column(String(64), nullable=True, comment="发起人部门") + form_data_json = Column(Text, nullable=True, comment="表单数据 JSON") + status = Column( + String(32), + nullable=False, + default="running", + comment="running/returned/completed/rejected/cancelled", + ) + source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例ID") + current_round = Column(Integer, nullable=False, default=1, comment="当前提交轮次") + tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") + order_num = Column(Integer, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") + + +class OaTask(Base): + """OA审批任务 oa_task""" + + __tablename__ = "oa_task" + + id = Column(String(64), primary_key=True, comment="主键ID") + instance_id = Column(String(64), nullable=False, comment="实例ID") + node_key = Column(String(64), nullable=True, comment="节点key") + node_name = Column(String(200), nullable=True, comment="节点名称") + task_type = Column(String(16), nullable=False, default="approval", comment="approval/cc") + assignee_user_id = Column(String(64), nullable=False, comment="处理人") + status = Column(String(32), nullable=False, default="pending", comment="任务状态") + comment_text = Column(String(512), nullable=True, comment="意见") + resolve_source = Column(String(64), nullable=True, comment="解析来源") + sign_mode = Column(String(8), nullable=True, comment="or/and") + round_no = Column(Integer, nullable=False, default=1, comment="所属提交轮次") + tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") + order_num = Column(Integer, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") + + +class OaInstanceEvent(Base): + """OA实例操作事件 oa_instance_event""" + + __tablename__ = "oa_instance_event" + + id = Column(String(64), primary_key=True, comment="主键ID") + instance_id = Column(String(64), nullable=False, comment="实例ID") + round_no = Column(Integer, nullable=False, default=1, comment="轮次") + event_type = Column(String(32), nullable=False, comment="submit/return/withdraw/…") + actor_user_id = Column(String(64), nullable=True, comment="操作人") + task_id = Column(String(64), nullable=True, comment="关联任务") + node_key = Column(String(64), nullable=True, comment="节点key") + comment_text = Column(String(512), nullable=True, comment="意见") + tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") + order_num = Column(Integer, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") + + +class OaRelation(Base): + """OA关联审批 oa_relation""" + + __tablename__ = "oa_relation" + + id = Column(String(64), primary_key=True, comment="主键ID") + from_instance_id = Column(String(64), nullable=False, comment="本实例ID") + to_instance_id = Column(String(64), nullable=False, comment="被关联实例ID") + field_key = Column(String(64), nullable=True, comment="表单字段key") + tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号") + order_num = Column(Integer, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记") + + # ============================================================================= # 细胞存储 — 采集协议单(biz_collection_protocol / audit / payment) # ============================================================================= diff --git a/backend/app/services/oa_engine.py b/backend/app/services/oa_engine.py index 1b746230..4392b75a 100644 --- a/backend/app/services/oa_engine.py +++ b/backend/app/services/oa_engine.py @@ -9,7 +9,16 @@ from sqlalchemy.orm import Session from app.core.utils import generate_uuid from app.crud import oa_approval as oa_crud -from app.models.models import OaApp, OaAppVersion, OaInstance, OaRelation, OaRole, OaTask, SysUser +from app.models.models import ( + OaApp, + OaAppVersion, + OaInstance, + OaInstanceEvent, + OaRelation, + OaRole, + OaTask, + SysUser, +) from app.services.oa_assignee import AssigneeResolveError, resolve_assignees from app.services.oa_formula import FormulaError, apply_formulas_and_linkages, eval_formula @@ -106,9 +115,10 @@ def _sort_tasks_for_detail( node_order = _flow_node_key_order(flow_root) if isinstance(flow_root, dict) else {} far = 10**9 - def key(t: OaTask) -> Tuple[int, int, datetime, str]: + def key(t: OaTask) -> Tuple[int, int, int, datetime, str]: nk = (t.node_key or "").strip() return ( + int(getattr(t, "round_no", None) or 1), node_order.get(nk, far), int(t.order_num) if t.order_num is not None else 0, t.create_time or datetime.min, @@ -141,58 +151,33 @@ def _terminal_item(status: str, title: str, inst: Any) -> Dict[str, Any]: } -def build_progress_items( +def _task_round(t: Any) -> int: + v = getattr(t, "round_no", None) + try: + if v is None: + return 1 + return int(v) + except (TypeError, ValueError): + return 1 + + +def _append_pending_nodes( + items: List[Dict[str, Any]], *, - inst: Any, - flow_root: Optional[Dict[str, Any]], - tasks: List[Any], - name_map: Dict[str, str], - events: Optional[List[Any]] = None, -) -> List[Dict[str, Any]]: - """投影审批进度。P0:submit + 按树序 tasks + pending_node + terminal。 - P1:插入 return/resubmit 事件并带 roundNo。""" - _ = events # P1 使用 - items: List[Dict[str, Any]] = [] - round_no = 1 - items.append({ - "kind": "submit", - "roundNo": round_no, - "title": "发起人", - "status": "submitted", - "assigneeUserId": inst.initiator_user_id, - "assigneeUserName": name_map.get(inst.initiator_user_id or "", ""), - "timestamp": _fmt_dt(getattr(inst, "create_time", None)), - "commentText": "", - "nodeKey": "", - "taskType": "", - }) - sorted_tasks = _sort_tasks_for_detail(list(tasks), flow_root=flow_root) - reached_keys = {(t.node_key or "").strip() for t in sorted_tasks if (t.node_key or "").strip()} - for t in sorted_tasks: - items.append({ - "kind": "task", - "roundNo": round_no, - "title": t.node_name or t.node_key or "-", - "status": t.status, - "assigneeUserId": t.assignee_user_id, - "assigneeUserName": name_map.get(t.assignee_user_id or "", ""), - "timestamp": _fmt_dt(t.update_time or t.create_time), - "commentText": t.comment_text or "", - "nodeKey": t.node_key or "", - "taskType": t.task_type or "", - "resolveSource": getattr(t, "resolve_source", None) or "", - "taskId": t.id, - }) - if inst.status == "running" and isinstance(flow_root, dict): - order = _flow_node_key_order(flow_root) - for nid, _idx in sorted(order.items(), key=lambda x: x[1]): - node = _find_node(flow_root, nid) - if not node: - continue - nt = _node_type(node) - if nt not in ("approval", "cc") or nid in reached_keys: - continue - items.append({ + flow_root: Dict[str, Any], + reached_keys: set, + round_no: int, +) -> None: + order = _flow_node_key_order(flow_root) + for nid, _idx in sorted(order.items(), key=lambda x: x[1]): + node = _find_node(flow_root, nid) + if not node: + continue + nt = _node_type(node) + if nt not in ("approval", "cc") or nid in reached_keys: + continue + items.append( + { "kind": "pending_node", "roundNo": round_no, "title": node.get("name") or ("抄送人" if nt == "cc" else "审批人"), @@ -203,7 +188,101 @@ def build_progress_items( "commentText": "", "nodeKey": nid, "taskType": nt, - }) + } + ) + + +def build_progress_items( + *, + inst: Any, + flow_root: Optional[Dict[str, Any]], + tasks: List[Any], + name_map: Dict[str, str], + events: Optional[List[Any]] = None, +) -> List[Dict[str, Any]]: + """投影审批进度:按轮次 submit → 树序 tasks → return 事件 → 未到达;终态收尾。""" + events = list(events or []) + sorted_tasks = _sort_tasks_for_detail(list(tasks), flow_root=flow_root) + current_round = int(getattr(inst, "current_round", None) or 1) + round_set = {1, current_round} + for t in sorted_tasks: + round_set.add(_task_round(t)) + for e in events: + round_set.add(int(getattr(e, "round_no", None) or 1)) + rounds = sorted(round_set) + + returns_by_round: Dict[int, Any] = {} + for e in events: + if (getattr(e, "event_type", None) or "") == "return": + returns_by_round[int(getattr(e, "round_no", None) or 1)] = e + + items: List[Dict[str, Any]] = [] + for rn in rounds: + submit_ts = _fmt_dt(getattr(inst, "create_time", None)) if rn == 1 else "" + for e in events: + if (getattr(e, "event_type", None) or "") == "submit" and int( + getattr(e, "round_no", None) or 1 + ) == rn: + submit_ts = _fmt_dt(getattr(e, "create_time", None)) or submit_ts + break + items.append( + { + "kind": "submit", + "roundNo": rn, + "title": "发起人" if rn == 1 else f"发起人(第 {rn} 次提交)", + "status": "submitted", + "assigneeUserId": inst.initiator_user_id, + "assigneeUserName": name_map.get(inst.initiator_user_id or "", ""), + "timestamp": submit_ts, + "commentText": "", + "nodeKey": "", + "taskType": "", + } + ) + round_tasks = [t for t in sorted_tasks if _task_round(t) == rn] + reached_keys = { + (t.node_key or "").strip() for t in round_tasks if (t.node_key or "").strip() + } + for t in round_tasks: + items.append( + { + "kind": "task", + "roundNo": rn, + "title": t.node_name or t.node_key or "-", + "status": t.status, + "assigneeUserId": t.assignee_user_id, + "assigneeUserName": name_map.get(t.assignee_user_id or "", ""), + "timestamp": _fmt_dt(t.update_time or t.create_time), + "commentText": t.comment_text or "", + "nodeKey": t.node_key or "", + "taskType": t.task_type or "", + "resolveSource": getattr(t, "resolve_source", None) or "", + "taskId": t.id, + } + ) + ret = returns_by_round.get(rn) + if ret: + actor = getattr(ret, "actor_user_id", None) or "" + items.append( + { + "kind": "return", + "roundNo": rn, + "title": "打回", + "status": "returned", + "assigneeUserId": actor, + "assigneeUserName": name_map.get(actor, ""), + "timestamp": _fmt_dt(getattr(ret, "create_time", None)), + "commentText": getattr(ret, "comment_text", None) or "", + "nodeKey": getattr(ret, "node_key", None) or "", + "taskType": "approval", + "taskId": getattr(ret, "task_id", None) or "", + } + ) + if inst.status == "running" and rn == current_round and isinstance(flow_root, dict): + _append_pending_nodes( + items, flow_root=flow_root, reached_keys=reached_keys, round_no=rn + ) + if inst.status == "completed": items.append(_terminal_item("done", "流程结束", inst)) elif inst.status == "rejected": @@ -213,6 +292,41 @@ def build_progress_items( return items +def _add_instance_event( + db: Session, + *, + inst: OaInstance, + event_type: str, + actor_user_id: Optional[str], + round_no: int, + comment: Optional[str] = None, + task_id: Optional[str] = None, + node_key: Optional[str] = None, +) -> OaInstanceEvent: + now = datetime.now() + ev = OaInstanceEvent( + id=generate_uuid(), + instance_id=inst.id, + round_no=round_no, + event_type=event_type, + actor_user_id=actor_user_id, + task_id=task_id, + node_key=node_key, + comment_text=(comment or "").strip() or None, + tenant_no=inst.tenant_no, + order_num=0, + remark=None, + create_by=actor_user_id, + create_dept_id=inst.initiator_dept_id or "", + create_time=now, + update_by=actor_user_id, + update_time=now, + del_flag=0, + ) + db.add(ev) + return ev + + def _advanced(ver: OaAppVersion) -> Dict[str, Any]: data = _loads(ver.advanced_settings_json, {}) or {} if not isinstance(data, dict): @@ -242,6 +356,8 @@ def _instance_to_dict(inst: OaInstance, *, app_name: str = "") -> Dict[str, Any] "initiatorDeptId": inst.initiator_dept_id or "", "formDataJson": _loads(inst.form_data_json, {}), "status": inst.status, + "sourceInstanceId": getattr(inst, "source_instance_id", None) or "", + "currentRound": int(getattr(inst, "current_round", None) or 1), "createTime": inst.create_time.strftime("%Y-%m-%d %H:%M:%S") if inst.create_time else "", "updateTime": inst.update_time.strftime("%Y-%m-%d %H:%M:%S") if inst.update_time else "", } @@ -615,6 +731,7 @@ def _create_tasks_for_node( comment_text=None, resolve_source=resolve_source, sign_mode=sign_mode if task_type == "approval" else None, + round_no=int(getattr(inst, "current_round", None) or 1), tenant_no=inst.tenant_no, order_num=seq, remark=None, @@ -753,6 +870,7 @@ def start_instance( title: Optional[str] = None, initiator_select_user_ids: Optional[List[str]] = None, related_instance_ids: Optional[List[str]] = None, + source_instance_id: Optional[str] = None, ) -> Dict[str, Any]: app = oa_crud.get_app(db, app_id, tenant_no) if not app or app.status != "1": @@ -762,6 +880,14 @@ def start_instance( if not ver: raise OaEngineError("应用尚未发布,无法发起") + src_id = (source_instance_id or "").strip() or None + if src_id: + src = _get_instance(db, src_id, tenant_no) + if src.initiator_user_id != initiator_user_id: + raise OaEngineError("只能从本人撤销的审批重提") + if src.status != "cancelled": + raise OaEngineError("仅已撤销的审批可关联重提") + schema = _loads(ver.form_schema_json, {}) data = dict(form_data or {}) try: @@ -777,15 +903,20 @@ def start_instance( 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() + if src_id and title_text and not title_text.startswith("重提-"): + title_text = f"重提-{title_text}" inst = OaInstance( id=generate_uuid(), app_id=app_id, app_version_id=ver.id, - title=(title or app.app_name or "").strip()[:200], + title=title_text[:200], initiator_user_id=initiator_user_id, initiator_dept_id=dept_id, form_data_json=_dumps(data), status="running", + source_instance_id=src_id, + current_round=1, tenant_no=tenant_no, order_num=0, remark=None, @@ -820,6 +951,13 @@ def start_instance( ) ) + _add_instance_event( + db, + inst=inst, + event_type="submit", + actor_user_id=initiator_user_id, + round_no=1, + ) advance_from( db, inst=inst, @@ -830,7 +968,7 @@ def start_instance( ) db.commit() db.refresh(inst) - return _instance_to_dict(inst, app_name=app.app_name) + return get_instance_detail(db, inst.id, tenant_no=tenant_no) def _get_instance(db: Session, instance_id: str, tenant_no: str) -> OaInstance: @@ -882,15 +1020,37 @@ def get_instance_detail(db: Session, instance_id: str, *, tenant_no: str) -> Dic ] data["relatedInstanceIds"] = [r.to_instance_id for r in rels] data["formSchemaJson"] = _loads(ver.form_schema_json, {}) if ver else {} - data["advancedSettingsJson"] = _advanced(ver) if ver else dict(oa_crud.DEFAULT_ADVANCED_SETTINGS) - data["allowAddSign"] = bool((_advanced(ver) if ver else {}).get("allowAddSign", True)) + adv = _advanced(ver) if ver else dict(oa_crud.DEFAULT_ADVANCED_SETTINGS) + data["advancedSettingsJson"] = adv + data["allowAddSign"] = bool(adv.get("allowAddSign", True)) + data["allowReturn"] = bool(adv.get("allowReturn", True)) + events = ( + db.query(OaInstanceEvent) + .filter(OaInstanceEvent.instance_id == instance_id, OaInstanceEvent.del_flag == 0) + .order_by(OaInstanceEvent.create_time.asc()) + .all() + ) + event_actors = [e.actor_user_id for e in events if e.actor_user_id] + if event_actors: + name_map.update(_user_name_map(db, event_actors)) data["progress"] = build_progress_items( inst=inst, flow_root=flow_root, tasks=tasks, name_map=name_map, - events=[], + events=events, ) + resub = ( + db.query(OaInstance) + .filter( + OaInstance.source_instance_id == instance_id, + OaInstance.tenant_no == tenant_no, + OaInstance.del_flag == 0, + ) + .order_by(OaInstance.create_time.desc()) + .first() + ) + data["resubmittedInstanceId"] = resub.id if resub else "" return data @@ -1155,6 +1315,7 @@ def add_sign( comment_text=None, resolve_source="add_sign", sign_mode=task.sign_mode or "or", + round_no=int(getattr(inst, "current_round", None) or getattr(task, "round_no", None) or 1), tenant_no=tenant_no, order_num=next_order, remark=(comment or "").strip() or None, @@ -1180,6 +1341,144 @@ def add_sign( return get_instance_detail(db, inst.id, tenant_no=tenant_no) +def return_task( + db: Session, + task_id: str, + *, + tenant_no: str, + user_id: str, + comment: Optional[str] = None, +) -> Dict[str, Any]: + """审批人打回:退回发起人修改,同实例不结束。""" + task = ( + db.query(OaTask) + .filter(OaTask.id == task_id, OaTask.tenant_no == tenant_no, OaTask.del_flag == 0) + .first() + ) + if not task: + raise OaEngineError("任务不存在") + if task.assignee_user_id != user_id: + raise OaEngineError("无权打回该任务") + if task.task_type != "approval" or task.status != "pending": + raise OaEngineError("仅待处理的审批任务可打回") + + inst = _get_instance(db, task.instance_id, tenant_no) + if inst.status != "running": + raise OaEngineError("仅进行中的审批可打回") + + ver = ( + db.query(OaAppVersion) + .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0) + .first() + ) + if not ver: + raise OaEngineError("流程版本不存在") + advanced = _advanced(ver) + if not advanced.get("allowReturn", True): + raise OaEngineError("该应用不允许打回") + + comment_text = (comment or "").strip() + if not comment_text: + raise OaEngineError("打回意见必填") + + now = datetime.now() + round_no = int(getattr(inst, "current_round", None) or getattr(task, "round_no", None) or 1) + task.status = "returned" + task.comment_text = comment_text + task.update_by = user_id + task.update_time = now + db.add(task) + + for t in ( + db.query(OaTask) + .filter( + OaTask.instance_id == inst.id, + OaTask.status == "pending", + OaTask.del_flag == 0, + OaTask.id != task.id, + ) + .all() + ): + t.status = "cancelled" + t.update_by = user_id + t.update_time = now + db.add(t) + + inst.status = "returned" + inst.update_by = user_id + inst.update_time = now + db.add(inst) + _add_instance_event( + db, + inst=inst, + event_type="return", + actor_user_id=user_id, + round_no=round_no, + comment=comment_text, + task_id=task.id, + node_key=task.node_key, + ) + db.commit() + return get_instance_detail(db, inst.id, tenant_no=tenant_no) + + +def resubmit_instance( + db: Session, + instance_id: str, + *, + tenant_no: str, + user_id: str, + form_data: Optional[Dict[str, Any]] = None, + initiator_select_user_ids: Optional[List[str]] = None, +) -> Dict[str, Any]: + """打回后发起人修改表单并再次提交(同实例新一轮)。""" + inst = _get_instance(db, instance_id, tenant_no) + if inst.initiator_user_id != user_id: + raise OaEngineError("仅发起人可再次提交") + if inst.status != "returned": + raise OaEngineError("仅打回待修改的审批可再次提交") + + ver = ( + db.query(OaAppVersion) + .filter(OaAppVersion.id == inst.app_version_id, OaAppVersion.del_flag == 0) + .first() + ) + if not ver: + raise OaEngineError("流程版本不存在") + + schema = _loads(ver.form_schema_json, {}) + data = dict(form_data if form_data is not None else (_loads(inst.form_data_json, {}) or {})) + try: + data = apply_formulas_and_linkages(schema, data) + except FormulaError as e: + raise OaEngineError(f"公式计算失败: {e}") from e + + now = datetime.now() + inst.form_data_json = _dumps(data) + inst.current_round = int(getattr(inst, "current_round", None) or 1) + 1 + inst.status = "running" + inst.update_by = user_id + inst.update_time = now + db.add(inst) + _add_instance_event( + db, + inst=inst, + event_type="submit", + actor_user_id=user_id, + round_no=inst.current_round, + ) + advance_from( + db, + inst=inst, + ver=ver, + from_node=None, + user_id=user_id, + initiator_select_user_ids=initiator_select_user_ids, + ) + db.commit() + return get_instance_detail(db, inst.id, tenant_no=tenant_no) + + def withdraw_instance( db: Session, instance_id: str, *, tenant_no: str, user_id: str ) -> Dict[str, Any]: @@ -1207,9 +1506,16 @@ def withdraw_instance( .filter(OaTask.instance_id == inst.id, OaTask.status == "pending", OaTask.del_flag == 0) .all() ): - t.status = "done" + t.status = "cancelled" t.update_by = user_id t.update_time = now db.add(t) + _add_instance_event( + db, + inst=inst, + event_type="withdraw", + actor_user_id=user_id, + round_no=int(getattr(inst, "current_round", None) or 1), + ) db.commit() return get_instance_detail(db, inst.id, tenant_no=tenant_no) diff --git a/backend/tests/test_oa_engine.py b/backend/tests/test_oa_engine.py index 4ce21721..42e279c3 100644 --- a/backend/tests/test_oa_engine.py +++ b/backend/tests/test_oa_engine.py @@ -243,13 +243,14 @@ class TestCompleteTaskOrSign(unittest.TestCase): from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from app.core.database import Base - from app.models.models import OaAppVersion, OaInstance, OaTask + from app.models.models import OaAppVersion, OaInstance, OaInstanceEvent, OaTask self.engine = create_engine("sqlite:///:memory:") # 仅建引擎相关表 OaAppVersion.__table__.create(self.engine) OaInstance.__table__.create(self.engine) OaTask.__table__.create(self.engine) + OaInstanceEvent.__table__.create(self.engine) Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False) self.db = Session() self.Base = Base @@ -429,6 +430,7 @@ class TestBuildProgress(unittest.TestCase): status="pending", assignee_user_id="u2", order_num=0, + round_no=1, create_time=ts, update_time=ts, comment_text=None, @@ -442,6 +444,7 @@ class TestBuildProgress(unittest.TestCase): status="done", assignee_user_id="u9", order_num=0, + round_no=1, create_time=ts, update_time=ts, comment_text=None, @@ -450,6 +453,7 @@ class TestBuildProgress(unittest.TestCase): inst = MagicMock( status="running", initiator_user_id="u0", + current_round=1, create_time=ts, update_time=ts, ) @@ -498,6 +502,7 @@ class TestBuildProgress(unittest.TestCase): status="rejected", assignee_user_id="u2", order_num=0, + round_no=1, create_time=ts, update_time=ts, comment_text="不同意", @@ -507,6 +512,7 @@ class TestBuildProgress(unittest.TestCase): inst = MagicMock( status=status, initiator_user_id="u0", + current_round=1, create_time=ts, update_time=ts, ) @@ -523,6 +529,156 @@ class TestBuildProgress(unittest.TestCase): self.assertEqual(len(terminal), 1, f"status={status} should have one terminal") +class TestReturnAndResubmit(unittest.TestCase): + def setUp(self): + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from app.models.models import OaAppVersion, OaInstance, OaInstanceEvent, OaTask + + self.engine = create_engine("sqlite:///:memory:") + OaAppVersion.__table__.create(self.engine) + OaInstance.__table__.create(self.engine) + OaTask.__table__.create(self.engine) + OaInstanceEvent.__table__.create(self.engine) + Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False) + self.db = Session() + self.OaInstance = OaInstance + self.OaTask = OaTask + self.OaInstanceEvent = OaInstanceEvent + + flow = { + "id": "s", + "type": "start", + "child": { + "id": "a1", + "type": "approval", + "name": "审批人", + "approverConfig": { + "assigneeType": "users", + "userIds": ["u1"], + "signMode": "or", + }, + "child": {"id": "e1", "type": "end"}, + }, + } + now = datetime.now() + self.db.add( + OaAppVersion( + id="v1", + app_id="app1", + version_no=1, + status="published", + is_latest_published="1", + form_schema_json="{}", + flow_tree_json=json.dumps(flow), + advanced_settings_json=json.dumps({"allowReturn": True}), + tenant_no="zkxw", + create_dept_id="", + create_time=now, + update_time=now, + del_flag=0, + ) + ) + self.db.add( + OaInstance( + id="i1", + app_id="app1", + app_version_id="v1", + title="打回测", + initiator_user_id="u0", + initiator_dept_id="d1", + form_data_json="{}", + status="running", + current_round=1, + tenant_no="zkxw", + create_dept_id="", + create_time=now, + update_time=now, + del_flag=0, + ) + ) + self.db.add( + OaTask( + id="t1", + instance_id="i1", + node_key="a1", + node_name="审批人", + task_type="approval", + assignee_user_id="u1", + status="pending", + sign_mode="or", + round_no=1, + tenant_no="zkxw", + create_dept_id="", + create_time=now, + update_time=now, + del_flag=0, + ) + ) + self.db.commit() + + def tearDown(self): + self.db.close() + self.engine.dispose() + + def test_return_sets_returned_and_event(self): + from app.services.oa_engine import return_task + + with patch( + "app.services.oa_engine.get_instance_detail", + return_value={"id": "i1", "status": "returned"}, + ): + return_task( + self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料" + ) + inst = self.db.query(self.OaInstance).filter_by(id="i1").first() + task = self.db.query(self.OaTask).filter_by(id="t1").first() + ev = ( + self.db.query(self.OaInstanceEvent) + .filter_by(instance_id="i1", event_type="return") + .first() + ) + self.assertEqual(inst.status, "returned") + self.assertEqual(task.status, "returned") + self.assertIsNotNone(ev) + self.assertEqual(ev.comment_text, "请补充材料") + + def test_resubmit_starts_new_round(self): + from app.services.oa_engine import resubmit_instance, return_task + + with patch( + "app.services.oa_engine.get_instance_detail", + return_value={"id": "i1", "status": "returned"}, + ): + return_task( + self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料" + ) + + fake_task = MagicMock() + with patch( + "app.services.oa_engine._create_tasks_for_node", return_value=[fake_task] + ), patch( + "app.services.oa_engine.get_instance_detail", + return_value={"id": "i1", "status": "running"}, + ): + resubmit_instance( + self.db, + "i1", + tenant_no="zkxw", + user_id="u0", + form_data={"x": 1}, + ) + inst = self.db.query(self.OaInstance).filter_by(id="i1").first() + self.assertEqual(inst.status, "running") + self.assertEqual(inst.current_round, 2) + submit_ev = ( + self.db.query(self.OaInstanceEvent) + .filter_by(instance_id="i1", event_type="submit", round_no=2) + .first() + ) + self.assertIsNotNone(submit_ev) + + if __name__ == "__main__": unittest.main() diff --git a/docs/sql/patch_2026-07-15_oa_progress_return.sql b/docs/sql/patch_2026-07-15_oa_progress_return.sql new file mode 100644 index 00000000..da158206 --- /dev/null +++ b/docs/sql/patch_2026-07-15_oa_progress_return.sql @@ -0,0 +1,34 @@ +-- OA 审批进度多轮 + 打回 + 撤销重提关联 +-- MySQL 5.7 / utf8mb4 + +ALTER TABLE oa_instance + ADD COLUMN source_instance_id VARCHAR(64) NULL COMMENT '撤销重提所关联的原实例ID' AFTER status, + ADD COLUMN current_round INT NOT NULL DEFAULT 1 COMMENT '当前提交轮次,从1起' AFTER source_instance_id; + +-- status 语义:running / returned / completed / rejected / cancelled +-- returned = 已打回,待发起人修改再提交 + +ALTER TABLE oa_task + ADD COLUMN round_no INT NOT NULL DEFAULT 1 COMMENT '所属提交轮次' AFTER sign_mode; + +CREATE TABLE IF NOT EXISTS oa_instance_event ( + id VARCHAR(64) NOT NULL COMMENT '主键ID', + instance_id VARCHAR(64) NOT NULL COMMENT '实例ID', + round_no INT NOT NULL DEFAULT 1 COMMENT '轮次', + event_type VARCHAR(32) NOT NULL COMMENT 'submit/return/withdraw/complete/reject_end', + actor_user_id VARCHAR(64) NULL COMMENT '操作人', + task_id VARCHAR(64) NULL COMMENT '关联任务(打回时)', + node_key VARCHAR(64) NULL COMMENT '节点key', + comment_text VARCHAR(512) NULL COMMENT '意见', + tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号', + order_num INT NULL COMMENT '排序号', + remark VARCHAR(512) NULL COMMENT '备注', + create_by VARCHAR(64) NULL COMMENT '创建人id', + create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID', + create_time DATETIME NOT NULL COMMENT '创建时间', + update_by VARCHAR(64) NULL COMMENT '更新人id', + update_time DATETIME NOT NULL COMMENT '更新时间', + del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记', + PRIMARY KEY (id), + KEY idx_oa_evt_inst (instance_id, del_flag, create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA实例操作事件(进度轴)'; diff --git a/docs/superpowers/plans/2026-07-15-oa-approval-progress-timeline.md b/docs/superpowers/plans/2026-07-15-oa-approval-progress-timeline.md new file mode 100644 index 00000000..e2ed8fda --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-oa-approval-progress-timeline.md @@ -0,0 +1,759 @@ +# OA 审批进度与打回/撤销重提 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 按钉钉语义落地审批进度展示,并分阶段实现:P0 稳单轮进度轴;P1 打回同实例再提交 + 撤销关联新单。 + +**Architecture:** 详情进度由后端投影 `progress`(发起/任务/未到达/终态/轮次),任务仍落 `oa_task`;P1 新增 `oa_instance_event` 记录提交/打回/撤销等非任务事件,并给实例增加 `returned` 状态与 `source_instance_id`。轮内按流程树节点序排序(已有 `_sort_tasks_for_detail`),轮间按 `round_no`。 + +**Tech Stack:** FastAPI · SQLAlchemy · MySQL 5.7 · Vue 3 · Element Plus · Python `unittest` + +**Design spec:** `docs/superpowers/specs/2026-07-15-oa-approval-progress-timeline-design.md` + +--- + +## Open questions 收口(本计划采纳) + +| # | 决议 | +|---|------| +| 1 | 打回后实例状态 = **`returned`**(待发起人修改再提交);列表「我发起的」需能筛出该状态 | +| 2 | P1 打回目标 **仅退回发起人(start)**;不支持退回到中间审批节点 | +| 3 | 使用独立表 **`oa_instance_event`**(对齐钉钉 `operation_records` 思路),不用纯 task 投影硬扛多轮 | + +--- + +## File Map + +| 文件 | 职责 | +|------|------| +| `docs/sql/patch_2026-07-15_oa_progress_return.sql` | **P1 新建** `oa_instance_event`;`oa_instance` 增 `source_instance_id`、`current_round`;注释扩展 status | +| `backend/app/models/models.py` | `OaInstance` 新字段;**新建** `OaInstanceEvent` | +| `backend/app/services/oa_engine.py` | `build_progress`;`return_task`;`resubmit_instance`;start/withdraw 写事件;详情带 `progress` | +| `backend/app/crud/oa_approval.py` | `DEFAULT_ADVANCED_SETTINGS.allowReturn` | +| `backend/app/api/oa_approval.py` | `POST .../return`、`POST .../resubmit`;start 接受 `sourceInstanceId` | +| `backend/tests/test_oa_engine.py` | 排序/progress/打回/再提交/关联单测 | +| `backend/tests/test_oa_progress.py` | **新建** progress 投影单测(可并入 engine 测) | +| `web-admin/src/api/oaApproval.ts` | return / resubmit / start 扩展 | +| `web-admin/src/views/oa-approval/instances/detail.vue` | 消费 `progress`;打回/再提交按钮;轮次 UI | +| `web-admin/src/views/oa-approval/start/index.vue` | 撤销重提带 `sourceInstanceId`;`returned` 再提交入口可进编辑 | +| `web-admin/src/views/oa-approval/designer/index.vue` | 高级设置「允许打回」开关 | +| `web-admin/src/views/oa-approval/mine/index.vue` 或 `RuntimeList.vue` | 状态文案含 `returned` | + +--- + +## 依赖顺序 + +```text +Task 1 P0 progress 投影 API + 单测 + → Task 2 P0 详情页改吃 progress + 未到达 + 文案 + → Task 3 P1 schema / model / allowReturn + → Task 4 P1 return_task 引擎 + API + → Task 5 P1 resubmit_instance + 事件轮次 + → Task 6 P1 撤销重提关联(source_instance_id) + → Task 7 P1 前端打回 / 再提交 / 多轮轴 / 关联提示 + → Task 8 验收清单走查 + 图谱更新 +``` + +**说明:** 会话中已落地的 `_flow_node_key_order` / `_sort_tasks_for_detail` / 详情简易 `timelineItems` 视为 Task 1–2 的起点,需并入正式 `progress` 契约,避免前后端各拼一套。 + +--- + +### Task 1: P0 — 后端 `progress` 投影 + +**Files:** +- Modify: `backend/app/services/oa_engine.py` +- Modify: `backend/tests/test_oa_engine.py` + +- [ ] **Step 1: 增加 failing 单测(progress 含发起 + 抄送先于审批 + 未到达)** + +在 `backend/tests/test_oa_engine.py` 增加: + +```python +class TestBuildProgress(unittest.TestCase): + def test_cc_before_approval_and_pending_ahead(self): + from app.services.oa_engine import build_progress_items + + root = { + "id": "s", + "type": "start", + "child": { + "id": "c1", + "type": "cc", + "name": "抄送人", + "child": { + "id": "a1", + "type": "approval", + "name": "审批人", + "child": { + "id": "a2", + "type": "approval", + "name": "总经理", + "child": {"id": "e1", "type": "end"}, + }, + }, + }, + } + ts = datetime(2026, 7, 15, 14, 5, 7) + # 审批任务 create 同秒但列表故意审批在前 + t_approval = MagicMock( + id="tA", + node_key="a1", + node_name="审批人", + task_type="approval", + status="pending", + assignee_user_id="u2", + order_num=0, + create_time=ts, + update_time=ts, + comment_text=None, + resolve_source="users", + ) + t_cc = MagicMock( + id="tC", + node_key="c1", + node_name="抄送人", + task_type="cc", + status="done", + assignee_user_id="u9", + order_num=0, + create_time=ts, + update_time=ts, + comment_text=None, + resolve_source="users", + ) + inst = MagicMock( + status="running", + initiator_user_id="u0", + create_time=ts, + update_time=ts, + ) + items = build_progress_items( + inst=inst, + flow_root=root, + tasks=[t_approval, t_cc], + name_map={"u0": "发起人甲", "u2": "审批乙", "u9": "抄送丙"}, + events=[], # P0 可空 + ) + kinds = [x["kind"] for x in items] + self.assertEqual(kinds[0], "submit") + # 已产生:cc 再 approval + self.assertEqual( + [x["nodeKey"] for x in items if x["kind"] == "task"], + ["c1", "a1"], + ) + # running 时未到达 a2 + pending = [x for x in items if x["kind"] == "pending_node"] + self.assertTrue(any(p["nodeKey"] == "a2" for p in pending)) +``` + +- [ ] **Step 2: 跑测确认失败** + +```bash +cd backend && ./venv/bin/python -m unittest tests.test_oa_engine.TestBuildProgress -v +``` + +Expected: `ImportError` 或 `AttributeError: build_progress_items` + +- [ ] **Step 3: 实现 `build_progress_items` + 详情挂载** + +在 `oa_engine.py` 增加(要点): + +```python +def build_progress_items( + *, + inst: Any, + flow_root: Optional[Dict[str, Any]], + tasks: List[Any], + name_map: Dict[str, str], + events: Optional[List[Any]] = None, +) -> List[Dict[str, Any]]: + """投影审批进度。P0:submit + 按树序 tasks + pending_node + terminal。 + P1:插入 return/resubmit 事件并带 roundNo。""" + items: List[Dict[str, Any]] = [] + round_no = 1 + # 1) 首条提交(无 events 时用实例创建) + items.append({ + "kind": "submit", + "roundNo": round_no, + "title": "发起人", + "status": "submitted", + "assigneeUserId": inst.initiator_user_id, + "assigneeUserName": name_map.get(inst.initiator_user_id or "", ""), + "timestamp": _fmt_dt(getattr(inst, "create_time", None)), + "commentText": "", + "nodeKey": "", + "taskType": "", + }) + sorted_tasks = _sort_tasks_for_detail(list(tasks), flow_root=flow_root) + reached_keys = {(t.node_key or "").strip() for t in sorted_tasks if (t.node_key or "").strip()} + for t in sorted_tasks: + items.append({ + "kind": "task", + "roundNo": round_no, + "title": t.node_name or t.node_key or "-", + "status": t.status, + "assigneeUserId": t.assignee_user_id, + "assigneeUserName": name_map.get(t.assignee_user_id or "", ""), + "timestamp": _fmt_dt(t.update_time or t.create_time), + "commentText": t.comment_text or "", + "nodeKey": t.node_key or "", + "taskType": t.task_type or "", + "resolveSource": getattr(t, "resolve_source", None) or "", + "taskId": t.id, + }) + # 2) 未到达:running 时,流程树中 approval/cc 且不在 reached_keys + if inst.status == "running" and isinstance(flow_root, dict): + order = _flow_node_key_order(flow_root) + for nid, _idx in sorted(order.items(), key=lambda x: x[1]): + node = _find_node(flow_root, nid) + if not node: + continue + nt = _node_type(node) + if nt not in ("approval", "cc") or nid in reached_keys: + continue + items.append({ + "kind": "pending_node", + "roundNo": round_no, + "title": node.get("name") or ("抄送人" if nt == "cc" else "审批人"), + "status": "not_reached", + "assigneeUserId": "", + "assigneeUserName": "", + "timestamp": "", + "commentText": "", + "nodeKey": nid, + "taskType": nt, + }) + # 3) 终态 + if inst.status == "completed": + items.append(_terminal_item("done", "流程结束", inst)) + elif inst.status == "rejected": + items.append(_terminal_item("rejected", "流程结束(已拒绝)", inst)) + elif inst.status == "cancelled": + items.append(_terminal_item("cancelled", "已撤销", inst)) + return items + + +def _fmt_dt(dt: Any) -> str: + if not dt: + return "" + if hasattr(dt, "strftime"): + return dt.strftime("%Y-%m-%d %H:%M:%S") + return str(dt) + + +def _terminal_item(status: str, title: str, inst: Any) -> Dict[str, Any]: + return { + "kind": "terminal", + "roundNo": getattr(inst, "current_round", None) or 1, + "title": title, + "status": status, + "assigneeUserId": "", + "assigneeUserName": "", + "timestamp": _fmt_dt(getattr(inst, "update_time", None)), + "commentText": "", + "nodeKey": "", + "taskType": "", + } +``` + +在 `get_instance_detail` 中: + +```python +data["progress"] = build_progress_items( + inst=inst, + flow_root=flow_root, + tasks=tasks, + name_map=name_map, + events=[], # Task 3+ 再查 oa_instance_event +) +# 保留 data["tasks"] 供审批按钮找 pending;UI 进度优先 progress +``` + +- [ ] **Step 4: 跑测通过** + +```bash +cd backend && ./venv/bin/python -m unittest tests.test_oa_engine.TestBuildProgress tests.test_oa_engine.TestOaEngineHelpers -v +``` + +Expected: OK + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/oa_engine.py backend/tests/test_oa_engine.py +git commit -m "$(cat <<'EOF' +feat(oa): project approval progress for instance detail + +EOF +)" +``` + +--- + +### Task 2: P0 — 详情页消费 `progress` + +**Files:** +- Modify: `web-admin/src/views/oa-approval/instances/detail.vue` +- Modify: `web-admin/src/api/oaApproval.ts`(可选:为 progress 补类型注释) + +- [ ] **Step 1: 用 `detail.progress` 替换本地 `timelineItems` 拼装** + +`timelineItems` 改为: + +```typescript +const timelineItems = computed(() => { + const list = detail.value?.progress + if (Array.isArray(list) && list.length) { + return list.map((p: any, idx: number) => ({ + key: `${p.kind}-${p.taskId || p.nodeKey || idx}-${p.roundNo || 1}`, + title: p.title || '-', + status: p.status || 'info', + timestamp: p.timestamp || '', + assignee: p.assigneeUserName || p.assigneeUserId || '', + resolveSource: p.resolveSource || '', + commentText: p.commentText || '', + taskType: p.taskType || '', + kind: p.kind || '', + roundNo: p.roundNo || 1 + })) + } + return [] // 无 progress 时不要静默用错序 tasks +}) +``` + +文案映射补充: + +```typescript +not_reached: '未到达', +submitted: '已提交', +``` + +模板中:若 `item.kind === 'pending_node'`,不显示「处理人」行;`item.kind === 'terminal'` 可不显示处理人。 + +按钮文案确认:拒绝按钮文案为 **「拒绝」**,禁止写成「打回」。 + +- [ ] **Step 2: 手工验收** + +1. 打开已有实例 `F6E46BC0CB2C4A12A4B324A9735ED1E0`(或本地等价:设计抄送→审批)。 +2. 进度应为:发起人 → 抄送人 → 审批人(待处理)→(若有后续)未到达。 +3. 拒绝后进度末尾为「流程结束(已拒绝)」且无「未到达」。 + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/views/oa-approval/instances/detail.vue web-admin/src/api/oaApproval.ts +git commit -m "$(cat <<'EOF' +feat(oa): render instance detail progress from API projection + +EOF +)" +``` + +--- + +### Task 3: P1 — Schema / Model / 高级设置 + +**Files:** +- Create: `docs/sql/patch_2026-07-15_oa_progress_return.sql` +- Modify: `backend/app/models/models.py` +- Modify: `backend/app/crud/oa_approval.py` + +- [ ] **Step 1: 编写 SQL patch** + +```sql +-- docs/sql/patch_2026-07-15_oa_progress_return.sql +-- OA 审批进度多轮 + 打回 + 撤销重提关联 + +ALTER TABLE oa_instance + ADD COLUMN source_instance_id VARCHAR(64) NULL COMMENT '撤销重提所关联的原实例ID' AFTER status, + ADD COLUMN current_round INT NOT NULL DEFAULT 1 COMMENT '当前提交轮次,从1起' AFTER source_instance_id; + +-- status 语义扩展(无需改列类型):running/returned/completed/rejected/cancelled +-- returned = 已打回,待发起人修改再提交 + +CREATE TABLE IF NOT EXISTS oa_instance_event ( + id VARCHAR(64) NOT NULL COMMENT '主键ID', + instance_id VARCHAR(64) NOT NULL COMMENT '实例ID', + round_no INT NOT NULL DEFAULT 1 COMMENT '轮次', + event_type VARCHAR(32) NOT NULL COMMENT 'submit/return/withdraw/complete/reject_end', + actor_user_id VARCHAR(64) NULL COMMENT '操作人', + task_id VARCHAR(64) NULL COMMENT '关联任务(打回时)', + node_key VARCHAR(64) NULL COMMENT '节点key', + comment_text VARCHAR(512) NULL COMMENT '意见', + tenant_no VARCHAR(64) NOT NULL DEFAULT 'zkxw' COMMENT '租户编号', + order_num INT NULL COMMENT '排序号', + remark VARCHAR(512) NULL COMMENT '备注', + create_by VARCHAR(64) NULL COMMENT '创建人id', + create_dept_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID', + create_time DATETIME NOT NULL COMMENT '创建时间', + update_by VARCHAR(64) NULL COMMENT '更新人id', + update_time DATETIME NOT NULL COMMENT '更新时间', + del_flag SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记', + PRIMARY KEY (id), + KEY idx_oa_evt_inst (instance_id, del_flag, create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA实例操作事件(进度轴)'; +``` + +在目标库执行该 patch(本地/测试库按环境)。 + +- [ ] **Step 2: 更新 SQLAlchemy 模型** + +`OaInstance` 增加: + +```python +source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例") +current_round = Column(Integer, nullable=False, default=1, comment="当前轮次") +``` + +新建: + +```python +class OaInstanceEvent(Base): + __tablename__ = "oa_instance_event" + id = Column(String(64), primary_key=True) + instance_id = Column(String(64), nullable=False) + round_no = Column(Integer, nullable=False, default=1) + event_type = Column(String(32), nullable=False) + actor_user_id = Column(String(64), nullable=True) + task_id = Column(String(64), nullable=True) + node_key = Column(String(64), nullable=True) + comment_text = Column(String(512), nullable=True) + tenant_no = Column(String(64), nullable=False, default="zkxw") + order_num = Column(Integer, nullable=True) + remark = Column(String(512), nullable=True) + create_by = Column(String(64), nullable=True) + create_dept_id = Column(String(64), nullable=False, default="") + create_time = Column(DateTime, nullable=False, default=datetime.now) + update_by = Column(String(64), nullable=True) + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) + del_flag = Column(SmallInteger, nullable=False, default=0) +``` + +- [ ] **Step 3: 高级设置默认值** + +```python +DEFAULT_ADVANCED_SETTINGS: Dict[str, Any] = { + "supervisorFallbackToDeptLeader": True, + "allowWithdrawRunning": True, + "approverDedup": False, + "commentRequired": False, + "allowAddSign": True, + "allowReturn": True, # 打回 +} +``` + +设计器 `designer/index.vue` 增加与「允许加签」同级的 `el-switch`:`allowReturn`,文案「允许审批人打回(退回发起人修改)」。 + +- [ ] **Step 4: Commit** + +```bash +git add docs/sql/patch_2026-07-15_oa_progress_return.sql backend/app/models/models.py backend/app/crud/oa_approval.py web-admin/src/views/oa-approval/designer/index.vue +git commit -m "$(cat <<'EOF' +feat(oa): add instance events schema and allowReturn setting + +EOF +)" +``` + +--- + +### Task 4: P1 — `return_task`(打回) + +**Files:** +- Modify: `backend/app/services/oa_engine.py` +- Modify: `backend/app/api/oa_approval.py` +- Modify: `backend/tests/test_oa_engine.py` + +- [ ] **Step 1: 写 failing 单测(sqlite 内存或 MagicMock 链路)** + +优先沿用 `TestCompleteTaskOrSign` 的 sqlite 内存风格:准备实例 `running` + pending task → `return_task` → 断言: + +- `inst.status == "returned"` +- 所有 `pending` 任务变为 `cancelled`(新状态;展示「已取消」) +- 存在 `event_type == "return"` 的 `OaInstanceEvent` +- 打回任务自身 `status == "returned"`(或 `comment` + 专用状态;**统一用任务 status=`returned`**) + +```python +def test_return_sets_instance_returned_and_cancels_pending(self): + from app.services.oa_engine import return_task + # …插入 ver/inst/task 后 + return_task(self.db, "t1", tenant_no="zkxw", user_id="u1", comment="请补充材料") + inst = self.db.query(self.OaInstance).filter_by(id="i1").first() + self.assertEqual(inst.status, "returned") +``` + +- [ ] **Step 2: 实现 `return_task`** + +语义: + +1. 校验任务 assignee、`task_type=approval`、`status=pending`、实例 `running`。 +2. `advanced.allowReturn` 必须为 true。 +3. 评论:若 `commentRequired` 或打回场景 **SHALL 要求非空意见**(打回强制意见,即使全局意见非必填)。 +4. 当前任务 `status=returned`,写入 `comment_text`。 +5. 同实例其余 `pending` → `cancelled`。 +6. 实例 `status=returned`。 +7. 写入 `oa_instance_event`:`event_type=return`,`round_no=inst.current_round`。 +8. `commit` + `get_instance_detail`。 + +**注意:** `complete_task(..., action="reject")` 保持整单 `rejected`,**不得**改成打回。 + +- [ ] **Step 3: API** + +```python +@router.post("/tasks/{task_id}/return") +async def return_oa_task(task_id: str, body: CommentBody, ...): + data = return_task(db, task_id, tenant_no=..., user_id=..., comment=body.comment) + return success_response(data, message="已打回") +``` + +- [ ] **Step 4: 跑测 + Commit** + +```bash +cd backend && ./venv/bin/python -m unittest tests.test_oa_engine -v +git add backend/app/services/oa_engine.py backend/app/api/oa_approval.py backend/tests/test_oa_engine.py +git commit -m "$(cat <<'EOF' +feat(oa): add return-to-initiator action for approval tasks + +EOF +)" +``` + +--- + +### Task 5: P1 — `resubmit_instance`(同实例再提交) + +**Files:** +- Modify: `backend/app/services/oa_engine.py` +- Modify: `backend/app/api/oa_approval.py` +- Modify: `backend/tests/test_oa_engine.py` + +- [ ] **Step 1: Failing 单测** + +- 仅 `status=returned` 且操作者为发起人可再提交。 +- 更新 `form_data_json` 后 `current_round += 1`,状态改回 `running`。 +- 写 `event_type=submit`(新轮)。 +- 调用现有 `advance_from(..., from_node=None)` 从 start 后第一跳重建任务(中间节点重审)。 +- **保留**旧轮 `oa_task` / `return` 事件,禁止物理删除历史任务。 + +- [ ] **Step 2: 实现** + +```python +def resubmit_instance( + db: Session, + instance_id: str, + *, + tenant_no: str, + user_id: str, + form_data: Optional[Dict[str, Any]] = None, + initiator_select_user_ids: Optional[List[str]] = None, +) -> Dict[str, Any]: + inst = _get_instance(db, instance_id, tenant_no) + if inst.initiator_user_id != user_id: + raise OaEngineError("仅发起人可再次提交") + if inst.status != "returned": + raise OaEngineError("仅打回待修改的审批可再次提交") + ver = ... + # 公式/校验与 start_instance 同源片段可抽私有函数 + now = datetime.now() + if form_data is not None: + inst.form_data_json = _dumps(form_data) + inst.current_round = int(inst.current_round or 1) + 1 + inst.status = "running" + inst.update_by = user_id + inst.update_time = now + db.add(inst) + _add_event(db, inst, event_type="submit", round_no=inst.current_round, actor=user_id) + advance_from( + db, + inst=inst, + ver=ver, + from_node=None, + user_id=user_id, + initiator_select_user_ids=initiator_select_user_ids, + ) + db.commit() + return get_instance_detail(db, inst.id, tenant_no=tenant_no) +``` + +创建任务时把 `remark` 或后续字段带上 `round_no`:**最低成本**在 `_create_tasks_for_node` 写入 `remark=json.dumps({"roundNo": inst.current_round})`,或给 `oa_task` 增加 `round_no` 列。 + +**本计划采纳:** patch 中同时 + +```sql +ALTER TABLE oa_task ADD COLUMN round_no INT NOT NULL DEFAULT 1 COMMENT '所属提交轮次' AFTER sign_mode; +``` + +新建任务:`round_no=inst.current_round`。`build_progress_items` 按 `round_no` 分组排序。 + +- [ ] **Step 3: API `POST /instances/{id}/resubmit`** + +Body 与 start 类似:`formData`、`initiatorSelectUserIds`。 + +- [ ] **Step 4: 升级 `build_progress_items`** + +- 查询 events,按 `(round_no, create_time)` 展开。 +- 每一轮:`submit` 事件 → 该轮 `tasks`(`round_no` 匹配)按树序 → 若为本轮且 running 则 pending_node。 +- `return` 事件插在对应轮任务之后。 +- 终态仅一轮结束时追加。 + +- [ ] **Step 5: 测通 + Commit** + +```bash +cd backend && ./venv/bin/python -m unittest tests.test_oa_engine -v +git add docs/sql/patch_2026-07-15_oa_progress_return.sql backend/app/models/models.py backend/app/services/oa_engine.py backend/app/api/oa_approval.py backend/tests/test_oa_engine.py +git commit -m "$(cat <<'EOF' +feat(oa): support resubmit after return with multi-round progress + +EOF +)" +``` + +--- + +### Task 6: P1 — 撤销后重提关联新实例 + +**Files:** +- Modify: `backend/app/services/oa_engine.py`(`start_instance`、`withdraw_instance`) +- Modify: `backend/app/api/oa_approval.py` +- Modify: `web-admin/src/api/oaApproval.ts` +- Modify: `web-admin/src/views/oa-approval/start/index.vue` +- Modify: `web-admin/src/views/oa-approval/mine/index.vue`(或列表组件) + +- [ ] **Step 1: `start_instance` 增加可选 `source_instance_id`** + +```python +# 若传入 source_instance_id: +src = _get_instance(db, source_instance_id, tenant_no) +if src.initiator_user_id != initiator_user_id: + raise OaEngineError("只能从本人撤销的审批重提") +if src.status != "cancelled": + raise OaEngineError("仅已撤销的审批可关联重提") +inst.source_instance_id = src.id +# 可选:复制标题前缀「重提-」 +``` + +`withdraw_instance` 写 `event_type=withdraw`。 + +首次 `start_instance` 也写 `event_type=submit`(round 1),便于 progress 统一走 events。 + +- [ ] **Step 2: 详情返回** + +```python +data["sourceInstanceId"] = inst.source_instance_id or "" +# 若有人从本单重提,可反查: +data["resubmittedInstanceId"] = ... # query source_instance_id == inst.id limit 1 +``` + +- [ ] **Step 3: 前端「我发起的」** + +对 `cancelled` 行提供「修改并重新提交」→ 跳转发起页: + +`/#/system-manage/oa-approval/start?appId=...&sourceInstanceId=...` + +发起页 `onMounted`:若有 `sourceInstanceId`,拉旧详情预填 `formData`,提交时带上该字段。 + +详情页顶部:若有 `sourceInstanceId`,展示链接「由已撤销单重提,查看原单」。 + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/services/oa_engine.py backend/app/api/oa_approval.py web-admin/src/api/oaApproval.ts web-admin/src/views/oa-approval/start/index.vue web-admin/src/views/oa-approval/instances/detail.vue web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue +git commit -m "$(cat <<'EOF' +feat(oa): link revoke-and-resubmit as a new instance + +EOF +)" +``` + +--- + +### Task 7: P1 — 详情/待办 UI(打回 + 再提交 + 多轮) + +**Files:** +- Modify: `web-admin/src/views/oa-approval/instances/detail.vue` +- Modify: `web-admin/src/api/oaApproval.ts` +- Modify: `web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue`(状态文案) + +- [ ] **Step 1: API 封装** + +```typescript +export function fetchReturnOaTask(taskId: string, comment: string) { + return request.post({ url: `${BASE}/tasks/${taskId}/return`, data: { comment } }) +} +export function fetchResubmitOaInstance( + id: string, + data: { formData?: Record; initiatorSelectUserIds?: string[] } +) { + return request.post({ url: `${BASE}/instances/${id}/resubmit`, data }) +} +``` + +- [ ] **Step 2: 详情操作区** + +- 审批人待办:在「拒绝」旁增加 **「打回」**(`allowReturn !== false`),`ElMessageBox.prompt` **必填意见**,调 `fetchReturnOaTask`。 +- 发起人且 `status === 'returned'`:主按钮 **「修改并再次提交」** → 打开与发起类似的可编辑表单(可复用 start 页组件或详情内嵌 form-create 可编辑),提交调 `resubmit`。 +- 进度轴:按 `roundNo` 在轮与轮之间插入分隔标题「第 N 轮」(`v-if` 轮号变化)。 +- 状态 tag:`returned` → `warning`「待修改」。 + +- [ ] **Step 3: 手工走查脚本** + +1. 发起(抄送→两级审批)→ 第一级同意 → 第二级打回。 +2. 进度可见第 1 轮 + 打回意见;实例「待修改」。 +3. 发起人改表单再提交 → 第 2 轮;原同意节点重新出现待办。 +4. 拒绝一条单 → 结束且不能再提交。 +5. 撤销 → 「我发起的」重提 → **新 instanceId**,详情可回跳旧单。 + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/api/oaApproval.ts web-admin/src/views/oa-approval/instances/detail.vue web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue +git commit -m "$(cat <<'EOF' +feat(oa): UI for return, resubmit, and multi-round progress + +EOF +)" +``` + +--- + +### Task 8: 收尾 + +- [ ] **Step 1: 对照设计验收勾选** + +规格 `2026-07-15-oa-approval-progress-timeline-design.md` §6 P0/P1 清单全部勾选或注明缺口。 + +- [ ] **Step 2: 更新知识图谱** + +```bash +./scripts/update-graph.sh backend +# 若前端路由/API 有新增: +./scripts/update-graph.sh frontend +``` + +- [ ] **Step 3: 规格状态** + +将设计文档表头「状态」改为「已定稿;实现按 plans/2026-07-15-oa-approval-progress-timeline.md」。 + +--- + +## Spec coverage 自检 + +| 规格要求 | Task | +|----------|------| +| 轮内按设计节点序 | 1(继承排序)+ 5 | +| 发起人 + 终态 + 未到达 | 1–2 | +| 拒绝 ≠ 打回 | 4(独立 API)+ 7(文案) | +| 撤销 → 新实例再提 | 6 | +| 打回 → 同实例多轮 | 4–5、7 | +| 打回后中间节点重审 | 5(`advance_from` from start) | +| allowReturn 高级设置 | 3 | +| operation_records 等价 | 3(`oa_instance_event`) | + +--- + +## 风险与注意 + +- MySQL 5.7:`ALTER` 加列注意锁表窗口;测试库先跑 patch。 +- 旧实例无 `oa_instance_event`:`build_progress_items` 必须能在 `events=[]` 时退化到 P0(用 `create_time` 伪造首条 submit)。 +- 任务 status 新增 `returned` / `cancelled`:列表「已处理」查询勿把 `cancelled` 当同意。 +- Autoflush=False 会话:取消 pending 时写库顺序参考现有或签测试。 diff --git a/docs/superpowers/specs/2026-07-15-oa-approval-progress-timeline-design.md b/docs/superpowers/specs/2026-07-15-oa-approval-progress-timeline-design.md new file mode 100644 index 00000000..e1315757 --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-oa-approval-progress-timeline-design.md @@ -0,0 +1,206 @@ +# OA 审批进度展示与实例流转设计(钉钉对齐) + +| 项 | 内容 | +|----|------| +| 状态 | 已定稿;实现已落地(见 `docs/superpowers/plans/2026-07-15-oa-approval-progress-timeline.md`) | +| 日期 | 2026-07-15 | +| 范围 | web-admin 审批详情「审批进度」;拒绝 / 撤销 / 打回 的实例语义 | +| 权威对齐 | 钉钉 OA 审批详情(`tasks` + `operation_records` 思路) | +| 关联 | `2026-07-14-oa-approval-dingtalk-design.md`(本文件**修订**其中「驳回 = 整单结束、V1 不做回退」——拆成「拒绝」与「打回」) | + +--- + +## 1. 背景与问题 + +产品与研发对齐过程中发现两处易混: + +1. **进度排序**:流程设计为「发起人 → 抄送 → 审批 → 结束」,详情曾因同秒创建任务、仅按 `create_time` 排序导致抄送/审批颠倒。 +2. **打回 vs 拒绝 vs 撤销后再提**:口语「打回」常被当成「拒绝结束」;实际上需要三套语义,且「同实例第二次提交」主要出现在**打回**路径。 + +本文件固定:**实例怎么活** + **进度怎么画**,并与钉钉主流体感对齐。 + +--- + +## 2. 目标 + +- 审批进度可读、可审计,与流程设计顺序在「同一轮」内一致。 +- 明确拒绝 / 撤销 / 打回三种动作对实例生命周期的影响。 +- 分阶段落地:先稳单轮 + 拒绝/撤销收尾;再做打回同实例多轮与撤销关联新单。 + +非目标(本文件不展开实现细节到接口字段级,留给实现计划): + +- 转交、评论独立墙、代理人等钉钉全量操作类型的 UI 一一复刻。 +- 小程序端进度 UI(语义同本章,交互另规)。 + +--- + +## 3. 三种动作(SHALL) + +| 动作 | 操作者 | 本单命运 | 再次进入审批 | 钉钉类比 | +|------|--------|----------|--------------|----------| +| **拒绝** | 审批人 | **结束**(结果:已拒绝) | 只能**新开实例** | 任务 `REFUSE` + 实例完结 | +| **撤销** | 发起人(受高级设置约束) | **终止**(已撤销) | **新实例**再提(可关联旧单) | `TERMINATE`;重提常带 `biz_action=REVOKE` / 附属实例 | +| **打回**(退回) | 审批人 | **不结束** | **同一实例**改表单后再次提交 | `REDIRECT_PROCESS` | + +### 3.1 禁止混淆的表述 + +- **不得**将「打回」实现为「拒绝结束」。 +- **不得**将「拒绝」实现为「退回发起人改单」。 +- 「撤销后再提交」**不是**同实例复活(见 §3.2);「打回后再提交」**才是**同实例第二轮(见 §3.3)。 + +### 3.2 撤销 → 修改 → 再提交(钉钉思路) + +``` +实例 #1:… → 发起人撤销 → 状态终态「已撤销」 +实例 #2:重新发起(新 instanceId)→ 可选关联 #1 +``` + +- 列表:旧单「已撤销」,新单「审批中 / 已完成 …」。 +- 详情:各自独立进度轴;新单详情可展示「由某某已撤销单重提」并跳转旧单。 +- **SHALL NOT** 在已撤销实例上直接改状态为 running 并清空历史后重跑(避免审计与待办混乱)。 + +### 3.3 打回 → 修改 → 再提交(同实例多轮) + +``` +同一实例: + 第 1 轮提交 → … → 审批人打回(附意见) + → 发起人改表单 → 第 2 轮提交 → 按退回策略重走节点 … +``` + +- 进度轴**保留第 1 轮记录**,再追加第 2 轮,不得覆盖抹掉。 +- **退回后中间已通过节点是否重审**:默认与钉钉宜搭高级流程一致——**需要重新审批**(从退回目标之后的路径重走;V1 打回目标默认为「发起人 / 开始节点」)。若日后支持「退回到指定节点」,另开变更,默认仍「退回后至结束前节点重审」。 + +### 3.4 拒绝(打回≠拒绝) + +- 当前审批人拒绝 → 实例进入终态「已拒绝」。 +- 进度:拒绝节点结果 + 「流程结束(已拒绝)」;**不得**再展示后续未到达节点为待处理。 +- 业务上若需继续办理:发起人**新开一张**审批(与旧单无强制引擎关联;产品可选「复制表单发起」属后续增强)。 + +--- + +## 4. 审批进度展示模型(SHALL) + +对齐钉钉:**对外一条时间轴,对内区分「流程快照感」与「操作/轮次事件」**。 + +### 4.1 推荐模型:混合 + +| 层级 | 规则 | +|------|------| +| **轮次(大序)** | 同一实例内按「提交轮 / 打回事件」分段或打标(第 1 轮、打回、第 2 轮…) | +| **轮内(小序)** | **严格按该版本流程树设计节点顺序**(抄送在审批前则展示在审批前) | +| **同节点多人** | 会签 / 或签 / 加签:**一人一条**结果,不合并成一条含糊节点 | +| **未到达** | 仅当前轮且实例 `running` 时可灰显「未到达」;终态(通过 / 拒绝 / 撤销)后**不再**挂未达节点 | +| **抄送** | 不阻断;创建即记入进度(知会),排序仍服从设计序 | +| **结束** | 通过 →「流程结束」;拒绝 →「流程结束(已拒绝)」;撤销 →「已撤销」(可不展示虚假「流程正常结束」) | + +### 4.2 单轮 happy path 示意 + +``` +发起人 · 张三 · 已提交 · t0 +抄送人 · … · 已抄送 · t1 ← 设计在审批前则在上 +审批人 · … · 待处理 / 已同意 · t2 +(可选)后续节点 · 未到达 +(全部通过后)流程结束 +``` + +### 4.3 打回多轮示意 + +``` +—— 第 1 轮 —— +发起人 · 已提交 +审批人甲 · 已同意 +审批人乙 · 打回(意见:请补充附件) +—— 第 2 轮 —— +发起人 · 再次提交 +审批人甲 · 待处理 / … +… +``` + +### 4.4 撤销后再提(跨实例)示意 + +``` +【实例 #1 详情】 +… → 已撤销 + +【实例 #2 详情】 +提示:本单由「实例 #1」撤销后重提 +发起人 · 已提交 +… +``` + +### 4.5 排序与数据权威 + +- **展示排序权威**:流程树节点序(`node_key` 在 flow tree 先序/路径序中的位置)→ 同节点内 `order_num` → `create_time` → `id`。 +- **禁止**仅依赖同秒精度的 `create_time` 作为唯一排序键(会导致抄送/审批颠倒)。 +- 新建任务时 **SHALL** 写入递增 `order_num`,便于同节点加签等场景稳定排序。 +- 长期演进:可引入独立 `operation_records`(或等价事件表),与钉钉 `operation_records` 对齐;在引入前,允许由「实例事件 + oa_task」投影生成时间轴,但语义须符合本章。 + +### 4.6 与「发起页流程预览」的关系 + +- 发起页底部预览(见 `2026-07-15-oa-start-flow-preview-design.md`):提交前的**预期链**。 +- 详情审批进度:提交后的**实际发生 + 当前轮预期未达**。 +- 两者节点名与顺序在同一发布版本下应一致;条件分支以实际表单路径为准。 + +--- + +## 5. 分阶段落地 + +### 5.1 阶段 P0(当前可验收,不依赖打回引擎) + +1. 详情进度按**流程树节点序**排列任务(兼容历史 `order_num=0` 同秒数据)。 +2. 时间轴含:**发起人(已提交)**;任务节点;终态时 **流程结束 / 已拒绝 / 已撤销**。 +3. 拒绝、撤销行为保持:本单结束,无同实例第二轮。 +4. 撤销后再提:用户从发起入口**新开实例**(关联旧单可为 P1)。 + +### 5.2 阶段 P1(钉钉完整语义) + +1. 审批人操作拆分:**同意 / 拒绝 / 打回**(打回需意见;高级设置可开关)。 +2. 打回后:取消当前及后续未完成待办;实例进入「待发起人修改」类状态(命名实现时确定);发起人可改表单并**再次提交**;引擎按 §3.3 重走。 +3. 进度轴支持**多轮**展示(§4.3)。 +4. 撤销后重提:创建新实例时写入关联(如 `oa_relation` 或实例字段 `prev_instance_id` / `biz_action=revoke_resubmit`),双方详情可跳转。 + +### 5.3 与旧规格的关系 + +`2026-07-14-oa-approval-dingtalk-design.md` §5.2 原文: + +> 结束:实例 `completed` / `rejected`(驳回策略:整单结束,V1 不做复杂回退多版本) + +**修订为**: + +- 「驳回」一词废弃;改为「**拒绝** = 整单结束」。 +- 「**打回** = 同实例退回重提」列入 P1,不再用「V1 永不做回退」表述封死产品方向。 +- P0 仍可不实现打回引擎,但产品文案与按钮不得把拒绝叫做打回。 + +--- + +## 6. 验收要点 + +### P0 + +- [x] 设计为「抄送 → 审批」时,详情进度在同秒创建下仍为抄送在上、审批在下。 +- [x] 进度首条为发起人已提交;拒绝/撤销/通过后有正确终态文案。 +- [x] 拒绝后列表与详情均为已结束,无法在同单再提交。 + +### P1 + +- [x] 打回后同实例可再次提交,轴上可见两轮提交与中间打回意见。(引擎+UI 已实现;需业务侧走查) +- [x] 打回后中间已同意节点默认需重审。 +- [x] 撤销后重提为新 `instanceId`,可从新单跳到旧单。 +- [x] 拒绝与打回按钮/结果文案互不混用。 + +--- + +## 7. 决议记录 + +| 日期 | 决议 | +|------|------| +| 2026-07-15 | 按钉钉思路:拒绝结束本单;撤销后再提 = 新实例;打回 = 同实例再提交;进度 = 轮次时间轴 + 轮内设计序。 | +| 2026-07-15 | 未到达节点:进行中可展示;终态不展示(在混合模型中确认)。 | + +--- + +## 8. 开放问题(已在实现计划收口) + +1. 打回后实例状态 = **`returned`**(待发起人修改再提交)。 +2. P1 打回目标 **仅退回发起人(start)**。 +3. 使用独立表 **`oa_instance_event`**(对齐钉钉 operation_records)。 diff --git a/web-admin/src/api/oaApproval.ts b/web-admin/src/api/oaApproval.ts index 0d937641..3859efaf 100644 --- a/web-admin/src/api/oaApproval.ts +++ b/web-admin/src/api/oaApproval.ts @@ -210,6 +210,7 @@ export function fetchStartOaInstance(data: { initiatorDeptId?: string initiatorSelectUserIds?: string[] relatedInstanceIds?: string[] + sourceInstanceId?: string }) { return request.post({ url: `${BASE}/instances`, @@ -260,6 +261,25 @@ export function fetchRejectOaTask(taskId: string, comment?: string) { }) } +export function fetchReturnOaTask(taskId: string, comment: string) { + return request.post({ + url: `${BASE}/tasks/${taskId}/return`, + data: { comment }, + showSuccessMessage: true + }) +} + +export function fetchResubmitOaInstance( + id: string, + data: { formData?: Record; initiatorSelectUserIds?: string[] } +) { + return request.post({ + url: `${BASE}/instances/${id}/resubmit`, + data, + showSuccessMessage: true + }) +} + export function fetchAddSignOaTask( taskId: string, data: { userIds: string[]; comment?: string } diff --git a/web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue b/web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue new file mode 100644 index 00000000..f46de6f3 --- /dev/null +++ b/web-admin/src/views/oa-approval/_placeholder/RuntimeList.vue @@ -0,0 +1,129 @@ + + + + diff --git a/web-admin/src/views/oa-approval/designer/index.vue b/web-admin/src/views/oa-approval/designer/index.vue new file mode 100644 index 00000000..c854f792 --- /dev/null +++ b/web-admin/src/views/oa-approval/designer/index.vue @@ -0,0 +1,430 @@ + + + + + + + diff --git a/web-admin/src/views/oa-approval/instances/detail.vue b/web-admin/src/views/oa-approval/instances/detail.vue index cc7256d0..d0995bd6 100644 --- a/web-admin/src/views/oa-approval/instances/detail.vue +++ b/web-admin/src/views/oa-approval/instances/detail.vue @@ -28,6 +28,15 @@ 拒绝 + + + 打回 + 加签 + + + 修改并再次提交 +
+ +

表单内容

@@ -67,13 +96,18 @@

审批进度

- +

暂无任务记录

@@ -161,6 +197,8 @@ fetchApproveOaTask, fetchOaInstanceDetail, fetchRejectOaTask, + fetchResubmitOaInstance, + fetchReturnOaTask, fetchWithdrawOaInstance } from '@/api/oaApproval' import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list' @@ -227,7 +265,7 @@ const canApprove = computed(() => !!myPendingTask.value) function statusType(s: string) { - if (s === 'pending' || s === 'running') return 'warning' + if (s === 'pending' || s === 'running' || s === 'returned') return 'warning' if (s === 'approved' || s === 'completed' || s === 'done' || s === 'submitted') return 'success' if (s === 'rejected' || s === 'cancelled') return 'danger' if (s === 'not_reached') return 'info' @@ -244,15 +282,18 @@ cancelled: '已撤销', done: '已结束', submitted: '已提交', - not_reached: '未到达' + not_reached: '未到达', + returned: '待修改' } + // 任务被打回后取消:展示「已取消」 + if (s === 'cancelled') return '已撤销' return map[s] || s } function timelineType(s: string) { if (s === 'approved' || s === 'completed' || s === 'done' || s === 'submitted') return 'success' if (s === 'rejected' || s === 'cancelled') return 'danger' - if (s === 'pending') return 'warning' + if (s === 'pending' || s === 'returned') return 'warning' if (s === 'not_reached') return 'info' return 'primary' } @@ -330,6 +371,52 @@ } } + async function doReturn() { + const task = myPendingTask.value + if (!task) return + let comment = '' + try { + const res = await ElMessageBox.prompt('请填写打回意见(必填)', '打回', { + inputPlaceholder: '请发起人修改后再次提交', + confirmButtonText: '确定打回', + cancelButtonText: '取消', + inputValidator: (v) => !!(v && String(v).trim()) || '打回意见必填' + }) + comment = String(res.value || '').trim() + } catch { + return + } + acting.value = true + try { + await fetchReturnOaTask(task.id, comment) + await load() + } finally { + acting.value = false + } + } + + async function openResubmit() { + await ElMessageBox.confirm( + '将使用当前表单内容再次提交并重新走审批。若需改字段,请先在下方确认表单后确定。', + '再次提交', + { type: 'warning' } + ) + acting.value = true + try { + await fetchResubmitOaInstance(String(route.params.id || ''), { + formData: { ...(viewFormData.value || detail.value?.formDataJson || {}) } + }) + await load() + } finally { + acting.value = false + } + } + + function goInstance(id: string) { + if (!id) return + router.push({ path: `/system-manage/oa-approval/instances/${id}` }) + } + async function loadUsers() { userLoading.value = true try { @@ -447,6 +534,20 @@ font-size: 13px; } + .oa-detail__link-tip { + max-width: 960px; + margin: 0 auto 8px; + color: var(--mint-text-muted, #64748b); + font-size: 13px; + } + + .oa-tl__round { + margin: 8px 0 4px 4px; + color: var(--mint-text-muted, #94a3b8); + font-size: 12px; + font-weight: 600; + } + .oa-tl__title { display: flex; flex-wrap: wrap; diff --git a/web-admin/src/views/oa-approval/start/index.vue b/web-admin/src/views/oa-approval/start/index.vue new file mode 100644 index 00000000..c7e1602f --- /dev/null +++ b/web-admin/src/views/oa-approval/start/index.vue @@ -0,0 +1,646 @@ + + + + + +