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 <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-15 14:53:52 +08:00
1 parent 265d790ea6
commit 26800865cd
13 files changed
+4736 -72

No files matched your search

+745
View File
@@ -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))
+915
View File
@@ -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)
+217
View File
@@ -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)
# =============================================================================
+366 -60
View File
@@ -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)
+157 -1
View File
@@ -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()
@@ -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实例操作事件(进度轴)';
@@ -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<string, any>; 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 时写库顺序参考现有或签测试。
@@ -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)。
+20
View File
@@ -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<string, any>; initiatorSelectUserIds?: string[] }
) {
return request.post({
url: `${BASE}/instances/${id}/resubmit`,
data,
showSuccessMessage: true
})
}
export function fetchAddSignOaTask(
taskId: string,
data: { userIds: string[]; comment?: string }
@@ -0,0 +1,129 @@
<!-- OA 办理中心列表(待处理 / 已处理 / 我发起 / 抄送) -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top">
<h2 class="title-bar-heading">{{ title }}</h2>
</div>
<div class="list-body list-wrap">
<el-table class="list-table" :data="rows" v-loading="loading" stripe>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column
v-if="kind === 'mine'"
prop="title"
label="标题"
min-width="200"
align="left"
header-align="left"
/>
<el-table-column
v-if="kind !== 'mine'"
prop="nodeName"
label="节点"
min-width="140"
align="center"
header-align="center"
/>
<el-table-column prop="status" label="状态" min-width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="statusType(row.status)">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="时间" min-width="170" align="center" />
<el-table-column label="操作" min-width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button class="btn-op-item primary" @click="openDetail(row)">
<el-icon><View /></el-icon>
查看
</el-button>
<el-button
v-if="kind === 'mine' && row.status === 'cancelled' && row.appId"
class="btn-op-item"
@click="openResubmitFromCancelled(row)"
>
<el-icon><RefreshRight /></el-icon>
修改并重新提交
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onActivated, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { RefreshRight, View } from '@element-plus/icons-vue'
import { fetchOaTasks } from '@/api/oaApproval'
const props = defineProps<{
title: string
kind: 'pending' | 'done' | 'mine' | 'cc'
}>()
const router = useRouter()
const loading = ref(false)
const rows = ref<any[]>([])
function statusType(s: string) {
if (s === 'pending' || s === 'running' || s === 'returned') return 'warning'
if (s === 'approved' || s === 'completed' || s === 'done') return 'success'
if (s === 'rejected' || s === 'cancelled') return 'danger'
return 'info'
}
function statusText(s: string) {
if (!s) return '-'
const map: Record<string, string> = {
pending: '待处理',
running: '进行中',
approved: '已同意',
rejected: '已拒绝',
completed: '已完成',
cancelled: '已撤销',
done: '已结束',
returned: '待修改'
}
return map[s] || s
}
function openDetail(row: any) {
const id = props.kind === 'mine' ? row.id : row.instanceId
if (!id) return
router.push({ path: `/system-manage/oa-approval/instances/${id}` })
}
function openResubmitFromCancelled(row: any) {
if (!row?.appId || !row?.id) return
router.push({
path: '/system-manage/oa-approval/start',
query: { appId: row.appId, sourceInstanceId: row.id }
})
}
async function load() {
loading.value = true
try {
const res = await fetchOaTasks(props.kind)
rows.value = res?.list || []
} catch {
rows.value = []
} finally {
loading.value = false
}
}
onMounted(load)
// 菜单 keepAlive:切回本页时重新拉取,避免审批后列表仍是旧数据
onActivated(load)
</script>
@@ -0,0 +1,430 @@
<!-- OA 四步设计器壳(D10 完善基础/高级;E11/E12 填表单与流程) -->
<template>
<div class="page-container oa-designer">
<section class="panel oa-designer__panel">
<div class="oa-designer__toolbar">
<el-button class="btn-ghost" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<div class="oa-designer__actions">
<el-button class="btn-ghost" :loading="saving" @click="saveDraft">
<el-icon><Document /></el-icon>
保存草稿
</el-button>
<el-button class="btn-main" type="primary" :loading="publishing" @click="publish">
<el-icon><Upload /></el-icon>
发布
</el-button>
</div>
</div>
<el-tabs v-model="activeStep" class="oa-designer__tabs" @tab-change="onStepChange">
<el-tab-pane label="1 基础设置" name="basic" />
<el-tab-pane label="2 表单设计" name="form" />
<el-tab-pane label="3 流程设计" name="flow" />
<el-tab-pane label="4 高级设置" name="advanced" />
</el-tabs>
<div v-loading="loading" class="oa-designer__body">
<div v-show="activeStep === 'basic'" class="oa-designer__step">
<el-form label-position="top" class="oa-designer__form">
<div class="form-grid-2">
<el-form-item label="应用图标" class="form-col-span-2">
<div class="oa-designer__icon-row">
<span class="oa-designer__icon-preview">
<img :src="resolveOaAppIconUrl(basic.iconUrl)" alt="" />
</span>
<el-upload
:show-file-list="false"
accept="image/*"
:http-request="uploadBasicIcon"
:disabled="iconUploading"
>
<el-button class="btn-ghost" :loading="iconUploading">
<el-icon><Upload /></el-icon>
上传
</el-button>
</el-upload>
<el-button class="btn-ghost" @click="basic.iconUrl = pickRandomOaAppIconUrl()">
换默认
</el-button>
</div>
</el-form-item>
<el-form-item label="应用名称">
<el-input v-model="basic.appName" maxlength="50" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="basic.status">
<el-option label="启用" value="1" />
<el-option label="停用" value="0" />
</el-select>
</el-form-item>
<el-form-item label="表单说明" class="form-col-span-2">
<el-input v-model="basic.description" type="textarea" :rows="3" maxlength="100" />
</el-form-item>
</div>
</el-form>
</div>
<div v-show="activeStep === 'form'" class="oa-designer__form-step">
<OaFormDesignerEmbed
v-if="formDesignerMounted"
ref="formEmbedRef"
v-model="formSchema"
/>
</div>
<div v-show="activeStep === 'flow'" class="oa-designer__step oa-designer__flow-step">
<FlowDesigner v-model="flowTreeJson" />
</div>
<div v-show="activeStep === 'advanced'" class="oa-designer__step">
<el-form label-position="top" class="oa-designer__form oa-designer__form--narrow">
<el-form-item label="主管为空时回退部门负责人">
<el-switch v-model="advanced.supervisorFallbackToDeptLeader" />
</el-form-item>
<el-form-item label="允许撤销进行中的审批">
<el-switch v-model="advanced.allowWithdrawRunning" />
</el-form-item>
<el-form-item label="审批意见必填">
<el-switch v-model="advanced.commentRequired" />
</el-form-item>
<el-form-item label="允许加签">
<el-switch v-model="advanced.allowAddSign" />
</el-form-item>
<el-form-item label="允许审批人打回(退回发起人修改)">
<el-switch v-model="advanced.allowReturn" />
</el-form-item>
<el-form-item label="审批人去重">
<el-switch v-model="advanced.approverDedup" />
</el-form-item>
</el-form>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { UploadRequestOptions } from 'element-plus'
import { ArrowLeft, Document, Upload } from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/business'
import { fetchOaAppDraft, fetchPublishOaApp, fetchSaveOaAppDraft } from '@/api/oaApproval'
import FlowDesigner from '@/components/oa-approval/flow-tree/FlowDesigner.vue'
import { createDefaultFlow, ensureFlowRoot } from '@/components/oa-approval/flow-tree/types'
import OaFormDesignerEmbed from '@/components/oa-approval/OaFormDesignerEmbed.vue'
import { pickRandomOaAppIconUrl, resolveOaAppIconUrl } from '@/utils/oa-app-icons'
defineOptions({ name: 'OaApprovalDesigner' })
const route = useRoute()
const router = useRouter()
const appId = String(route.params.id || '')
const loading = ref(false)
const saving = ref(false)
const publishing = ref(false)
const activeStep = ref('basic')
const draft = ref<any>(null)
/** iframe 表单设计器(与主应用 Tailwind 隔离) */
const formEmbedRef = ref<{ fetchSchema: () => Promise<{ rule: unknown; option: unknown }> } | null>(
null
)
const formDesignerMounted = ref(false)
const formSchema = ref<{ rule: unknown; option: unknown }>({ rule: [], option: {} })
const basic = reactive({
appName: '',
description: '',
status: '1',
groupId: '',
iconUrl: ''
})
const iconUploading = ref(false)
const advanced = reactive({
supervisorFallbackToDeptLeader: true,
allowWithdrawRunning: true,
commentRequired: false,
allowAddSign: true,
allowReturn: true,
approverDedup: false
})
const flowTreeJson = ref(createDefaultFlow())
async function collectFormSchema() {
if (formEmbedRef.value?.fetchSchema) {
try {
const schema = await formEmbedRef.value.fetchSchema()
formSchema.value = schema
return schema
} catch {
/* 超时则回退本地缓存 */
}
}
return {
rule: formSchema.value?.rule ?? draft.value?.formSchemaJson?.rule ?? [],
option: formSchema.value?.option ?? draft.value?.formSchemaJson?.option ?? {}
}
}
async function uploadBasicIcon(options: UploadRequestOptions) {
const file = options.file as File
if (!file?.type?.startsWith('image/')) {
ElMessage.warning('请上传图片文件')
return
}
iconUploading.value = true
try {
const fd = new FormData()
fd.append('file', file)
const res: any = await fetchUploadAttachment(fd)
const url = res?.fileUrl || res?.data?.fileUrl
if (!url) {
ElMessage.error('上传失败,未返回地址')
return
}
basic.iconUrl = url
options.onSuccess?.(res)
} catch (e) {
options.onError?.(e as any)
} finally {
iconUploading.value = false
}
}
async function loadDraft() {
if (!appId) return
loading.value = true
try {
const data = await fetchOaAppDraft(appId)
draft.value = data
const b = data?.basic || {}
basic.appName = b.appName || ''
basic.description = b.description || ''
basic.status = b.status || '1'
basic.groupId = b.groupId || ''
basic.iconUrl = b.iconUrl || ''
const a = data?.advancedSettingsJson || {}
advanced.supervisorFallbackToDeptLeader = a.supervisorFallbackToDeptLeader !== false
advanced.allowWithdrawRunning = a.allowWithdrawRunning !== false
advanced.commentRequired = !!a.commentRequired
advanced.allowAddSign = a.allowAddSign !== false
advanced.allowReturn = a.allowReturn !== false
advanced.approverDedup = !!a.approverDedup
flowTreeJson.value = ensureFlowRoot(data?.flowTreeJson)
const fs = data?.formSchemaJson
// 存纯 JSON,便于 iframe postMessage 克隆与 FormCreate setRule
try {
const plain = JSON.parse(JSON.stringify(fs ?? {}))
formSchema.value = {
rule: Array.isArray(plain?.rule) ? plain.rule : Array.isArray(plain) ? plain : [],
option: plain?.option && typeof plain.option === 'object' ? plain.option : {}
}
} catch {
formSchema.value = { rule: [], option: {} }
}
} finally {
loading.value = false
}
}
async function saveDraft() {
saving.value = true
try {
const formSchemaJson = await collectFormSchema()
const data = await fetchSaveOaAppDraft(appId, {
basic: {
appName: basic.appName.trim(),
description: basic.description,
status: basic.status,
groupId: basic.groupId || undefined,
iconUrl: basic.iconUrl || undefined
},
formSchemaJson,
flowTreeJson: flowTreeJson.value,
advancedSettingsJson: { ...advanced }
})
draft.value = data
} finally {
saving.value = false
}
}
async function publish() {
await saveDraft()
await ElMessageBox.confirm('确定发布当前草稿吗?', '发布确认', { type: 'warning' })
publishing.value = true
try {
await fetchPublishOaApp(appId)
// 勿再 loadDraft:GET /draft 会无草稿时从已发布版克隆出新草稿,导致列表仍显示「草稿」
goBack()
} catch (e: any) {
const errors = e?.data?.errors || e?.response?.data?.data?.errors
if (Array.isArray(errors) && errors.length) {
await ElMessageBox.alert(errors.map((x: string) => `· ${x}`).join('\n'), '发布校验未通过', {
type: 'error'
})
}
} finally {
publishing.value = false
}
}
function goBack() {
router.push({ path: '/system-manage/oa-approval/apps' })
}
async function onStepChange(name: string | number) {
if (formDesignerMounted.value) {
try {
const schema = await collectFormSchema()
if (draft.value) draft.value.formSchemaJson = schema
} catch {
/* ignore */
}
}
if (name === 'form') {
formDesignerMounted.value = true
}
}
onMounted(async () => {
await loadDraft()
if (activeStep.value === 'form') {
formDesignerMounted.value = true
}
})
</script>
<style scoped lang="scss">
.oa-designer__panel {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
}
.oa-designer__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
}
.oa-designer__actions {
display: flex;
gap: 8px;
}
/* Tab 导航居中 */
.oa-designer__tabs {
:deep(.el-tabs__header) {
margin: 0 0 16px;
}
:deep(.el-tabs__nav-wrap) {
display: flex;
justify-content: center;
}
:deep(.el-tabs__nav-scroll) {
overflow: visible;
}
:deep(.el-tabs__nav) {
float: none;
display: inline-flex;
justify-content: center;
margin: 0 auto;
transform: none !important;
}
:deep(.el-tabs__item) {
padding: 0 22px;
font-size: 15px;
font-weight: 600;
}
}
.oa-designer__body {
width: 100%;
min-height: 360px;
padding: 8px 0 16px;
}
.oa-designer {
width: 100%;
}
:deep(.oa-designer__tabs.el-tabs),
:deep(.el-tabs__content) {
width: 100%;
}
/* 各步内容水平居中 */
.oa-designer__step {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.oa-designer__form {
width: 100%;
max-width: 720px;
:deep(.el-form-item) {
margin-bottom: 0;
}
}
.oa-designer__form--narrow {
max-width: 480px;
}
.oa-designer__icon-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.oa-designer__icon-preview {
display: block;
width: 48px;
height: 48px;
overflow: hidden;
border-radius: 10px;
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.oa-designer__form-step {
width: 100%;
}
.oa-designer__flow-step {
align-items: stretch;
width: 100%;
min-height: 0;
:deep(.oa-flow-designer) {
width: 100%;
max-width: none;
margin: 0;
}
}
</style>
@@ -28,6 +28,15 @@
<el-icon><Close /></el-icon>
拒绝
</el-button>
<el-button
v-if="canApprove && detail?.allowReturn !== false"
class="btn-ghost"
:loading="acting"
@click="doReturn"
>
<el-icon><RefreshLeft /></el-icon>
打回
</el-button>
<el-button
v-if="canApprove && detail?.allowAddSign !== false"
class="btn-ghost"
@@ -37,6 +46,16 @@
<el-icon><Plus /></el-icon>
加签
</el-button>
<el-button
v-if="detail?.status === 'returned' && isInitiator"
class="btn-main"
type="primary"
:loading="acting"
@click="openResubmit"
>
<el-icon><Check /></el-icon>
修改并再次提交
</el-button>
<el-button
v-if="detail?.status === 'running' && isInitiator"
class="btn-ghost"
@@ -50,6 +69,16 @@
</div>
<div v-loading="loading" class="oa-detail__body">
<p v-if="detail?.sourceInstanceId" class="oa-detail__link-tip">
本单由已撤销审批重提,
<el-button link type="primary" @click="goInstance(detail.sourceInstanceId)">查看原单</el-button>
</p>
<p v-if="detail?.resubmittedInstanceId" class="oa-detail__link-tip">
已有重提单,
<el-button link type="primary" @click="goInstance(detail.resubmittedInstanceId)">
查看重提单
</el-button>
</p>
<section class="oa-detail__section">
<h3>表单内容</h3>
<div class="oa-detail__form-wrap">
@@ -67,13 +96,18 @@
<h3>审批进度</h3>
<div class="oa-detail__timeline-wrap">
<el-timeline v-if="timelineItems.length">
<el-timeline-item
v-for="item in timelineItems"
:key="item.key"
:type="timelineType(item.status)"
:timestamp="item.timestamp"
placement="top"
>
<template v-for="(item, idx) in timelineItems" :key="item.key">
<div
v-if="idx === 0 || item.roundNo !== timelineItems[idx - 1].roundNo"
class="oa-tl__round"
>
第 {{ item.roundNo }} 轮
</div>
<el-timeline-item
:type="timelineType(item.status)"
:timestamp="item.timestamp"
placement="top"
>
<div class="oa-tl">
<div class="oa-tl__title">
{{ item.title }}
@@ -81,6 +115,7 @@
{{ statusText(item.status) }}
</el-tag>
<el-tag v-if="item.taskType === 'cc'" size="small" type="info">抄送</el-tag>
<el-tag v-if="item.kind === 'return'" size="small" type="warning">打回</el-tag>
</div>
<div
v-if="item.assignee && item.kind !== 'pending_node' && item.kind !== 'terminal'"
@@ -93,7 +128,8 @@
</div>
<div v-if="item.commentText" class="oa-tl__comment">意见:{{ item.commentText }}</div>
</div>
</el-timeline-item>
</el-timeline-item>
</template>
</el-timeline>
<p v-else class="oa-detail__empty">暂无任务记录</p>
</div>
@@ -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;
@@ -0,0 +1,646 @@
<!-- 发起审批 · 列表样式对齐审批管理 -->
<template>
<div class="page-container">
<section class="panel oa-apps">
<div class="oa-apps__search-wrap">
<el-input
v-model="searchKeyword"
class="oa-apps__search"
clearable
placeholder="请输入审批单名称"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<div class="oa-apps__tabs" role="tablist">
<button
type="button"
class="oa-apps__tab"
:class="{ active: activeGroupId === '' }"
role="tab"
@click="activeGroupId = ''"
>
全部
</button>
<button
v-for="g in groupOptions"
:key="g.id"
type="button"
class="oa-apps__tab"
:class="{ active: activeGroupId === g.id }"
role="tab"
@click="activeGroupId = g.id"
>
{{ g.groupName }}
</button>
</div>
<div class="oa-apps__toolbar">
<div class="oa-apps__toolbar-hint">选择一个审批单即可发起</div>
</div>
<div v-loading="loading" class="oa-apps__body">
<div v-if="!displayGroups.length && !loading" class="oa-apps__empty">
暂无可发起的审批模板,请先在「审批管理」中发布应用。
</div>
<section
v-for="sec in displayGroups"
:key="sec.id || '_ungrouped'"
class="oa-apps__section"
>
<div class="oa-apps__section-head">
<h3>{{ sec.name }}</h3>
<span class="oa-apps__dot" aria-hidden="true" />
<span class="oa-apps__section-meta">{{ sec.apps.length }} 个表单</span>
</div>
<div class="oa-apps__card-row">
<div
v-for="row in sec.apps"
:key="row.id"
class="oa-app-card"
role="button"
tabindex="0"
@click="onPick(row)"
@keydown.enter="onPick(row)"
>
<div class="oa-app-card__main">
<span class="oa-app-card__icon">
<img :src="resolveOaAppIconUrl(row.iconUrl)" alt="" />
</span>
<span class="oa-app-card__body">
<span class="oa-app-card__name">{{ row.appName }}</span>
<span class="oa-app-card__desc">{{ row.description || '点击发起审批' }}</span>
</span>
</div>
<div class="oa-app-card__ops">
<el-button class="oa-app-card__btn primary" @click.stop="onPick(row)">
<el-icon><Promotion /></el-icon>
发起
</el-button>
</div>
</div>
</div>
</section>
</div>
<el-dialog
v-model="dlgVisible"
:title="picked?.appName || '发起审批'"
width="720px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="protocol-dialog-mask"
destroy-on-close
:close-on-click-modal="false"
>
<div v-loading="detailLoading">
<el-form v-if="needInitiatorSelect" label-position="top" class="oa-start__meta">
<el-form-item label="自选审批人" required>
<el-select
v-model="initiatorSelectUserIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="请选择审批人"
style="width: 100%"
:loading="userLoading"
>
<el-option
v-for="u in userOptions"
:key="u.id"
:label="`${u.nickName}(${u.userNo})`"
:value="u.id"
/>
</el-select>
</el-form-item>
</el-form>
<form-create
v-if="formRule.length"
v-model="formData"
v-model:api="fapi"
:rule="formRule"
:option="formOption"
/>
<p v-else-if="!detailLoading" class="oa-start__tip">该模板暂无表单字段,仍可直接提交。</p>
<OaFlowPreview
v-if="!detailLoading"
:steps="flowSteps"
:loading="previewLoading"
/>
</div>
<template #footer>
<el-button class="btn-ghost" @click="dlgVisible = false">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" :loading="submitting" @click="submit">
<el-icon><Check /></el-icon>
提交
</el-button>
</template>
</el-dialog>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Check, Close, Promotion, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { fetchUserList } from '@/api/admin'
import {
fetchOaFlowPreview,
fetchOaGroupList,
fetchOaInstanceDetail,
fetchOaTemplateDetail,
fetchOaTemplates,
fetchStartOaInstance,
type OaFlowPreviewStep
} from '@/api/oaApproval'
import OaFlowPreview from '@/components/oa-approval/OaFlowPreview.vue'
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
import { resolveOaAppIconUrl } from '@/utils/oa-app-icons'
defineOptions({ name: 'OaApprovalStart' })
const router = useRouter()
const route = useRoute()
const loading = ref(false)
const templates = ref<any[]>([])
const groupOptions = ref<Array<{ id: string; groupName: string }>>([])
const searchKeyword = ref('')
const activeGroupId = ref('')
const dlgVisible = ref(false)
const detailLoading = ref(false)
const picked = ref<any>(null)
const formData = ref<Record<string, any>>({})
const formRule = ref<any[]>([])
const formOption = ref<Record<string, any>>({ submitBtn: false, resetBtn: false })
const fapi = ref<any>(null)
const submitting = ref(false)
const sourceInstanceId = ref('')
const needInitiatorSelect = ref(false)
const initiatorSelectUserIds = ref<string[]>([])
const userLoading = ref(false)
const userOptions = ref<Array<{ id: string; nickName: string; userNo: string }>>([])
const flowSteps = ref<OaFlowPreviewStep[]>([])
const previewLoading = ref(false)
const displayGroups = computed(() => {
const kw = (searchKeyword.value || '').trim().toLowerCase()
let list = [...(templates.value || [])]
if (activeGroupId.value) {
list = list.filter((a) => a.groupId === activeGroupId.value)
}
if (kw) {
list = list.filter((a) => String(a.appName || '').toLowerCase().includes(kw))
}
const bucket = new Map<string, { id: string; name: string; apps: any[] }>()
for (const g of groupOptions.value) {
bucket.set(g.id, { id: g.id, name: g.groupName, apps: [] })
}
const ungrouped = { id: '_ungrouped', name: '未分组', apps: [] as any[] }
for (const app of list) {
if (app.groupId && bucket.has(app.groupId)) {
bucket.get(app.groupId)!.apps.push(app)
} else {
ungrouped.apps.push(app)
}
}
if (activeGroupId.value) {
const one = bucket.get(activeGroupId.value)
return one && one.apps.length ? [one] : []
}
const sections: { id: string; name: string; apps: any[] }[] = []
for (const g of groupOptions.value) {
const sec = bucket.get(g.id)!
if (sec.apps.length) sections.push(sec)
}
if (ungrouped.apps.length) sections.push(ungrouped)
return sections
})
function flowNeedsInitiatorSelect(node: any): boolean {
if (!node || typeof node !== 'object') return false
const cfg = node.approverConfig || {}
if ((cfg.assigneeType || '') === 'initiator_select') return true
if (node.child && flowNeedsInitiatorSelect(node.child)) return true
for (const g of node.conditionGroups || []) {
if (g?.child && flowNeedsInitiatorSelect(g.child)) return true
}
return false
}
async function loadUsers() {
userLoading.value = true
try {
const pageSize = MAX_ADMIN_LIST_PAGE_SIZE
const raw: any[] = []
let page = 1
for (;;) {
const res = await fetchUserList({ page, pageSize, status: '1' })
const list = res?.list || []
raw.push(...list)
if (list.length < pageSize) break
if (typeof res?.total === 'number' && raw.length >= res.total) break
page += 1
}
userOptions.value = raw.map((u) => ({
id: u.id,
nickName: u.nickName || '',
userNo: u.userNo || ''
}))
} finally {
userLoading.value = false
}
}
async function loadFlowPreview(appId: string) {
previewLoading.value = true
try {
const preview = await fetchOaFlowPreview(
appId,
needInitiatorSelect.value ? [...initiatorSelectUserIds.value] : undefined
)
flowSteps.value = preview?.steps || []
} catch {
flowSteps.value = []
} finally {
previewLoading.value = false
}
}
async function onPick(t: any) {
picked.value = t
formData.value = {}
formRule.value = []
needInitiatorSelect.value = false
initiatorSelectUserIds.value = []
flowSteps.value = []
dlgVisible.value = true
detailLoading.value = true
previewLoading.value = true
try {
const detail = await fetchOaTemplateDetail(t.id)
const schema = detail?.formSchemaJson || {}
formRule.value = Array.isArray(schema.rule) ? schema.rule : Array.isArray(schema) ? schema : []
formOption.value = {
...(schema.option || {}),
form: {
...(schema.option?.form || {}),
labelPosition: 'top'
},
submitBtn: false,
resetBtn: false
}
const flow = detail?.flowTreeJson || {}
const root = flow.root || flow
needInitiatorSelect.value = flowNeedsInitiatorSelect(root)
if (needInitiatorSelect.value && !userOptions.value.length) {
await loadUsers()
}
await loadFlowPreview(t.id)
} catch {
formRule.value = []
flowSteps.value = []
previewLoading.value = false
} finally {
detailLoading.value = false
}
}
watch(
initiatorSelectUserIds,
() => {
if (dlgVisible.value && picked.value?.id && needInitiatorSelect.value) {
loadFlowPreview(picked.value.id)
}
},
{ deep: true }
)
async function submit() {
if (!picked.value?.id) return
if (needInitiatorSelect.value && !initiatorSelectUserIds.value.length) {
ElMessage.warning('请选择自选审批人')
return
}
if (fapi.value?.validate) {
try {
await fapi.value.validate()
} catch {
ElMessage.warning('请完善表单必填项')
return
}
}
submitting.value = true
try {
const data: any = await fetchStartOaInstance({
appId: picked.value.id,
formData: { ...formData.value },
initiatorSelectUserIds: needInitiatorSelect.value
? [...initiatorSelectUserIds.value]
: undefined,
sourceInstanceId: sourceInstanceId.value || undefined
})
dlgVisible.value = false
sourceInstanceId.value = ''
if (data?.id) {
router.push({ path: `/system-manage/oa-approval/instances/${data.id}` })
}
} finally {
submitting.value = false
}
}
onMounted(async () => {
loading.value = true
try {
const [tpl, groupsRes] = await Promise.all([fetchOaTemplates(), fetchOaGroupList()])
templates.value = tpl?.list || []
groupOptions.value = groupsRes?.list || []
const qAppId = String(route.query.appId || '').trim()
const qSource = String(route.query.sourceInstanceId || '').trim()
if (qAppId) {
const row = templates.value.find((t: any) => t.id === qAppId)
if (row) {
sourceInstanceId.value = qSource
await onPick(row)
if (qSource) {
try {
const old = await fetchOaInstanceDetail(qSource)
formData.value = { ...(old?.formDataJson || {}) }
} catch {
/* 预填失败仍可手工填 */
}
}
}
}
} finally {
loading.value = false
}
})
</script>
<style scoped lang="scss">
.oa-apps {
background: transparent;
border: none;
border-radius: 0;
box-shadow: none;
}
.oa-apps__search-wrap {
display: flex;
justify-content: center;
margin: 8px 0 20px;
}
.oa-apps__search {
width: min(520px, 100%);
:deep(.el-input__wrapper) {
height: 42px;
padding-left: 16px;
background: #fff;
border-radius: 999px;
box-shadow:
0 0 0 1px var(--mint-border, #cbd5e1) inset,
0 1px 2px rgb(15 23 42 / 6%);
}
:deep(.el-input__wrapper.is-focus) {
box-shadow:
0 0 0 1px var(--primary, #10b981) inset,
0 0 0 3px var(--primary-bg, #ecfdf5);
}
}
.oa-apps__tabs {
display: flex;
flex-wrap: wrap;
gap: 28px;
margin-bottom: 4px;
}
.oa-apps__tab {
position: relative;
padding: 0 0 12px;
color: var(--mint-text, #334155);
font-size: 16px;
font-weight: 600;
cursor: pointer;
background: none;
border: 0;
&:hover {
color: var(--primary-dark, #059669);
}
&.active {
color: var(--mint-text-strong, #0f172a);
&::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 3px;
content: '';
background: var(--mint-text-strong, #0f172a);
border-radius: 2px;
}
}
}
.oa-apps__toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin: 8px 0 20px;
}
.oa-apps__toolbar-hint {
color: var(--mint-text-muted, #94a3b8);
font-size: 13px;
}
.oa-apps__body {
min-height: 160px;
}
.oa-apps__empty {
padding: 48px 16px;
color: var(--mint-text-muted, #94a3b8);
text-align: center;
}
.oa-apps__section {
margin-bottom: 28px;
}
.oa-apps__section-head {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 14px;
h3 {
margin: 0;
color: var(--mint-text, #334155);
font-size: 14px;
font-weight: 600;
}
}
.oa-apps__dot {
width: 6px;
height: 6px;
background: #f87171;
border-radius: 50%;
}
.oa-apps__section-meta {
margin-left: auto;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
}
.oa-apps__card-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: stretch;
}
.oa-app-card {
display: flex;
flex-direction: column;
gap: 12px;
width: 260px;
min-height: 100px;
padding: 14px 16px 10px;
cursor: pointer;
background: #fff;
border: 1px solid var(--mint-border, #cbd5e1);
border-radius: 14px;
box-shadow: 0 2px 8px rgb(15 23 42 / 8%);
transition:
border-color 0.15s,
box-shadow 0.15s,
transform 0.15s;
&:hover {
border-color: #86efac;
box-shadow: 0 4px 14px rgb(16 185 129 / 16%);
transform: translateY(-1px);
}
}
.oa-app-card__main {
display: flex;
gap: 14px;
align-items: center;
min-width: 0;
}
.oa-app-card__icon {
display: grid;
flex-shrink: 0;
place-items: center;
width: 40px;
height: 40px;
overflow: hidden;
border-radius: 10px;
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.oa-app-card__body {
flex: 1;
min-width: 0;
}
.oa-app-card__name {
display: block;
overflow: hidden;
color: var(--mint-text-strong, #0f172a);
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.oa-app-card__desc {
display: block;
margin-top: 4px;
overflow: hidden;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.oa-app-card__ops {
display: flex;
gap: 6px;
justify-content: flex-end;
padding-top: 8px;
border-top: 1px solid #f1f5f9;
}
.oa-app-card__btn {
height: 24px !important;
padding: 0 8px !important;
font-size: 11px !important;
border-radius: 5px !important;
.el-icon {
margin-right: 2px;
font-size: 12px;
}
&.primary {
color: #fff !important;
background: var(--primary, #10b981) !important;
border-color: var(--primary, #10b981) !important;
}
}
.oa-start__tip {
margin: 8px 0 0;
color: var(--mint-text-muted, #94a3b8);
font-size: 13px;
}
.oa-start__meta {
margin-bottom: 8px;
:deep(.el-form-item) {
margin-bottom: 12px;
}
}
@media (max-width: 640px) {
.oa-app-card {
width: 100%;
}
}
</style>