@josephyan/qingflow-app-builder-mcp 0.2.0-beta.31 → 0.2.0-beta.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/server.py +10 -0
- package/src/qingflow_mcp/server_app_user.py +10 -0
- package/src/qingflow_mcp/tools/approval_tools.py +54 -1
- package/src/qingflow_mcp/tools/task_context_tools.py +1063 -0
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.
|
|
6
|
+
npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.32
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.32 qingflow-app-builder-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -17,6 +17,7 @@ from .tools.qingbi_report_tools import QingbiReportTools
|
|
|
17
17
|
from .tools.record_tools import RecordTools
|
|
18
18
|
from .tools.role_tools import RoleTools
|
|
19
19
|
from .tools.solution_tools import SolutionTools
|
|
20
|
+
from .tools.task_context_tools import TaskContextTools
|
|
20
21
|
from .tools.view_tools import ViewTools
|
|
21
22
|
from .tools.workflow_tools import WorkflowTools
|
|
22
23
|
from .tools.workspace_tools import WorkspaceTools
|
|
@@ -93,6 +94,14 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
93
94
|
- If a member or department field id is known but candidate ids are not, use `record_member_candidates` or `record_department_candidates` before `record_write`.
|
|
94
95
|
- For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
|
|
95
96
|
|
|
97
|
+
## Task Workflow Path
|
|
98
|
+
|
|
99
|
+
`task_list -> task_get -> task_action_execute`
|
|
100
|
+
|
|
101
|
+
- Use `task_associated_report_detail_get` for associated view or report details.
|
|
102
|
+
- Use `task_workflow_log_get` for full workflow log history.
|
|
103
|
+
- Task actions operate on `app_key + record_id + workflow_node_id`, not `task_id`.
|
|
104
|
+
|
|
96
105
|
## Time Handling
|
|
97
106
|
|
|
98
107
|
Normalize relative dates before building DSL.
|
|
@@ -115,6 +124,7 @@ Avoid builder-side app or schema changes here.""",
|
|
|
115
124
|
WorkspaceTools(sessions, backend).register(server)
|
|
116
125
|
FileTools(sessions, backend).register(server)
|
|
117
126
|
RecordTools(sessions, backend).register(server)
|
|
127
|
+
TaskContextTools(sessions, backend).register(server)
|
|
118
128
|
RoleTools(sessions, backend).register(server)
|
|
119
129
|
AppTools(sessions, backend).register(server)
|
|
120
130
|
QingbiReportTools(sessions, backend).register(server)
|
|
@@ -12,6 +12,7 @@ from .tools.auth_tools import AuthTools
|
|
|
12
12
|
from .tools.directory_tools import DirectoryTools
|
|
13
13
|
from .tools.file_tools import FileTools
|
|
14
14
|
from .tools.record_tools import RecordTools
|
|
15
|
+
from .tools.task_context_tools import TaskContextTools
|
|
15
16
|
from .tools.workspace_tools import WorkspaceTools
|
|
16
17
|
|
|
17
18
|
|
|
@@ -81,6 +82,14 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
81
82
|
- If a member or department field id is known but candidate ids are not, use `record_member_candidates` or `record_department_candidates` before `record_write`.
|
|
82
83
|
- For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
|
|
83
84
|
|
|
85
|
+
## Task Workflow Path
|
|
86
|
+
|
|
87
|
+
`task_list -> task_get -> task_action_execute`
|
|
88
|
+
|
|
89
|
+
- Use `task_associated_report_detail_get` for associated view or report details.
|
|
90
|
+
- Use `task_workflow_log_get` for full workflow log history.
|
|
91
|
+
- Task actions operate on `app_key + record_id + workflow_node_id`, not `task_id`.
|
|
92
|
+
|
|
84
93
|
## Time Handling
|
|
85
94
|
|
|
86
95
|
Normalize relative dates before building DSL.
|
|
@@ -221,6 +230,7 @@ Avoid builder-side app or schema changes here.""",
|
|
|
221
230
|
)
|
|
222
231
|
|
|
223
232
|
RecordTools(sessions, backend).register(server)
|
|
233
|
+
TaskContextTools(sessions, backend).register(server)
|
|
224
234
|
DirectoryTools(sessions, backend).register(server)
|
|
225
235
|
|
|
226
236
|
return server
|
|
@@ -611,9 +611,11 @@ class ApprovalTools(ToolBase):
|
|
|
611
611
|
self._normalize_alias(body, "formId", "form_id")
|
|
612
612
|
|
|
613
613
|
node_id = self._extract_node_id(body)
|
|
614
|
-
body["nodeId"] = node_id
|
|
614
|
+
body["nodeId"] = self._resolve_actionable_node_id(context, app_key, apply_id, node_id)
|
|
615
615
|
body["applyId"] = self._match_or_fill_int(body, field_name="applyId", expected_value=apply_id)
|
|
616
616
|
body["formId"] = self._resolve_form_id(profile, context, app_key, explicit_form_id=body.get("formId"))
|
|
617
|
+
if body.get("answers") is None:
|
|
618
|
+
body["answers"] = self._fetch_current_todo_answers(context, app_key, apply_id, body["nodeId"])
|
|
617
619
|
|
|
618
620
|
self._validate_approval_payload(body)
|
|
619
621
|
return body
|
|
@@ -672,6 +674,54 @@ class ApprovalTools(ToolBase):
|
|
|
672
674
|
elif alias_value is not None and payload.get(canonical_key) != alias_value:
|
|
673
675
|
raise_tool_error(QingflowApiError.config_error(f"payload.{canonical_key} and payload.{alias_key} must match when both are provided"))
|
|
674
676
|
|
|
677
|
+
def _resolve_actionable_node_id(self, context, app_key: str, apply_id: int, node_id: int) -> int: # type: ignore[no-untyped-def]
|
|
678
|
+
infos = self.backend.request(
|
|
679
|
+
"GET",
|
|
680
|
+
context,
|
|
681
|
+
f"/app/{app_key}/apply/{apply_id}/auditInfo",
|
|
682
|
+
params={"type": 1},
|
|
683
|
+
)
|
|
684
|
+
if not isinstance(infos, list) or not infos:
|
|
685
|
+
raise_tool_error(
|
|
686
|
+
QingflowApiError.config_error(
|
|
687
|
+
f"apply_id={apply_id} is not currently actionable for the logged-in user in todo list"
|
|
688
|
+
)
|
|
689
|
+
)
|
|
690
|
+
actionable_node_ids = {
|
|
691
|
+
candidate
|
|
692
|
+
for item in infos
|
|
693
|
+
if isinstance(item, dict)
|
|
694
|
+
for candidate in (item.get("auditNodeId"), item.get("nodeId"))
|
|
695
|
+
if isinstance(candidate, int) and candidate > 0
|
|
696
|
+
}
|
|
697
|
+
if node_id not in actionable_node_ids:
|
|
698
|
+
raise_tool_error(
|
|
699
|
+
QingflowApiError.config_error(
|
|
700
|
+
f"payload.nodeId={node_id} is not an actionable todo node for apply_id={apply_id}"
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
return node_id
|
|
704
|
+
|
|
705
|
+
def _fetch_current_todo_answers(self, context, app_key: str, apply_id: int, node_id: int) -> list[dict[str, Any]]: # type: ignore[no-untyped-def]
|
|
706
|
+
detail = self.backend.request(
|
|
707
|
+
"GET",
|
|
708
|
+
context,
|
|
709
|
+
f"/app/{app_key}/apply/{apply_id}",
|
|
710
|
+
params={"role": 3, "listType": 1, "auditNodeId": node_id},
|
|
711
|
+
)
|
|
712
|
+
answers = detail.get("answers") if isinstance(detail, dict) else None
|
|
713
|
+
if not isinstance(answers, list):
|
|
714
|
+
raise_tool_error(
|
|
715
|
+
QingflowApiError.config_error(
|
|
716
|
+
f"cannot resolve current answers for apply_id={apply_id} nodeId={node_id}"
|
|
717
|
+
)
|
|
718
|
+
)
|
|
719
|
+
normalized_answers: list[dict[str, Any]] = []
|
|
720
|
+
for item in answers:
|
|
721
|
+
if isinstance(item, dict):
|
|
722
|
+
normalized_answers.append(dict(item))
|
|
723
|
+
return normalized_answers
|
|
724
|
+
|
|
675
725
|
def _validate_approval_payload(self, payload: dict[str, Any]) -> None:
|
|
676
726
|
self._reject_unsupported_fields(payload)
|
|
677
727
|
if not isinstance(payload.get("formId"), int) or payload["formId"] <= 0:
|
|
@@ -680,6 +730,9 @@ class ApprovalTools(ToolBase):
|
|
|
680
730
|
raise_tool_error(QingflowApiError.config_error("payload.applyId must be a positive integer"))
|
|
681
731
|
if not isinstance(payload.get("nodeId"), int) or payload["nodeId"] <= 0:
|
|
682
732
|
raise_tool_error(QingflowApiError.config_error("payload.nodeId must be a positive integer"))
|
|
733
|
+
answers = payload.get("answers")
|
|
734
|
+
if answers is not None and not isinstance(answers, list):
|
|
735
|
+
raise_tool_error(QingflowApiError.config_error("payload.answers must be an array when provided"))
|
|
683
736
|
|
|
684
737
|
def _validate_audit_payload(self, payload: dict[str, Any], *, require_uid: bool = False) -> None:
|
|
685
738
|
self._reject_unsupported_fields(payload)
|
|
@@ -0,0 +1,1063 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from ..backend_client import BackendRequestContext
|
|
9
|
+
from ..config import DEFAULT_PROFILE
|
|
10
|
+
from ..errors import QingflowApiError, raise_tool_error
|
|
11
|
+
from ..json_types import JSONObject
|
|
12
|
+
from .approval_tools import ApprovalTools, _approval_page_amount, _approval_page_items, _approval_page_total
|
|
13
|
+
from .base import ToolBase
|
|
14
|
+
from .qingbi_report_tools import _qingbi_base_url
|
|
15
|
+
from .task_tools import TaskTools, _task_page_amount, _task_page_items, _task_page_total
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TaskContextTools(ToolBase):
|
|
19
|
+
def __init__(self, sessions, backend) -> None: # type: ignore[no-untyped-def]
|
|
20
|
+
super().__init__(sessions, backend)
|
|
21
|
+
self._task_tools = TaskTools(sessions, backend)
|
|
22
|
+
self._approval_tools = ApprovalTools(sessions, backend)
|
|
23
|
+
|
|
24
|
+
def register(self, mcp: FastMCP) -> None:
|
|
25
|
+
@mcp.tool()
|
|
26
|
+
def task_list(
|
|
27
|
+
profile: str = DEFAULT_PROFILE,
|
|
28
|
+
task_box: str = "todo",
|
|
29
|
+
flow_status: str = "all",
|
|
30
|
+
app_key: str | None = None,
|
|
31
|
+
workflow_node_id: int | None = None,
|
|
32
|
+
query: str | None = None,
|
|
33
|
+
page: int = 1,
|
|
34
|
+
page_size: int = 20,
|
|
35
|
+
) -> dict[str, Any]:
|
|
36
|
+
return self.task_list(
|
|
37
|
+
profile=profile,
|
|
38
|
+
task_box=task_box,
|
|
39
|
+
flow_status=flow_status,
|
|
40
|
+
app_key=app_key,
|
|
41
|
+
workflow_node_id=workflow_node_id,
|
|
42
|
+
query=query,
|
|
43
|
+
page=page,
|
|
44
|
+
page_size=page_size,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@mcp.tool()
|
|
48
|
+
def task_get(
|
|
49
|
+
profile: str = DEFAULT_PROFILE,
|
|
50
|
+
app_key: str = "",
|
|
51
|
+
record_id: int = 0,
|
|
52
|
+
workflow_node_id: int = 0,
|
|
53
|
+
include_candidates: bool = True,
|
|
54
|
+
include_associated_reports: bool = True,
|
|
55
|
+
) -> dict[str, Any]:
|
|
56
|
+
return self.task_get(
|
|
57
|
+
profile=profile,
|
|
58
|
+
app_key=app_key,
|
|
59
|
+
record_id=record_id,
|
|
60
|
+
workflow_node_id=workflow_node_id,
|
|
61
|
+
include_candidates=include_candidates,
|
|
62
|
+
include_associated_reports=include_associated_reports,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@mcp.tool(description=self._high_risk_tool_description(operation="execute", target="workflow task action"))
|
|
66
|
+
def task_action_execute(
|
|
67
|
+
profile: str = DEFAULT_PROFILE,
|
|
68
|
+
app_key: str = "",
|
|
69
|
+
record_id: int = 0,
|
|
70
|
+
workflow_node_id: int = 0,
|
|
71
|
+
action: str = "",
|
|
72
|
+
payload: dict[str, Any] | None = None,
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
return self.task_action_execute(
|
|
75
|
+
profile=profile,
|
|
76
|
+
app_key=app_key,
|
|
77
|
+
record_id=record_id,
|
|
78
|
+
workflow_node_id=workflow_node_id,
|
|
79
|
+
action=action,
|
|
80
|
+
payload=payload or {},
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@mcp.tool()
|
|
84
|
+
def task_associated_report_detail_get(
|
|
85
|
+
profile: str = DEFAULT_PROFILE,
|
|
86
|
+
app_key: str = "",
|
|
87
|
+
record_id: int = 0,
|
|
88
|
+
workflow_node_id: int = 0,
|
|
89
|
+
report_id: int = 0,
|
|
90
|
+
page: int = 1,
|
|
91
|
+
page_size: int = 20,
|
|
92
|
+
) -> dict[str, Any]:
|
|
93
|
+
return self.task_associated_report_detail_get(
|
|
94
|
+
profile=profile,
|
|
95
|
+
app_key=app_key,
|
|
96
|
+
record_id=record_id,
|
|
97
|
+
workflow_node_id=workflow_node_id,
|
|
98
|
+
report_id=report_id,
|
|
99
|
+
page=page,
|
|
100
|
+
page_size=page_size,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@mcp.tool()
|
|
104
|
+
def task_workflow_log_get(
|
|
105
|
+
profile: str = DEFAULT_PROFILE,
|
|
106
|
+
app_key: str = "",
|
|
107
|
+
record_id: int = 0,
|
|
108
|
+
workflow_node_id: int = 0,
|
|
109
|
+
) -> dict[str, Any]:
|
|
110
|
+
return self.task_workflow_log_get(
|
|
111
|
+
profile=profile,
|
|
112
|
+
app_key=app_key,
|
|
113
|
+
record_id=record_id,
|
|
114
|
+
workflow_node_id=workflow_node_id,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def task_list(
|
|
118
|
+
self,
|
|
119
|
+
*,
|
|
120
|
+
profile: str,
|
|
121
|
+
task_box: str,
|
|
122
|
+
flow_status: str,
|
|
123
|
+
app_key: str | None,
|
|
124
|
+
workflow_node_id: int | None,
|
|
125
|
+
query: str | None,
|
|
126
|
+
page: int,
|
|
127
|
+
page_size: int,
|
|
128
|
+
) -> dict[str, Any]:
|
|
129
|
+
normalized_type = self._task_tools._task_box_to_type(task_box)
|
|
130
|
+
normalized_status = self._task_tools._flow_status_to_process_status(flow_status)
|
|
131
|
+
raw = self._task_tools.task_list(
|
|
132
|
+
profile=profile,
|
|
133
|
+
type=normalized_type,
|
|
134
|
+
process_status=normalized_status,
|
|
135
|
+
app_key=app_key,
|
|
136
|
+
node_id=workflow_node_id,
|
|
137
|
+
search_key=query,
|
|
138
|
+
page_num=page,
|
|
139
|
+
page_size=page_size,
|
|
140
|
+
create_time_asc=None,
|
|
141
|
+
)
|
|
142
|
+
task_page = raw.get("page", {})
|
|
143
|
+
items = [
|
|
144
|
+
self._normalize_task_item(item, task_box=task_box, flow_status=flow_status)
|
|
145
|
+
for item in _task_page_items(task_page)
|
|
146
|
+
if isinstance(item, dict)
|
|
147
|
+
]
|
|
148
|
+
return {
|
|
149
|
+
"profile": profile,
|
|
150
|
+
"ws_id": raw.get("ws_id"),
|
|
151
|
+
"ok": True,
|
|
152
|
+
"request_route": raw.get("request_route"),
|
|
153
|
+
"warnings": [],
|
|
154
|
+
"output_profile": "normal",
|
|
155
|
+
"data": {
|
|
156
|
+
"items": items,
|
|
157
|
+
"pagination": {
|
|
158
|
+
"page": page,
|
|
159
|
+
"page_size": page_size,
|
|
160
|
+
"returned_items": len(items),
|
|
161
|
+
"page_amount": _task_page_amount(task_page),
|
|
162
|
+
"reported_total": _task_page_total(task_page),
|
|
163
|
+
},
|
|
164
|
+
"selection": {
|
|
165
|
+
"task_box": task_box,
|
|
166
|
+
"flow_status": flow_status,
|
|
167
|
+
"app_key": app_key,
|
|
168
|
+
"workflow_node_id": workflow_node_id,
|
|
169
|
+
"query": query,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
def task_get(
|
|
175
|
+
self,
|
|
176
|
+
*,
|
|
177
|
+
profile: str,
|
|
178
|
+
app_key: str,
|
|
179
|
+
record_id: int,
|
|
180
|
+
workflow_node_id: int,
|
|
181
|
+
include_candidates: bool,
|
|
182
|
+
include_associated_reports: bool,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
self._require_app_record_and_node(app_key, record_id, workflow_node_id)
|
|
185
|
+
|
|
186
|
+
def runner(session_profile, context):
|
|
187
|
+
data = self._build_task_context(
|
|
188
|
+
context,
|
|
189
|
+
app_key=app_key,
|
|
190
|
+
record_id=record_id,
|
|
191
|
+
workflow_node_id=workflow_node_id,
|
|
192
|
+
include_candidates=include_candidates,
|
|
193
|
+
include_associated_reports=include_associated_reports,
|
|
194
|
+
)
|
|
195
|
+
return {
|
|
196
|
+
"profile": profile,
|
|
197
|
+
"ws_id": session_profile.selected_ws_id,
|
|
198
|
+
"ok": True,
|
|
199
|
+
"request_route": self._request_route_payload(context),
|
|
200
|
+
"warnings": [],
|
|
201
|
+
"output_profile": "normal",
|
|
202
|
+
"data": data,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return self._run(profile, runner)
|
|
206
|
+
|
|
207
|
+
def task_action_execute(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
profile: str,
|
|
211
|
+
app_key: str,
|
|
212
|
+
record_id: int,
|
|
213
|
+
workflow_node_id: int,
|
|
214
|
+
action: str,
|
|
215
|
+
payload: dict[str, Any],
|
|
216
|
+
) -> dict[str, Any]:
|
|
217
|
+
self._require_app_record_and_node(app_key, record_id, workflow_node_id)
|
|
218
|
+
normalized_action = (action or "").strip().lower()
|
|
219
|
+
if normalized_action not in {"approve", "reject", "rollback", "transfer", "urge"}:
|
|
220
|
+
raise_tool_error(
|
|
221
|
+
QingflowApiError.not_supported(
|
|
222
|
+
"TASK_ACTION_UNSUPPORTED: action must be one of approve, reject, rollback, transfer, or urge"
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
body = dict(payload or {})
|
|
226
|
+
context_summary = self.task_get(
|
|
227
|
+
profile=profile,
|
|
228
|
+
app_key=app_key,
|
|
229
|
+
record_id=record_id,
|
|
230
|
+
workflow_node_id=workflow_node_id,
|
|
231
|
+
include_candidates=False,
|
|
232
|
+
include_associated_reports=False,
|
|
233
|
+
)
|
|
234
|
+
capabilities = ((context_summary.get("data") or {}).get("capabilities") or {})
|
|
235
|
+
available_actions = capabilities.get("available_actions") or []
|
|
236
|
+
if normalized_action not in available_actions:
|
|
237
|
+
raise_tool_error(
|
|
238
|
+
QingflowApiError.config_error(
|
|
239
|
+
f"task action '{normalized_action}' is not currently available for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
feedback_required_for = capabilities.get("action_constraints", {}).get("feedback_required_for") or []
|
|
243
|
+
if normalized_action in feedback_required_for and not self._extract_audit_feedback(body):
|
|
244
|
+
raise_tool_error(
|
|
245
|
+
QingflowApiError.config_error(
|
|
246
|
+
f"payload.audit_feedback is required for action '{normalized_action}' on the current node"
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if normalized_action == "approve":
|
|
251
|
+
action_payload = dict(body)
|
|
252
|
+
action_payload["nodeId"] = workflow_node_id
|
|
253
|
+
raw = self._approval_tools.record_approve(
|
|
254
|
+
profile=profile,
|
|
255
|
+
app_key=app_key,
|
|
256
|
+
apply_id=record_id,
|
|
257
|
+
payload=action_payload,
|
|
258
|
+
)
|
|
259
|
+
elif normalized_action == "reject":
|
|
260
|
+
action_payload = dict(body)
|
|
261
|
+
action_payload["nodeId"] = workflow_node_id
|
|
262
|
+
if not self._extract_audit_feedback(action_payload):
|
|
263
|
+
raise_tool_error(QingflowApiError.config_error("payload.audit_feedback is required for reject"))
|
|
264
|
+
raw = self._approval_tools.record_reject(
|
|
265
|
+
profile=profile,
|
|
266
|
+
app_key=app_key,
|
|
267
|
+
apply_id=record_id,
|
|
268
|
+
payload=action_payload,
|
|
269
|
+
)
|
|
270
|
+
elif normalized_action == "rollback":
|
|
271
|
+
target_node_id = self._extract_positive_int(body, "target_workflow_node_id", aliases=("targetAuditNodeId", "targetWorkflowNodeId"))
|
|
272
|
+
action_payload: JSONObject = {
|
|
273
|
+
"auditNodeId": workflow_node_id,
|
|
274
|
+
"targetAuditNodeId": target_node_id,
|
|
275
|
+
}
|
|
276
|
+
audit_feedback = self._extract_audit_feedback(body)
|
|
277
|
+
if audit_feedback:
|
|
278
|
+
action_payload["auditFeedback"] = audit_feedback
|
|
279
|
+
raw = self._approval_tools.record_rollback(
|
|
280
|
+
profile=profile,
|
|
281
|
+
app_key=app_key,
|
|
282
|
+
apply_id=record_id,
|
|
283
|
+
payload=action_payload,
|
|
284
|
+
)
|
|
285
|
+
elif normalized_action == "transfer":
|
|
286
|
+
target_member_id = self._extract_positive_int(body, "target_member_id", aliases=("uid", "targetMemberId"))
|
|
287
|
+
action_payload = {
|
|
288
|
+
"auditNodeId": workflow_node_id,
|
|
289
|
+
"uid": target_member_id,
|
|
290
|
+
}
|
|
291
|
+
audit_feedback = self._extract_audit_feedback(body)
|
|
292
|
+
if audit_feedback:
|
|
293
|
+
action_payload["auditFeedback"] = audit_feedback
|
|
294
|
+
raw = self._approval_tools.record_transfer(
|
|
295
|
+
profile=profile,
|
|
296
|
+
app_key=app_key,
|
|
297
|
+
apply_id=record_id,
|
|
298
|
+
payload=action_payload,
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
raw = self._task_tools.task_urge(
|
|
302
|
+
profile=profile,
|
|
303
|
+
app_key=app_key,
|
|
304
|
+
row_record_id=record_id,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
"profile": raw.get("profile", profile),
|
|
309
|
+
"ws_id": raw.get("ws_id"),
|
|
310
|
+
"ok": bool(raw.get("ok", True)),
|
|
311
|
+
"request_route": raw.get("request_route"),
|
|
312
|
+
"warnings": [],
|
|
313
|
+
"output_profile": "normal",
|
|
314
|
+
"data": {
|
|
315
|
+
"action": normalized_action,
|
|
316
|
+
"resource": {
|
|
317
|
+
"app_key": app_key,
|
|
318
|
+
"record_id": record_id,
|
|
319
|
+
"workflow_node_id": workflow_node_id,
|
|
320
|
+
},
|
|
321
|
+
"selection": {"action": normalized_action},
|
|
322
|
+
"result": raw.get("result"),
|
|
323
|
+
"human_review": True,
|
|
324
|
+
},
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
def task_associated_report_detail_get(
|
|
328
|
+
self,
|
|
329
|
+
*,
|
|
330
|
+
profile: str,
|
|
331
|
+
app_key: str,
|
|
332
|
+
record_id: int,
|
|
333
|
+
workflow_node_id: int,
|
|
334
|
+
report_id: int,
|
|
335
|
+
page: int,
|
|
336
|
+
page_size: int,
|
|
337
|
+
) -> dict[str, Any]:
|
|
338
|
+
self._require_app_record_and_node(app_key, record_id, workflow_node_id)
|
|
339
|
+
if report_id <= 0:
|
|
340
|
+
raise_tool_error(QingflowApiError.config_error("report_id must be positive"))
|
|
341
|
+
if page <= 0 or page_size <= 0:
|
|
342
|
+
raise_tool_error(QingflowApiError.config_error("page and page_size must be positive"))
|
|
343
|
+
|
|
344
|
+
def runner(session_profile, context):
|
|
345
|
+
task_context = self._build_task_context(
|
|
346
|
+
context,
|
|
347
|
+
app_key=app_key,
|
|
348
|
+
record_id=record_id,
|
|
349
|
+
workflow_node_id=workflow_node_id,
|
|
350
|
+
include_candidates=False,
|
|
351
|
+
include_associated_reports=True,
|
|
352
|
+
)
|
|
353
|
+
report_item = self._find_associated_report(task_context, report_id)
|
|
354
|
+
if report_item is None:
|
|
355
|
+
raise_tool_error(
|
|
356
|
+
QingflowApiError.config_error(
|
|
357
|
+
f"report_id={report_id} is not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
association_query = self._build_association_query(
|
|
361
|
+
report_item["raw"],
|
|
362
|
+
task_context.get("record", {}).get("answers") or [],
|
|
363
|
+
)
|
|
364
|
+
selection = {
|
|
365
|
+
"app_key": app_key,
|
|
366
|
+
"record_id": record_id,
|
|
367
|
+
"workflow_node_id": workflow_node_id,
|
|
368
|
+
"report_id": report_id,
|
|
369
|
+
"target_app_key": report_item.get("target_app_key"),
|
|
370
|
+
"target_app_name": report_item.get("target_app_name"),
|
|
371
|
+
"chart_key": report_item.get("chart_key"),
|
|
372
|
+
"chart_name": report_item.get("chart_name"),
|
|
373
|
+
}
|
|
374
|
+
context_payload = {
|
|
375
|
+
"match_rules": report_item.get("match_rules") or [],
|
|
376
|
+
"resolved_filters": association_query.get("keyQueValues") or [],
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if report_item.get("graph_type") == "view":
|
|
380
|
+
viewgraph_key = str(report_item.get("chart_key") or "")
|
|
381
|
+
body = {
|
|
382
|
+
"filter": {},
|
|
383
|
+
"viewgraphKey": viewgraph_key,
|
|
384
|
+
"equipmentType": 0,
|
|
385
|
+
"associationQuery": association_query,
|
|
386
|
+
}
|
|
387
|
+
result = self.backend.request(
|
|
388
|
+
"POST",
|
|
389
|
+
context,
|
|
390
|
+
f"/view/{viewgraph_key}/apply/filter",
|
|
391
|
+
json_body=body,
|
|
392
|
+
)
|
|
393
|
+
items = _task_page_items(result)
|
|
394
|
+
return {
|
|
395
|
+
"profile": profile,
|
|
396
|
+
"ws_id": session_profile.selected_ws_id,
|
|
397
|
+
"ok": True,
|
|
398
|
+
"request_route": self._request_route_payload(context),
|
|
399
|
+
"warnings": [],
|
|
400
|
+
"output_profile": "normal",
|
|
401
|
+
"data": {
|
|
402
|
+
"result_type": "view_list",
|
|
403
|
+
"result": {
|
|
404
|
+
"items": items,
|
|
405
|
+
"pagination": {
|
|
406
|
+
"page": page,
|
|
407
|
+
"page_size": page_size,
|
|
408
|
+
"returned_items": len(items),
|
|
409
|
+
"page_amount": _task_page_amount(result),
|
|
410
|
+
"reported_total": _task_page_total(result),
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
"selection": selection,
|
|
414
|
+
"context": context_payload,
|
|
415
|
+
},
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
chart_key = str(report_item.get("chart_key") or "")
|
|
419
|
+
source_type = report_item.get("source_type")
|
|
420
|
+
if source_type == "qingbi":
|
|
421
|
+
qingbi_context = BackendRequestContext(
|
|
422
|
+
base_url=_qingbi_base_url(context.base_url),
|
|
423
|
+
token=context.token,
|
|
424
|
+
ws_id=context.ws_id,
|
|
425
|
+
qf_request_id=context.qf_request_id,
|
|
426
|
+
qf_version=context.qf_version,
|
|
427
|
+
qf_version_source=context.qf_version_source,
|
|
428
|
+
)
|
|
429
|
+
chart_result = self.backend.request(
|
|
430
|
+
"POST",
|
|
431
|
+
qingbi_context,
|
|
432
|
+
f"/qingbi/charts/data/{chart_key}",
|
|
433
|
+
params={
|
|
434
|
+
"qfUUID": uuid4().hex,
|
|
435
|
+
"pageNum": page,
|
|
436
|
+
"pageSize": page_size,
|
|
437
|
+
},
|
|
438
|
+
json_body={
|
|
439
|
+
"asosChartId": report_id,
|
|
440
|
+
"keyQueValues": association_query.get("keyQueValues") or [],
|
|
441
|
+
},
|
|
442
|
+
)
|
|
443
|
+
request_route = {
|
|
444
|
+
"base_url": qingbi_context.base_url,
|
|
445
|
+
"qf_version": qingbi_context.qf_version,
|
|
446
|
+
"qf_version_source": qingbi_context.qf_version_source or "context",
|
|
447
|
+
}
|
|
448
|
+
elif self._qingflow_chart_uses_apply_filter(context, chart_key):
|
|
449
|
+
chart_result = self.backend.request(
|
|
450
|
+
"POST",
|
|
451
|
+
context,
|
|
452
|
+
f"/chart/{chart_key}/apply/filter",
|
|
453
|
+
json_body={
|
|
454
|
+
"filter": {
|
|
455
|
+
"pageNum": page,
|
|
456
|
+
"pageSize": page_size,
|
|
457
|
+
},
|
|
458
|
+
"asosChartId": report_id,
|
|
459
|
+
"keyQueValues": association_query.get("keyQueValues") or [],
|
|
460
|
+
},
|
|
461
|
+
)
|
|
462
|
+
items = _task_page_items(chart_result)
|
|
463
|
+
return {
|
|
464
|
+
"profile": profile,
|
|
465
|
+
"ws_id": session_profile.selected_ws_id,
|
|
466
|
+
"ok": True,
|
|
467
|
+
"request_route": self._request_route_payload(context),
|
|
468
|
+
"warnings": [],
|
|
469
|
+
"output_profile": "normal",
|
|
470
|
+
"data": {
|
|
471
|
+
"result_type": "view_list",
|
|
472
|
+
"result": {
|
|
473
|
+
"items": items,
|
|
474
|
+
"pagination": {
|
|
475
|
+
"page": page,
|
|
476
|
+
"page_size": page_size,
|
|
477
|
+
"returned_items": len(items),
|
|
478
|
+
"page_amount": _task_page_amount(chart_result),
|
|
479
|
+
"reported_total": _task_page_total(chart_result),
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
"selection": selection,
|
|
483
|
+
"context": context_payload,
|
|
484
|
+
},
|
|
485
|
+
}
|
|
486
|
+
else:
|
|
487
|
+
chart_result = self.backend.request(
|
|
488
|
+
"POST",
|
|
489
|
+
context,
|
|
490
|
+
f"/chart/{chart_key}/chartData",
|
|
491
|
+
json_body={
|
|
492
|
+
"asosChartId": report_id,
|
|
493
|
+
"keyQueValues": association_query.get("keyQueValues") or [],
|
|
494
|
+
},
|
|
495
|
+
)
|
|
496
|
+
request_route = self._request_route_payload(context)
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
"profile": profile,
|
|
500
|
+
"ws_id": session_profile.selected_ws_id,
|
|
501
|
+
"ok": True,
|
|
502
|
+
"request_route": request_route,
|
|
503
|
+
"warnings": [],
|
|
504
|
+
"output_profile": "normal",
|
|
505
|
+
"data": {
|
|
506
|
+
"result_type": "chart_data",
|
|
507
|
+
"result": self._normalize_chart_result(chart_result),
|
|
508
|
+
"selection": selection,
|
|
509
|
+
"context": context_payload,
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return self._run(profile, runner)
|
|
514
|
+
|
|
515
|
+
def task_workflow_log_get(
|
|
516
|
+
self,
|
|
517
|
+
*,
|
|
518
|
+
profile: str,
|
|
519
|
+
app_key: str,
|
|
520
|
+
record_id: int,
|
|
521
|
+
workflow_node_id: int,
|
|
522
|
+
) -> dict[str, Any]:
|
|
523
|
+
self._require_app_record_and_node(app_key, record_id, workflow_node_id)
|
|
524
|
+
|
|
525
|
+
def runner(session_profile, context):
|
|
526
|
+
task_context = self._build_task_context(
|
|
527
|
+
context,
|
|
528
|
+
app_key=app_key,
|
|
529
|
+
record_id=record_id,
|
|
530
|
+
workflow_node_id=workflow_node_id,
|
|
531
|
+
include_candidates=False,
|
|
532
|
+
include_associated_reports=False,
|
|
533
|
+
)
|
|
534
|
+
visibility = task_context.get("visibility") or {}
|
|
535
|
+
if not visibility.get("audit_record_visible"):
|
|
536
|
+
raise_tool_error(
|
|
537
|
+
QingflowApiError.config_error(
|
|
538
|
+
f"workflow logs are not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
page = self.backend.request(
|
|
542
|
+
"POST",
|
|
543
|
+
context,
|
|
544
|
+
"/application/workflow/node/record",
|
|
545
|
+
json_body={
|
|
546
|
+
"key": app_key,
|
|
547
|
+
"rowRecordId": record_id,
|
|
548
|
+
"nodeId": workflow_node_id,
|
|
549
|
+
"role": 3,
|
|
550
|
+
"pageNum": 1,
|
|
551
|
+
"pageSize": 200,
|
|
552
|
+
},
|
|
553
|
+
)
|
|
554
|
+
items = self._normalize_workflow_logs(page)
|
|
555
|
+
return {
|
|
556
|
+
"profile": profile,
|
|
557
|
+
"ws_id": session_profile.selected_ws_id,
|
|
558
|
+
"ok": True,
|
|
559
|
+
"request_route": self._request_route_payload(context),
|
|
560
|
+
"warnings": [],
|
|
561
|
+
"output_profile": "normal",
|
|
562
|
+
"data": {
|
|
563
|
+
"selection": {
|
|
564
|
+
"app_key": app_key,
|
|
565
|
+
"record_id": record_id,
|
|
566
|
+
"workflow_node_id": workflow_node_id,
|
|
567
|
+
},
|
|
568
|
+
"visibility": {
|
|
569
|
+
"audit_record_visible": visibility.get("audit_record_visible"),
|
|
570
|
+
"qrobot_record_visible": visibility.get("qrobot_record_visible"),
|
|
571
|
+
},
|
|
572
|
+
"items": items,
|
|
573
|
+
},
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return self._run(profile, runner)
|
|
577
|
+
|
|
578
|
+
def _build_task_context(
|
|
579
|
+
self,
|
|
580
|
+
context: BackendRequestContext,
|
|
581
|
+
*,
|
|
582
|
+
app_key: str,
|
|
583
|
+
record_id: int,
|
|
584
|
+
workflow_node_id: int,
|
|
585
|
+
include_candidates: bool,
|
|
586
|
+
include_associated_reports: bool,
|
|
587
|
+
) -> dict[str, Any]:
|
|
588
|
+
audit_infos = self.backend.request(
|
|
589
|
+
"GET",
|
|
590
|
+
context,
|
|
591
|
+
f"/app/{app_key}/apply/{record_id}/auditInfo",
|
|
592
|
+
params={"type": 1},
|
|
593
|
+
)
|
|
594
|
+
node_info = self._select_task_node(audit_infos, workflow_node_id, app_key=app_key, record_id=record_id)
|
|
595
|
+
detail = self.backend.request(
|
|
596
|
+
"GET",
|
|
597
|
+
context,
|
|
598
|
+
f"/app/{app_key}/apply/{record_id}",
|
|
599
|
+
params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
|
|
600
|
+
)
|
|
601
|
+
associated_report_visible = self._resolve_associated_report_visible(node_info, detail)
|
|
602
|
+
associated_reports = {"visible": associated_report_visible, "count": 0, "items": []}
|
|
603
|
+
if include_associated_reports and associated_report_visible:
|
|
604
|
+
asos_chart_list = self.backend.request(
|
|
605
|
+
"GET",
|
|
606
|
+
context,
|
|
607
|
+
f"/app/{app_key}/asosChart",
|
|
608
|
+
params={"role": 3, "auditNodeId": workflow_node_id, "beingDraft": False},
|
|
609
|
+
)
|
|
610
|
+
associated_items = [
|
|
611
|
+
self._normalize_associated_report(item)
|
|
612
|
+
for item in (asos_chart_list.get("asosCharts") or [])
|
|
613
|
+
if isinstance(item, dict)
|
|
614
|
+
]
|
|
615
|
+
associated_reports = {
|
|
616
|
+
"visible": True,
|
|
617
|
+
"count": len(associated_items),
|
|
618
|
+
"items": associated_items,
|
|
619
|
+
}
|
|
620
|
+
rollback_items: list[dict[str, Any]] = []
|
|
621
|
+
transfer_items: list[dict[str, Any]] = []
|
|
622
|
+
if include_candidates:
|
|
623
|
+
rollback_result = self.backend.request(
|
|
624
|
+
"GET",
|
|
625
|
+
context,
|
|
626
|
+
f"/app/{app_key}/apply/{record_id}/revertNode",
|
|
627
|
+
params={"auditNodeId": workflow_node_id},
|
|
628
|
+
)
|
|
629
|
+
rollback_items = self._rollback_candidate_items(rollback_result)
|
|
630
|
+
transfer_result = self.backend.request(
|
|
631
|
+
"GET",
|
|
632
|
+
context,
|
|
633
|
+
f"/app/{app_key}/apply/{record_id}/transfer/member",
|
|
634
|
+
params={"pageNum": 1, "pageSize": 20, "auditNodeId": workflow_node_id},
|
|
635
|
+
)
|
|
636
|
+
transfer_items = _approval_page_items(transfer_result)
|
|
637
|
+
|
|
638
|
+
capabilities = self._build_capabilities(node_info)
|
|
639
|
+
visibility = self._build_visibility(node_info, detail)
|
|
640
|
+
return {
|
|
641
|
+
"task": {
|
|
642
|
+
"app_key": app_key,
|
|
643
|
+
"record_id": record_id,
|
|
644
|
+
"workflow_node_id": workflow_node_id,
|
|
645
|
+
"workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
|
|
646
|
+
"actionable": True,
|
|
647
|
+
},
|
|
648
|
+
"record": {
|
|
649
|
+
"apply_id": detail.get("applyId", record_id),
|
|
650
|
+
"apply_status": detail.get("applyStatus"),
|
|
651
|
+
"apply_num": detail.get("applyNum"),
|
|
652
|
+
"custom_apply_num": detail.get("customApplyNum"),
|
|
653
|
+
"apply_user": detail.get("applyUser"),
|
|
654
|
+
"apply_time": detail.get("applyTime"),
|
|
655
|
+
"last_update_time": detail.get("lastUpdateTime"),
|
|
656
|
+
"answers": detail.get("answers") or [],
|
|
657
|
+
},
|
|
658
|
+
"capabilities": capabilities,
|
|
659
|
+
"field_permissions": {
|
|
660
|
+
"que_auth_setting": node_info.get("queAuthSetting") or [],
|
|
661
|
+
},
|
|
662
|
+
"visibility": visibility,
|
|
663
|
+
"associated_reports": associated_reports,
|
|
664
|
+
"candidates": {
|
|
665
|
+
"rollback_nodes": rollback_items,
|
|
666
|
+
"transfer_members": transfer_items,
|
|
667
|
+
},
|
|
668
|
+
"workflow_log_summary": {
|
|
669
|
+
"visible": visibility["audit_record_visible"],
|
|
670
|
+
"available": visibility["audit_record_visible"],
|
|
671
|
+
"history_count": None,
|
|
672
|
+
"qrobot_log_visible": visibility["qrobot_record_visible"],
|
|
673
|
+
},
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
def _normalize_task_item(self, raw: dict[str, Any], *, task_box: str, flow_status: str) -> dict[str, Any]:
|
|
677
|
+
app_key = raw.get("appKey") or raw.get("app_key")
|
|
678
|
+
record_id = raw.get("rowRecordId") or raw.get("recordId") or raw.get("applyId")
|
|
679
|
+
workflow_node_id = raw.get("nodeId") or raw.get("auditNodeId")
|
|
680
|
+
apply_user = raw.get("applyUser")
|
|
681
|
+
if apply_user is None:
|
|
682
|
+
user_uid = raw.get("applyUserUid")
|
|
683
|
+
user_name = raw.get("applyUserName")
|
|
684
|
+
if user_uid is not None or user_name is not None:
|
|
685
|
+
apply_user = {"uid": user_uid, "name": user_name}
|
|
686
|
+
return {
|
|
687
|
+
"task_id": raw.get("id") or raw.get("taskId") or record_id,
|
|
688
|
+
"app_key": app_key,
|
|
689
|
+
"app_name": raw.get("formTitle") or raw.get("worksheetName") or raw.get("appName"),
|
|
690
|
+
"record_id": record_id,
|
|
691
|
+
"workflow_node_id": workflow_node_id,
|
|
692
|
+
"workflow_node_name": raw.get("nodeName") or raw.get("auditNodeName"),
|
|
693
|
+
"title": raw.get("title") or raw.get("applyTitle") or raw.get("name") or raw.get("formTitle"),
|
|
694
|
+
"apply_user": apply_user,
|
|
695
|
+
"apply_time": raw.get("applyTime") or raw.get("receiveTime"),
|
|
696
|
+
"task_box": task_box,
|
|
697
|
+
"flow_status": flow_status,
|
|
698
|
+
"actionable": task_box == "todo" and bool(record_id) and bool(workflow_node_id),
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
def _select_task_node(self, infos: Any, workflow_node_id: int, *, app_key: str, record_id: int) -> dict[str, Any]:
|
|
702
|
+
if not isinstance(infos, list) or not infos:
|
|
703
|
+
raise_tool_error(
|
|
704
|
+
QingflowApiError.config_error(
|
|
705
|
+
f"record_id={record_id} is not currently actionable for the logged-in user in app_key='{app_key}'"
|
|
706
|
+
)
|
|
707
|
+
)
|
|
708
|
+
for item in infos:
|
|
709
|
+
if not isinstance(item, dict):
|
|
710
|
+
continue
|
|
711
|
+
candidate = item.get("auditNodeId")
|
|
712
|
+
if not isinstance(candidate, int):
|
|
713
|
+
candidate = item.get("nodeId")
|
|
714
|
+
if candidate == workflow_node_id:
|
|
715
|
+
return item
|
|
716
|
+
raise_tool_error(
|
|
717
|
+
QingflowApiError.config_error(
|
|
718
|
+
f"workflow_node_id={workflow_node_id} is not an actionable todo node for app_key='{app_key}' record_id={record_id}"
|
|
719
|
+
)
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
def _build_capabilities(self, node_info: dict[str, Any]) -> dict[str, Any]:
|
|
723
|
+
available_actions = ["approve"]
|
|
724
|
+
if self._coerce_bool(node_info.get("rejectBtnStatus")):
|
|
725
|
+
available_actions.append("reject")
|
|
726
|
+
if self._coerce_bool(node_info.get("canRevert")):
|
|
727
|
+
available_actions.append("rollback")
|
|
728
|
+
if self._coerce_bool(node_info.get("canTransfer")):
|
|
729
|
+
available_actions.append("transfer")
|
|
730
|
+
if self._coerce_bool(node_info.get("canUrge")):
|
|
731
|
+
available_actions.append("urge")
|
|
732
|
+
|
|
733
|
+
visible_but_unimplemented_actions: list[str] = []
|
|
734
|
+
if self._coerce_bool(node_info.get("canRevoke")):
|
|
735
|
+
visible_but_unimplemented_actions.append("revoke")
|
|
736
|
+
if self._coerce_bool(node_info.get("beingEndWorkflow")):
|
|
737
|
+
visible_but_unimplemented_actions.append("end_workflow")
|
|
738
|
+
if self._coerce_bool(node_info.get("beingCanApplyAgain")):
|
|
739
|
+
visible_but_unimplemented_actions.append("apply_again")
|
|
740
|
+
|
|
741
|
+
feedback_required_for = []
|
|
742
|
+
raw_feedback_required = node_info.get("feedbackRequiredOperationType")
|
|
743
|
+
if isinstance(raw_feedback_required, list):
|
|
744
|
+
feedback_required_for = [str(item).strip().lower() for item in raw_feedback_required if str(item).strip()]
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
"available_actions": available_actions,
|
|
748
|
+
"visible_but_unimplemented_actions": visible_but_unimplemented_actions,
|
|
749
|
+
"action_constraints": {
|
|
750
|
+
"feedback_required_for": feedback_required_for,
|
|
751
|
+
"submit_check_enabled": self._coerce_bool(node_info.get("beingSubmitCheck")),
|
|
752
|
+
"submit_preview_enabled": self._coerce_bool(node_info.get("beingSubmitPreview")),
|
|
753
|
+
"can_end_workflow": self._coerce_bool(node_info.get("beingEndWorkflow")),
|
|
754
|
+
"can_apply_again": self._coerce_bool(node_info.get("beingCanApplyAgain")),
|
|
755
|
+
},
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
def _build_visibility(self, node_info: dict[str, Any], detail: dict[str, Any]) -> dict[str, bool]:
|
|
759
|
+
return {
|
|
760
|
+
"comment_visible": self._coerce_bool(node_info.get("commentStatus")),
|
|
761
|
+
"audit_record_visible": self._coerce_bool(node_info.get("auditRecordVisible")),
|
|
762
|
+
"workflow_future_visible": self._coerce_bool(node_info.get("beingWorkflowNodeFutureListVisible")),
|
|
763
|
+
"qrobot_record_visible": self._coerce_bool(node_info.get("qrobotRecordBeingVisible")),
|
|
764
|
+
"associated_report_visible": self._resolve_associated_report_visible(node_info, detail),
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
def _resolve_associated_report_visible(self, node_info: dict[str, Any], detail: dict[str, Any]) -> bool:
|
|
768
|
+
node_visible = node_info.get("asosChartVisible")
|
|
769
|
+
if node_visible is not None:
|
|
770
|
+
return self._coerce_bool(node_visible)
|
|
771
|
+
return self._coerce_bool(detail.get("viewAsosChartVisible"))
|
|
772
|
+
|
|
773
|
+
def _normalize_associated_report(self, raw: dict[str, Any]) -> dict[str, Any]:
|
|
774
|
+
graph_type = str(raw.get("graphType") or "").strip().lower()
|
|
775
|
+
source_type = str(raw.get("sourceType") or "").strip().lower()
|
|
776
|
+
return {
|
|
777
|
+
"report_id": raw.get("id"),
|
|
778
|
+
"chart_key": raw.get("chartKey"),
|
|
779
|
+
"chart_name": raw.get("chartName"),
|
|
780
|
+
"graph_type": "view" if graph_type.endswith("view") or graph_type == "view" else "chart",
|
|
781
|
+
"source_type": source_type or "qingflow",
|
|
782
|
+
"target_app_key": raw.get("appKey"),
|
|
783
|
+
"target_app_name": raw.get("formTitle"),
|
|
784
|
+
"match_rules": raw.get("matchRules") or [],
|
|
785
|
+
"raw": dict(raw),
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
def _rollback_candidate_items(self, payload: Any) -> list[dict[str, Any]]:
|
|
789
|
+
if isinstance(payload, dict):
|
|
790
|
+
revert_nodes = payload.get("revertNodes")
|
|
791
|
+
if isinstance(revert_nodes, list):
|
|
792
|
+
return [item for item in revert_nodes if isinstance(item, dict)]
|
|
793
|
+
return [item for item in _approval_page_items(payload) if isinstance(item, dict)]
|
|
794
|
+
|
|
795
|
+
def _find_associated_report(self, task_context: dict[str, Any], report_id: int) -> dict[str, Any] | None:
|
|
796
|
+
associated_reports = ((task_context.get("associated_reports") or {}).get("items") or [])
|
|
797
|
+
for item in associated_reports:
|
|
798
|
+
if isinstance(item, dict) and item.get("report_id") == report_id:
|
|
799
|
+
return item
|
|
800
|
+
return None
|
|
801
|
+
|
|
802
|
+
def _build_association_query(self, asos_chart: dict[str, Any], answers: list[dict[str, Any]]) -> dict[str, Any]:
|
|
803
|
+
key_que_ids = self._collect_match_rule_question_ids(asos_chart.get("matchRules") or [])
|
|
804
|
+
key_values: list[dict[str, Any]] = []
|
|
805
|
+
for answer in answers:
|
|
806
|
+
if not isinstance(answer, dict):
|
|
807
|
+
continue
|
|
808
|
+
answer_que_id = answer.get("queId")
|
|
809
|
+
if isinstance(answer_que_id, int) and answer_que_id in key_que_ids:
|
|
810
|
+
extracted_values = self._extract_answer_values(answer)
|
|
811
|
+
key_values.append({"keyQueId": answer_que_id, "values": extracted_values or None})
|
|
812
|
+
table_values = answer.get("tableValues")
|
|
813
|
+
if not isinstance(table_values, list):
|
|
814
|
+
continue
|
|
815
|
+
for idx, row in enumerate(table_values, start=1):
|
|
816
|
+
if not isinstance(row, list):
|
|
817
|
+
continue
|
|
818
|
+
for sub_answer in row:
|
|
819
|
+
if not isinstance(sub_answer, dict):
|
|
820
|
+
continue
|
|
821
|
+
sub_que_id = sub_answer.get("queId")
|
|
822
|
+
if isinstance(sub_que_id, int) and sub_que_id in key_que_ids:
|
|
823
|
+
extracted_values = self._extract_answer_values(sub_answer)
|
|
824
|
+
key_values.append(
|
|
825
|
+
{
|
|
826
|
+
"keyQueId": sub_que_id,
|
|
827
|
+
"ordinal": idx,
|
|
828
|
+
"values": extracted_values or None,
|
|
829
|
+
}
|
|
830
|
+
)
|
|
831
|
+
return {
|
|
832
|
+
"asosChart": self._sanitize_associated_chart(asos_chart),
|
|
833
|
+
"keyQueValues": key_values,
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
def _collect_match_rule_question_ids(self, match_rules: Any) -> set[int]:
|
|
837
|
+
question_ids: set[int] = set()
|
|
838
|
+
|
|
839
|
+
def visit(node: Any) -> None:
|
|
840
|
+
if isinstance(node, list):
|
|
841
|
+
for item in node:
|
|
842
|
+
visit(item)
|
|
843
|
+
return
|
|
844
|
+
if not isinstance(node, dict):
|
|
845
|
+
return
|
|
846
|
+
for key in ("queId", "judgeQueId"):
|
|
847
|
+
value = node.get(key)
|
|
848
|
+
if isinstance(value, int):
|
|
849
|
+
question_ids.add(value)
|
|
850
|
+
for value in node.values():
|
|
851
|
+
if isinstance(value, (list, dict)):
|
|
852
|
+
visit(value)
|
|
853
|
+
|
|
854
|
+
visit(match_rules)
|
|
855
|
+
return question_ids
|
|
856
|
+
|
|
857
|
+
def _extract_answer_values(self, answer: dict[str, Any]) -> list[str]:
|
|
858
|
+
values = answer.get("values")
|
|
859
|
+
if not isinstance(values, list):
|
|
860
|
+
return []
|
|
861
|
+
normalized: list[str] = []
|
|
862
|
+
for item in values:
|
|
863
|
+
if item is None:
|
|
864
|
+
continue
|
|
865
|
+
if isinstance(item, (str, int, float, bool)):
|
|
866
|
+
normalized.append(str(item))
|
|
867
|
+
continue
|
|
868
|
+
if not isinstance(item, dict):
|
|
869
|
+
continue
|
|
870
|
+
for key in ("value", "id", "uid", "userId", "applyId", "phone", "email", "name", "title", "label"):
|
|
871
|
+
value = item.get(key)
|
|
872
|
+
if value not in (None, ""):
|
|
873
|
+
normalized.append(str(value))
|
|
874
|
+
break
|
|
875
|
+
deduped: list[str] = []
|
|
876
|
+
seen: set[str] = set()
|
|
877
|
+
for item in normalized:
|
|
878
|
+
if item in seen:
|
|
879
|
+
continue
|
|
880
|
+
deduped.append(item)
|
|
881
|
+
seen.add(item)
|
|
882
|
+
return deduped
|
|
883
|
+
|
|
884
|
+
def _sanitize_associated_chart(self, asos_chart: dict[str, Any]) -> dict[str, Any]:
|
|
885
|
+
return {
|
|
886
|
+
"id": asos_chart.get("id"),
|
|
887
|
+
"appKey": asos_chart.get("appKey"),
|
|
888
|
+
"formTitle": asos_chart.get("formTitle"),
|
|
889
|
+
"chartKey": asos_chart.get("chartKey"),
|
|
890
|
+
"chartName": asos_chart.get("chartName"),
|
|
891
|
+
"chartType": asos_chart.get("chartType"),
|
|
892
|
+
"matchRules": asos_chart.get("matchRules") or [],
|
|
893
|
+
"sourceType": asos_chart.get("sourceType"),
|
|
894
|
+
"graphType": asos_chart.get("graphType"),
|
|
895
|
+
"viewType": asos_chart.get("viewType"),
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
def _qingflow_chart_uses_apply_filter(self, context: BackendRequestContext, chart_key: str) -> bool:
|
|
899
|
+
if not chart_key:
|
|
900
|
+
return False
|
|
901
|
+
try:
|
|
902
|
+
auth = self.backend.request(
|
|
903
|
+
"GET",
|
|
904
|
+
context,
|
|
905
|
+
f"/chart/{chart_key}/auth",
|
|
906
|
+
)
|
|
907
|
+
except QingflowApiError:
|
|
908
|
+
return False
|
|
909
|
+
if not isinstance(auth, dict):
|
|
910
|
+
return False
|
|
911
|
+
return self._coerce_bool(auth.get("detailedViewStatus")) and auth.get("lastViewType") == 1
|
|
912
|
+
|
|
913
|
+
def _normalize_chart_result(self, payload: Any) -> dict[str, Any]:
|
|
914
|
+
if isinstance(payload, dict):
|
|
915
|
+
rows = payload.get("rows")
|
|
916
|
+
if not isinstance(rows, list):
|
|
917
|
+
rows = payload.get("list") if isinstance(payload.get("list"), list) else []
|
|
918
|
+
series = payload.get("series")
|
|
919
|
+
if not isinstance(series, list):
|
|
920
|
+
series = payload.get("xAxis") if isinstance(payload.get("xAxis"), list) else []
|
|
921
|
+
metrics = payload.get("metrics")
|
|
922
|
+
if not isinstance(metrics, list):
|
|
923
|
+
metrics = payload.get("yAxis") if isinstance(payload.get("yAxis"), list) else []
|
|
924
|
+
summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
|
|
925
|
+
return {
|
|
926
|
+
"summary": summary,
|
|
927
|
+
"rows": rows or [],
|
|
928
|
+
"series": series or [],
|
|
929
|
+
"metrics": metrics or [],
|
|
930
|
+
}
|
|
931
|
+
if isinstance(payload, list):
|
|
932
|
+
return {"summary": {}, "rows": payload, "series": [], "metrics": []}
|
|
933
|
+
return {"summary": {}, "rows": [], "series": [], "metrics": []}
|
|
934
|
+
|
|
935
|
+
def _normalize_workflow_logs(self, payload: Any) -> list[dict[str, Any]]:
|
|
936
|
+
if isinstance(payload, dict):
|
|
937
|
+
page = payload.get("list") if isinstance(payload.get("list"), list) else payload.get("rows")
|
|
938
|
+
if not isinstance(page, list):
|
|
939
|
+
nested = payload.get("data")
|
|
940
|
+
if isinstance(nested, dict):
|
|
941
|
+
page = nested.get("list") if isinstance(nested.get("list"), list) else nested.get("rows")
|
|
942
|
+
if not isinstance(page, list):
|
|
943
|
+
page = []
|
|
944
|
+
elif isinstance(payload, list):
|
|
945
|
+
page = payload
|
|
946
|
+
else:
|
|
947
|
+
page = []
|
|
948
|
+
|
|
949
|
+
items: list[dict[str, Any]] = []
|
|
950
|
+
for node_record in page:
|
|
951
|
+
if not isinstance(node_record, dict):
|
|
952
|
+
continue
|
|
953
|
+
node_id = node_record.get("nodeId")
|
|
954
|
+
node_name = node_record.get("nodeName")
|
|
955
|
+
operation_record_list = node_record.get("operationRecordList")
|
|
956
|
+
if not isinstance(operation_record_list, list):
|
|
957
|
+
operation_record_list = []
|
|
958
|
+
for operation in operation_record_list:
|
|
959
|
+
if not isinstance(operation, dict):
|
|
960
|
+
continue
|
|
961
|
+
detail = self._first_nested_operation_detail(operation)
|
|
962
|
+
items.append(
|
|
963
|
+
{
|
|
964
|
+
"log_id": operation.get("workflowNodeOperationRecordId")
|
|
965
|
+
or node_record.get("workflowNodeProcessRecordId"),
|
|
966
|
+
"node_id": node_id,
|
|
967
|
+
"node_name": node_name,
|
|
968
|
+
"operator": operation.get("operator"),
|
|
969
|
+
"operation": operation.get("operationType"),
|
|
970
|
+
"operation_result": detail,
|
|
971
|
+
"operation_time": operation.get("operationTime"),
|
|
972
|
+
"remark": self._extract_remark(detail),
|
|
973
|
+
"signature_url": self._extract_signature_url(detail),
|
|
974
|
+
"attachments": self._extract_attachments(detail),
|
|
975
|
+
"qrobot_related": any(
|
|
976
|
+
operation.get(key)
|
|
977
|
+
for key in ("qRobotAdd", "qRobotUpdate", "qRobotSMS", "qRobotMail", "webhook")
|
|
978
|
+
),
|
|
979
|
+
}
|
|
980
|
+
)
|
|
981
|
+
return items
|
|
982
|
+
|
|
983
|
+
def _first_nested_operation_detail(self, operation: dict[str, Any]) -> Any:
|
|
984
|
+
for key in ("approval", "filling", "cc", "applicant", "qRobotAdd", "qRobotUpdate", "webhook", "qRobotSMS", "qRobotMail"):
|
|
985
|
+
value = operation.get(key)
|
|
986
|
+
if value is not None:
|
|
987
|
+
return value
|
|
988
|
+
return None
|
|
989
|
+
|
|
990
|
+
def _extract_remark(self, detail: Any) -> Any:
|
|
991
|
+
if not isinstance(detail, dict):
|
|
992
|
+
return None
|
|
993
|
+
for key in ("remark", "feedback", "comment", "content"):
|
|
994
|
+
value = detail.get(key)
|
|
995
|
+
if value not in (None, ""):
|
|
996
|
+
return value
|
|
997
|
+
return None
|
|
998
|
+
|
|
999
|
+
def _extract_signature_url(self, detail: Any) -> Any:
|
|
1000
|
+
if not isinstance(detail, dict):
|
|
1001
|
+
return None
|
|
1002
|
+
for key in ("signatureUrl", "handSignImageUrl"):
|
|
1003
|
+
value = detail.get(key)
|
|
1004
|
+
if value not in (None, ""):
|
|
1005
|
+
return value
|
|
1006
|
+
return None
|
|
1007
|
+
|
|
1008
|
+
def _extract_attachments(self, detail: Any) -> Any:
|
|
1009
|
+
if not isinstance(detail, dict):
|
|
1010
|
+
return []
|
|
1011
|
+
for key in ("attachments", "files", "uploadFiles"):
|
|
1012
|
+
value = detail.get(key)
|
|
1013
|
+
if isinstance(value, list):
|
|
1014
|
+
return value
|
|
1015
|
+
return []
|
|
1016
|
+
|
|
1017
|
+
def _extract_audit_feedback(self, payload: dict[str, Any]) -> str | None:
|
|
1018
|
+
for key in ("audit_feedback", "auditFeedback"):
|
|
1019
|
+
value = payload.get(key)
|
|
1020
|
+
if isinstance(value, str) and value.strip():
|
|
1021
|
+
return value.strip()
|
|
1022
|
+
return None
|
|
1023
|
+
|
|
1024
|
+
def _extract_positive_int(self, payload: dict[str, Any], key: str, *, aliases: tuple[str, ...] = ()) -> int:
|
|
1025
|
+
candidates = (key, *aliases)
|
|
1026
|
+
value: Any = None
|
|
1027
|
+
for candidate in candidates:
|
|
1028
|
+
if candidate in payload:
|
|
1029
|
+
value = payload.get(candidate)
|
|
1030
|
+
break
|
|
1031
|
+
if not isinstance(value, int) or value <= 0:
|
|
1032
|
+
names = ", ".join(candidates)
|
|
1033
|
+
raise_tool_error(QingflowApiError.config_error(f"one of [{names}] must be a positive integer"))
|
|
1034
|
+
return value
|
|
1035
|
+
|
|
1036
|
+
def _require_app_record_and_node(self, app_key: str, record_id: int, workflow_node_id: int) -> None:
|
|
1037
|
+
if not app_key:
|
|
1038
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
1039
|
+
if record_id <= 0:
|
|
1040
|
+
raise_tool_error(QingflowApiError.config_error("record_id must be positive"))
|
|
1041
|
+
if workflow_node_id <= 0:
|
|
1042
|
+
raise_tool_error(QingflowApiError.config_error("workflow_node_id must be positive"))
|
|
1043
|
+
|
|
1044
|
+
def _coerce_bool(self, value: Any) -> bool:
|
|
1045
|
+
if isinstance(value, bool):
|
|
1046
|
+
return value
|
|
1047
|
+
if isinstance(value, int):
|
|
1048
|
+
return value != 0
|
|
1049
|
+
if isinstance(value, str):
|
|
1050
|
+
return value.strip().lower() in {"1", "true", "yes", "y", "show", "visible", "enabled"}
|
|
1051
|
+
return bool(value)
|
|
1052
|
+
|
|
1053
|
+
def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
|
|
1054
|
+
describe_route = getattr(self.backend, "describe_route", None)
|
|
1055
|
+
if callable(describe_route):
|
|
1056
|
+
payload = describe_route(context)
|
|
1057
|
+
if isinstance(payload, dict):
|
|
1058
|
+
return payload
|
|
1059
|
+
return {
|
|
1060
|
+
"base_url": context.base_url,
|
|
1061
|
+
"qf_version": context.qf_version,
|
|
1062
|
+
"qf_version_source": context.qf_version_source or ("context" if context.qf_version else "unknown"),
|
|
1063
|
+
}
|