@qingflow-tech/qingflow-app-builder-mcp 1.0.0
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 +32 -0
- package/docs/local-agent-install.md +332 -0
- package/entry_point.py +13 -0
- package/npm/bin/qingflow-app-builder-mcp.mjs +7 -0
- package/npm/lib/runtime.mjs +339 -0
- package/npm/scripts/postinstall.mjs +16 -0
- package/package.json +34 -0
- package/pyproject.toml +67 -0
- package/qingflow-app-builder-mcp +15 -0
- package/skills/qingflow-app-builder/SKILL.md +251 -0
- package/skills/qingflow-app-builder/agents/openai.yaml +4 -0
- package/skills/qingflow-app-builder/references/create-app.md +128 -0
- package/skills/qingflow-app-builder/references/environments.md +63 -0
- package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
- package/skills/qingflow-app-builder/references/gotchas.md +64 -0
- package/skills/qingflow-app-builder/references/solution-playbooks.md +53 -0
- package/skills/qingflow-app-builder/references/tool-selection.md +93 -0
- package/skills/qingflow-app-builder/references/update-flow.md +158 -0
- package/skills/qingflow-app-builder/references/update-layout.md +68 -0
- package/skills/qingflow-app-builder/references/update-schema.md +68 -0
- package/skills/qingflow-app-builder/references/update-views.md +162 -0
- package/skills/qingflow-app-builder-code-integrations/SKILL.md +137 -0
- package/skills/qingflow-app-builder-code-integrations/agents/openai.yaml +4 -0
- package/skills/qingflow-app-builder-code-integrations/references/code-block.md +66 -0
- package/skills/qingflow-app-builder-code-integrations/references/q-linker.md +77 -0
- package/src/qingflow_mcp/__init__.py +5 -0
- package/src/qingflow_mcp/__main__.py +5 -0
- package/src/qingflow_mcp/backend_client.py +649 -0
- package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
- package/src/qingflow_mcp/builder_facade/models.py +1836 -0
- package/src/qingflow_mcp/builder_facade/service.py +15044 -0
- package/src/qingflow_mcp/cli/__init__.py +1 -0
- package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
- package/src/qingflow_mcp/cli/commands/app.py +40 -0
- package/src/qingflow_mcp/cli/commands/auth.py +44 -0
- package/src/qingflow_mcp/cli/commands/builder.py +538 -0
- package/src/qingflow_mcp/cli/commands/chart.py +18 -0
- package/src/qingflow_mcp/cli/commands/common.py +62 -0
- package/src/qingflow_mcp/cli/commands/imports.py +96 -0
- package/src/qingflow_mcp/cli/commands/portal.py +25 -0
- package/src/qingflow_mcp/cli/commands/record.py +331 -0
- package/src/qingflow_mcp/cli/commands/repo.py +80 -0
- package/src/qingflow_mcp/cli/commands/task.py +89 -0
- package/src/qingflow_mcp/cli/commands/view.py +18 -0
- package/src/qingflow_mcp/cli/commands/workspace.py +25 -0
- package/src/qingflow_mcp/cli/context.py +60 -0
- package/src/qingflow_mcp/cli/formatters.py +334 -0
- package/src/qingflow_mcp/cli/json_io.py +50 -0
- package/src/qingflow_mcp/cli/main.py +178 -0
- package/src/qingflow_mcp/config.py +513 -0
- package/src/qingflow_mcp/errors.py +66 -0
- package/src/qingflow_mcp/import_store.py +121 -0
- package/src/qingflow_mcp/json_types.py +18 -0
- package/src/qingflow_mcp/list_type_labels.py +76 -0
- package/src/qingflow_mcp/public_surface.py +233 -0
- package/src/qingflow_mcp/repository_store.py +71 -0
- package/src/qingflow_mcp/response_trim.py +470 -0
- package/src/qingflow_mcp/server.py +212 -0
- package/src/qingflow_mcp/server_app_builder.py +533 -0
- package/src/qingflow_mcp/server_app_user.py +362 -0
- package/src/qingflow_mcp/session_store.py +302 -0
- package/src/qingflow_mcp/solution/__init__.py +6 -0
- package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
- package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
- package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
- package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
- package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
- package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
- package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
- package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
- package/src/qingflow_mcp/solution/design_session.py +222 -0
- package/src/qingflow_mcp/solution/design_store.py +100 -0
- package/src/qingflow_mcp/solution/executor.py +2398 -0
- package/src/qingflow_mcp/solution/normalizer.py +23 -0
- package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
- package/src/qingflow_mcp/solution/run_store.py +244 -0
- package/src/qingflow_mcp/solution/spec_models.py +855 -0
- package/src/qingflow_mcp/tools/__init__.py +1 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +3419 -0
- package/src/qingflow_mcp/tools/app_tools.py +925 -0
- package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
- package/src/qingflow_mcp/tools/auth_tools.py +875 -0
- package/src/qingflow_mcp/tools/base.py +388 -0
- package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
- package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
- package/src/qingflow_mcp/tools/directory_tools.py +675 -0
- package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
- package/src/qingflow_mcp/tools/file_tools.py +409 -0
- package/src/qingflow_mcp/tools/import_tools.py +2189 -0
- package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
- package/src/qingflow_mcp/tools/package_tools.py +326 -0
- package/src/qingflow_mcp/tools/portal_tools.py +158 -0
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
- package/src/qingflow_mcp/tools/record_tools.py +14037 -0
- package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
- package/src/qingflow_mcp/tools/resource_read_tools.py +421 -0
- package/src/qingflow_mcp/tools/role_tools.py +112 -0
- package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
- package/src/qingflow_mcp/tools/task_context_tools.py +2228 -0
- package/src/qingflow_mcp/tools/task_tools.py +890 -0
- package/src/qingflow_mcp/tools/view_tools.py +335 -0
- package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
- package/src/qingflow_mcp/tools/workspace_tools.py +125 -0
|
@@ -0,0 +1,2228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from mcp.server.fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from ..backend_client import BackendRequestContext
|
|
10
|
+
from ..config import DEFAULT_PROFILE
|
|
11
|
+
from ..errors import QingflowApiError, raise_tool_error
|
|
12
|
+
from ..json_types import JSONObject
|
|
13
|
+
from .approval_tools import ApprovalTools, _approval_page_amount, _approval_page_items, _approval_page_total
|
|
14
|
+
from .base import ToolBase, tool_cn_name
|
|
15
|
+
from .qingbi_report_tools import _qingbi_base_url
|
|
16
|
+
from .record_tools import (
|
|
17
|
+
FieldIndex,
|
|
18
|
+
LAYOUT_ONLY_QUE_TYPES,
|
|
19
|
+
SUBTABLE_QUE_TYPES,
|
|
20
|
+
RecordTools,
|
|
21
|
+
_build_answer_backed_field_index,
|
|
22
|
+
_build_applicant_hidden_linked_top_level_field_index,
|
|
23
|
+
_build_applicant_top_level_field_index,
|
|
24
|
+
_build_static_schema_linkage_payloads,
|
|
25
|
+
_canonical_value_is_empty,
|
|
26
|
+
_canonicalize_answer_value_for_compare,
|
|
27
|
+
_clone_form_field,
|
|
28
|
+
_coerce_count,
|
|
29
|
+
_collect_linked_required_field_ids,
|
|
30
|
+
_collect_option_linked_field_ids,
|
|
31
|
+
_collect_question_relations,
|
|
32
|
+
_field_ref_payload,
|
|
33
|
+
_merge_field_indexes,
|
|
34
|
+
_subtable_descendant_ids,
|
|
35
|
+
)
|
|
36
|
+
from .task_tools import TaskTools, _task_page_amount, _task_page_items, _task_page_total
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TaskContextTools(ToolBase):
|
|
40
|
+
"""任务上下文工具(中文名:任务上下文与审批执行)。
|
|
41
|
+
|
|
42
|
+
类型:任务深度上下文工具。
|
|
43
|
+
主要职责:
|
|
44
|
+
1. 聚合任务详情、候选人、关联报表与流程日志;
|
|
45
|
+
2. 执行审批动作(通过、驳回、转交等);
|
|
46
|
+
3. 为任务处理过程提供可执行上下文而非仅列表数据。
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, sessions, backend) -> None: # type: ignore[no-untyped-def]
|
|
50
|
+
"""执行内部辅助逻辑。"""
|
|
51
|
+
super().__init__(sessions, backend)
|
|
52
|
+
self._task_tools = TaskTools(sessions, backend)
|
|
53
|
+
self._approval_tools = ApprovalTools(sessions, backend)
|
|
54
|
+
self._record_tools = RecordTools(sessions, backend)
|
|
55
|
+
|
|
56
|
+
def register(self, mcp: FastMCP) -> None:
|
|
57
|
+
"""注册当前工具到 MCP 服务。"""
|
|
58
|
+
@mcp.tool()
|
|
59
|
+
def task_list(
|
|
60
|
+
profile: str = DEFAULT_PROFILE,
|
|
61
|
+
task_box: str = "todo",
|
|
62
|
+
flow_status: str = "all",
|
|
63
|
+
app_key: str | None = None,
|
|
64
|
+
workflow_node_id: int | None = None,
|
|
65
|
+
query: str | None = None,
|
|
66
|
+
page: int = 1,
|
|
67
|
+
page_size: int = 20,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
return self.task_list(
|
|
70
|
+
profile=profile,
|
|
71
|
+
task_box=task_box,
|
|
72
|
+
flow_status=flow_status,
|
|
73
|
+
app_key=app_key,
|
|
74
|
+
workflow_node_id=workflow_node_id,
|
|
75
|
+
query=query,
|
|
76
|
+
page=page,
|
|
77
|
+
page_size=page_size,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
def task_get(
|
|
82
|
+
profile: str = DEFAULT_PROFILE,
|
|
83
|
+
app_key: str = "",
|
|
84
|
+
record_id: int = 0,
|
|
85
|
+
workflow_node_id: int = 0,
|
|
86
|
+
include_candidates: bool = True,
|
|
87
|
+
include_associated_reports: bool = True,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
return self.task_get(
|
|
90
|
+
profile=profile,
|
|
91
|
+
app_key=app_key,
|
|
92
|
+
record_id=record_id,
|
|
93
|
+
workflow_node_id=workflow_node_id,
|
|
94
|
+
include_candidates=include_candidates,
|
|
95
|
+
include_associated_reports=include_associated_reports,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
@mcp.tool(description=self._high_risk_tool_description(operation="execute", target="workflow task action"))
|
|
99
|
+
def task_action_execute(
|
|
100
|
+
profile: str = DEFAULT_PROFILE,
|
|
101
|
+
app_key: str = "",
|
|
102
|
+
record_id: int = 0,
|
|
103
|
+
workflow_node_id: int = 0,
|
|
104
|
+
action: str = "",
|
|
105
|
+
payload: dict[str, Any] | None = None,
|
|
106
|
+
fields: dict[str, Any] | None = None,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
return self.task_action_execute(
|
|
109
|
+
profile=profile,
|
|
110
|
+
app_key=app_key,
|
|
111
|
+
record_id=record_id,
|
|
112
|
+
workflow_node_id=workflow_node_id,
|
|
113
|
+
action=action,
|
|
114
|
+
payload=payload or {},
|
|
115
|
+
fields=fields or {},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@mcp.tool()
|
|
119
|
+
def task_associated_report_detail_get(
|
|
120
|
+
profile: str = DEFAULT_PROFILE,
|
|
121
|
+
app_key: str = "",
|
|
122
|
+
record_id: int = 0,
|
|
123
|
+
workflow_node_id: int = 0,
|
|
124
|
+
report_id: int = 0,
|
|
125
|
+
page: int = 1,
|
|
126
|
+
page_size: int = 20,
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
return self.task_associated_report_detail_get(
|
|
129
|
+
profile=profile,
|
|
130
|
+
app_key=app_key,
|
|
131
|
+
record_id=record_id,
|
|
132
|
+
workflow_node_id=workflow_node_id,
|
|
133
|
+
report_id=report_id,
|
|
134
|
+
page=page,
|
|
135
|
+
page_size=page_size,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@mcp.tool()
|
|
139
|
+
def task_workflow_log_get(
|
|
140
|
+
profile: str = DEFAULT_PROFILE,
|
|
141
|
+
app_key: str = "",
|
|
142
|
+
record_id: int = 0,
|
|
143
|
+
workflow_node_id: int = 0,
|
|
144
|
+
) -> dict[str, Any]:
|
|
145
|
+
return self.task_workflow_log_get(
|
|
146
|
+
profile=profile,
|
|
147
|
+
app_key=app_key,
|
|
148
|
+
record_id=record_id,
|
|
149
|
+
workflow_node_id=workflow_node_id,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@tool_cn_name("任务上下文列表")
|
|
153
|
+
def task_list(
|
|
154
|
+
self,
|
|
155
|
+
*,
|
|
156
|
+
profile: str,
|
|
157
|
+
task_box: str,
|
|
158
|
+
flow_status: str,
|
|
159
|
+
app_key: str | None,
|
|
160
|
+
workflow_node_id: int | None,
|
|
161
|
+
query: str | None,
|
|
162
|
+
page: int,
|
|
163
|
+
page_size: int,
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
"""执行任务相关逻辑。"""
|
|
166
|
+
normalized_type = self._task_tools._task_box_to_type(task_box)
|
|
167
|
+
normalized_status = self._task_tools._flow_status_to_process_status(flow_status)
|
|
168
|
+
raw = self._task_tools.task_list(
|
|
169
|
+
profile=profile,
|
|
170
|
+
type=normalized_type,
|
|
171
|
+
process_status=normalized_status,
|
|
172
|
+
app_key=app_key,
|
|
173
|
+
node_id=workflow_node_id,
|
|
174
|
+
search_key=query,
|
|
175
|
+
page_num=page,
|
|
176
|
+
page_size=page_size,
|
|
177
|
+
create_time_asc=None,
|
|
178
|
+
)
|
|
179
|
+
task_page = raw.get("page", {})
|
|
180
|
+
items = [
|
|
181
|
+
self._normalize_task_item(item, task_box=task_box, flow_status=flow_status)
|
|
182
|
+
for item in _task_page_items(task_page)
|
|
183
|
+
if isinstance(item, dict)
|
|
184
|
+
]
|
|
185
|
+
return {
|
|
186
|
+
"profile": profile,
|
|
187
|
+
"ws_id": raw.get("ws_id"),
|
|
188
|
+
"ok": True,
|
|
189
|
+
"request_route": raw.get("request_route"),
|
|
190
|
+
"warnings": [],
|
|
191
|
+
"output_profile": "normal",
|
|
192
|
+
"data": {
|
|
193
|
+
"items": items,
|
|
194
|
+
"pagination": {
|
|
195
|
+
"page": page,
|
|
196
|
+
"page_size": page_size,
|
|
197
|
+
"returned_items": len(items),
|
|
198
|
+
"page_amount": _task_page_amount(task_page),
|
|
199
|
+
"reported_total": _task_page_total(task_page),
|
|
200
|
+
},
|
|
201
|
+
"selection": {
|
|
202
|
+
"task_box": task_box,
|
|
203
|
+
"flow_status": flow_status,
|
|
204
|
+
"app_key": app_key,
|
|
205
|
+
"workflow_node_id": workflow_node_id,
|
|
206
|
+
"query": query,
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
@tool_cn_name("任务上下文详情")
|
|
212
|
+
def task_get(
|
|
213
|
+
self,
|
|
214
|
+
*,
|
|
215
|
+
profile: str,
|
|
216
|
+
app_key: str,
|
|
217
|
+
record_id: int,
|
|
218
|
+
workflow_node_id: int,
|
|
219
|
+
include_candidates: bool,
|
|
220
|
+
include_associated_reports: bool,
|
|
221
|
+
) -> dict[str, Any]:
|
|
222
|
+
"""执行任务相关逻辑。"""
|
|
223
|
+
self._require_app_record_and_node(app_key, record_id, workflow_node_id)
|
|
224
|
+
|
|
225
|
+
def runner(session_profile, context):
|
|
226
|
+
data = self._build_task_context(
|
|
227
|
+
profile=profile,
|
|
228
|
+
context=context,
|
|
229
|
+
app_key=app_key,
|
|
230
|
+
record_id=record_id,
|
|
231
|
+
workflow_node_id=workflow_node_id,
|
|
232
|
+
include_candidates=include_candidates,
|
|
233
|
+
include_associated_reports=include_associated_reports,
|
|
234
|
+
current_uid=session_profile.uid,
|
|
235
|
+
)
|
|
236
|
+
return {
|
|
237
|
+
"profile": profile,
|
|
238
|
+
"ws_id": session_profile.selected_ws_id,
|
|
239
|
+
"ok": True,
|
|
240
|
+
"request_route": self._request_route_payload(context),
|
|
241
|
+
"warnings": [],
|
|
242
|
+
"output_profile": "normal",
|
|
243
|
+
"data": data,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return self._run(profile, runner)
|
|
247
|
+
|
|
248
|
+
@tool_cn_name("任务仅保存")
|
|
249
|
+
def task_save_only(
|
|
250
|
+
self,
|
|
251
|
+
*,
|
|
252
|
+
profile: str,
|
|
253
|
+
app_key: str,
|
|
254
|
+
record_id: int,
|
|
255
|
+
workflow_node_id: int,
|
|
256
|
+
fields: dict[str, Any] | None = None,
|
|
257
|
+
) -> dict[str, Any]:
|
|
258
|
+
"""执行任务相关逻辑。"""
|
|
259
|
+
field_updates = dict(fields or {})
|
|
260
|
+
if not field_updates:
|
|
261
|
+
raise_tool_error(QingflowApiError.config_error("fields is required and must be non-empty for task_save_only"))
|
|
262
|
+
return self.task_action_execute(
|
|
263
|
+
profile=profile,
|
|
264
|
+
app_key=app_key,
|
|
265
|
+
record_id=record_id,
|
|
266
|
+
workflow_node_id=workflow_node_id,
|
|
267
|
+
action="save_only",
|
|
268
|
+
payload={},
|
|
269
|
+
fields=field_updates,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
@tool_cn_name("执行任务动作")
|
|
273
|
+
def task_action_execute(
|
|
274
|
+
self,
|
|
275
|
+
*,
|
|
276
|
+
profile: str,
|
|
277
|
+
app_key: str,
|
|
278
|
+
record_id: int,
|
|
279
|
+
workflow_node_id: int,
|
|
280
|
+
action: str,
|
|
281
|
+
payload: dict[str, Any],
|
|
282
|
+
fields: dict[str, Any] | None = None,
|
|
283
|
+
) -> dict[str, Any]:
|
|
284
|
+
"""执行任务相关逻辑。"""
|
|
285
|
+
self._require_app_record_and_node(app_key, record_id, workflow_node_id)
|
|
286
|
+
normalized_action = (action or "").strip().lower()
|
|
287
|
+
if normalized_action not in {"approve", "reject", "rollback", "transfer", "urge", "save_only"}:
|
|
288
|
+
raise_tool_error(
|
|
289
|
+
QingflowApiError.not_supported(
|
|
290
|
+
"TASK_ACTION_UNSUPPORTED: action must be one of approve, reject, rollback, transfer, urge, or save_only"
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
body = dict(payload or {})
|
|
294
|
+
field_updates = dict(fields or {})
|
|
295
|
+
if field_updates and body.get("answers") is not None:
|
|
296
|
+
raise_tool_error(
|
|
297
|
+
QingflowApiError.config_error(
|
|
298
|
+
"task actions must not provide payload.answers and fields at the same time; pass field changes through fields only"
|
|
299
|
+
)
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def runner(session_profile, context):
|
|
303
|
+
try:
|
|
304
|
+
task_context = self._build_task_context(
|
|
305
|
+
profile=profile,
|
|
306
|
+
context=context,
|
|
307
|
+
app_key=app_key,
|
|
308
|
+
record_id=record_id,
|
|
309
|
+
workflow_node_id=workflow_node_id,
|
|
310
|
+
include_candidates=False,
|
|
311
|
+
include_associated_reports=False,
|
|
312
|
+
current_uid=session_profile.uid,
|
|
313
|
+
)
|
|
314
|
+
except QingflowApiError as error:
|
|
315
|
+
if error.backend_code == 46001:
|
|
316
|
+
return self._task_action_visibility_unverified_response(
|
|
317
|
+
profile=profile,
|
|
318
|
+
session_profile=session_profile,
|
|
319
|
+
context=context,
|
|
320
|
+
app_key=app_key,
|
|
321
|
+
record_id=record_id,
|
|
322
|
+
workflow_node_id=workflow_node_id,
|
|
323
|
+
action=normalized_action,
|
|
324
|
+
source_error=error,
|
|
325
|
+
before_apply_status=None,
|
|
326
|
+
)
|
|
327
|
+
raise
|
|
328
|
+
if normalized_action == "save_only" and not field_updates:
|
|
329
|
+
raise_tool_error(
|
|
330
|
+
QingflowApiError.config_error("fields is required and must be non-empty for action 'save_only'")
|
|
331
|
+
)
|
|
332
|
+
if normalized_action == "transfer":
|
|
333
|
+
target_member_id = self._extract_positive_int(body, "target_member_id", aliases=("uid", "targetMemberId"))
|
|
334
|
+
if target_member_id == session_profile.uid:
|
|
335
|
+
raise_tool_error(
|
|
336
|
+
QingflowApiError.config_error(
|
|
337
|
+
"task transfer does not support transferring to the current user; choose another transfer member"
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
capabilities = task_context.get("capabilities") or {}
|
|
341
|
+
available_actions = capabilities.get("available_actions") or []
|
|
342
|
+
if normalized_action not in available_actions:
|
|
343
|
+
if normalized_action == "save_only":
|
|
344
|
+
capability_warnings = capabilities.get("warnings") or []
|
|
345
|
+
message = (
|
|
346
|
+
"task action 'save_only' is not currently available for the current node; "
|
|
347
|
+
"MCP only exposes save_only when backend editableQueIds returns a non-empty result"
|
|
348
|
+
)
|
|
349
|
+
if capability_warnings:
|
|
350
|
+
message += "; backend editableQueIds is unavailable or empty for this task context"
|
|
351
|
+
raise_tool_error(QingflowApiError.config_error(message))
|
|
352
|
+
raise_tool_error(
|
|
353
|
+
QingflowApiError.config_error(
|
|
354
|
+
f"task action '{normalized_action}' is not currently available for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
feedback_required_for = capabilities.get("action_constraints", {}).get("feedback_required_for") or []
|
|
358
|
+
if normalized_action in feedback_required_for and not self._extract_audit_feedback(body):
|
|
359
|
+
raise_tool_error(
|
|
360
|
+
QingflowApiError.config_error(
|
|
361
|
+
f"payload.audit_feedback is required for action '{normalized_action}' on the current node"
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
if normalized_action == "urge" and field_updates:
|
|
365
|
+
raise_tool_error(
|
|
366
|
+
QingflowApiError.not_supported(
|
|
367
|
+
"TASK_ACTION_FIELDS_NOT_SUPPORTED: action 'urge' does not support fields because the downstream route does not accept task answers"
|
|
368
|
+
)
|
|
369
|
+
)
|
|
370
|
+
prepared_fields = None
|
|
371
|
+
if field_updates:
|
|
372
|
+
prepared_fields = self._prepare_task_field_update(
|
|
373
|
+
profile=profile,
|
|
374
|
+
context=context,
|
|
375
|
+
app_key=app_key,
|
|
376
|
+
record_id=record_id,
|
|
377
|
+
workflow_node_id=workflow_node_id,
|
|
378
|
+
task_context=task_context,
|
|
379
|
+
fields=field_updates,
|
|
380
|
+
)
|
|
381
|
+
before_apply_status = ((task_context.get("record") or {}).get("apply_status"))
|
|
382
|
+
runtime_baseline = None
|
|
383
|
+
if normalized_action != "save_only":
|
|
384
|
+
runtime_baseline = self._capture_task_runtime_baseline(
|
|
385
|
+
profile=profile,
|
|
386
|
+
context=context,
|
|
387
|
+
app_key=app_key,
|
|
388
|
+
record_id=record_id,
|
|
389
|
+
workflow_node_id=workflow_node_id,
|
|
390
|
+
)
|
|
391
|
+
try:
|
|
392
|
+
raw = self._execute_task_action(
|
|
393
|
+
profile=profile,
|
|
394
|
+
app_key=app_key,
|
|
395
|
+
record_id=record_id,
|
|
396
|
+
workflow_node_id=workflow_node_id,
|
|
397
|
+
normalized_action=normalized_action,
|
|
398
|
+
payload=body,
|
|
399
|
+
prepared_fields=prepared_fields,
|
|
400
|
+
)
|
|
401
|
+
except QingflowApiError as error:
|
|
402
|
+
if error.backend_code == 46001:
|
|
403
|
+
return self._task_action_visibility_unverified_response(
|
|
404
|
+
profile=profile,
|
|
405
|
+
session_profile=session_profile,
|
|
406
|
+
context=context,
|
|
407
|
+
app_key=app_key,
|
|
408
|
+
record_id=record_id,
|
|
409
|
+
workflow_node_id=workflow_node_id,
|
|
410
|
+
action=normalized_action,
|
|
411
|
+
source_error=error,
|
|
412
|
+
before_apply_status=before_apply_status,
|
|
413
|
+
)
|
|
414
|
+
raise
|
|
415
|
+
|
|
416
|
+
if normalized_action == "save_only":
|
|
417
|
+
verification, warnings = self._verify_task_save_only(
|
|
418
|
+
context=context,
|
|
419
|
+
app_key=app_key,
|
|
420
|
+
record_id=record_id,
|
|
421
|
+
workflow_node_id=workflow_node_id,
|
|
422
|
+
before_apply_status=before_apply_status,
|
|
423
|
+
expected_answers=((prepared_fields or {}).get("normalized_answers") or []),
|
|
424
|
+
task_context=task_context,
|
|
425
|
+
)
|
|
426
|
+
save_verified = bool(verification.get("fields_saved_verified")) and bool(verification.get("task_still_actionable"))
|
|
427
|
+
status = "success" if save_verified else "failed"
|
|
428
|
+
error_code = None if save_verified else "TASK_SAVE_ONLY_VERIFICATION_FAILED"
|
|
429
|
+
else:
|
|
430
|
+
verification, warnings = self._verify_task_action_runtime(
|
|
431
|
+
profile=profile,
|
|
432
|
+
context=context,
|
|
433
|
+
app_key=app_key,
|
|
434
|
+
record_id=record_id,
|
|
435
|
+
workflow_node_id=workflow_node_id,
|
|
436
|
+
action=normalized_action,
|
|
437
|
+
before_apply_status=before_apply_status,
|
|
438
|
+
runtime_baseline=runtime_baseline,
|
|
439
|
+
)
|
|
440
|
+
runtime_verified = bool(verification.get("runtime_continuation_verified"))
|
|
441
|
+
status = "success" if runtime_verified else "partial_success"
|
|
442
|
+
error_code = None if runtime_verified else "WORKFLOW_CONTINUATION_UNVERIFIED"
|
|
443
|
+
return {
|
|
444
|
+
"profile": raw.get("profile", profile),
|
|
445
|
+
"ws_id": raw.get("ws_id", session_profile.selected_ws_id),
|
|
446
|
+
"ok": bool(raw.get("ok", True)) and status != "failed",
|
|
447
|
+
"status": status,
|
|
448
|
+
"error_code": error_code,
|
|
449
|
+
"request_route": raw.get("request_route") or self._request_route_payload(context),
|
|
450
|
+
"warnings": warnings,
|
|
451
|
+
"verification": verification,
|
|
452
|
+
"output_profile": "normal",
|
|
453
|
+
"data": {
|
|
454
|
+
"action": normalized_action,
|
|
455
|
+
"resource": {
|
|
456
|
+
"app_key": app_key,
|
|
457
|
+
"record_id": record_id,
|
|
458
|
+
"workflow_node_id": workflow_node_id,
|
|
459
|
+
},
|
|
460
|
+
"selection": {"action": normalized_action},
|
|
461
|
+
"result": raw.get("result"),
|
|
462
|
+
"human_review": True,
|
|
463
|
+
"field_update_applied": bool(field_updates),
|
|
464
|
+
},
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return self._run(profile, runner)
|
|
468
|
+
|
|
469
|
+
def _execute_task_action(
|
|
470
|
+
self,
|
|
471
|
+
*,
|
|
472
|
+
profile: str,
|
|
473
|
+
app_key: str,
|
|
474
|
+
record_id: int,
|
|
475
|
+
workflow_node_id: int,
|
|
476
|
+
normalized_action: str,
|
|
477
|
+
payload: dict[str, Any],
|
|
478
|
+
prepared_fields: dict[str, Any] | None,
|
|
479
|
+
) -> dict[str, Any]:
|
|
480
|
+
"""执行内部辅助逻辑。"""
|
|
481
|
+
merged_answers = None
|
|
482
|
+
normalized_answers = None
|
|
483
|
+
if isinstance(prepared_fields, dict):
|
|
484
|
+
candidate_answers = prepared_fields.get("merged_answers")
|
|
485
|
+
if isinstance(candidate_answers, list):
|
|
486
|
+
merged_answers = candidate_answers
|
|
487
|
+
candidate_normalized = prepared_fields.get("normalized_answers")
|
|
488
|
+
if isinstance(candidate_normalized, list):
|
|
489
|
+
normalized_answers = candidate_normalized
|
|
490
|
+
if normalized_action == "approve":
|
|
491
|
+
action_payload = dict(payload)
|
|
492
|
+
action_payload["nodeId"] = workflow_node_id
|
|
493
|
+
if merged_answers is not None:
|
|
494
|
+
action_payload["answers"] = merged_answers
|
|
495
|
+
return self._approval_tools.record_approve(
|
|
496
|
+
profile=profile,
|
|
497
|
+
app_key=app_key,
|
|
498
|
+
apply_id=record_id,
|
|
499
|
+
payload=action_payload,
|
|
500
|
+
)
|
|
501
|
+
if normalized_action == "reject":
|
|
502
|
+
action_payload = dict(payload)
|
|
503
|
+
action_payload["nodeId"] = workflow_node_id
|
|
504
|
+
if merged_answers is not None:
|
|
505
|
+
action_payload["answers"] = merged_answers
|
|
506
|
+
if not self._extract_audit_feedback(action_payload):
|
|
507
|
+
raise_tool_error(QingflowApiError.config_error("payload.audit_feedback is required for reject"))
|
|
508
|
+
return self._approval_tools.record_reject(
|
|
509
|
+
profile=profile,
|
|
510
|
+
app_key=app_key,
|
|
511
|
+
apply_id=record_id,
|
|
512
|
+
payload=action_payload,
|
|
513
|
+
)
|
|
514
|
+
if normalized_action == "rollback":
|
|
515
|
+
target_node_id = self._extract_positive_int(payload, "target_workflow_node_id", aliases=("targetAuditNodeId", "targetWorkflowNodeId"))
|
|
516
|
+
action_payload: JSONObject = {
|
|
517
|
+
"auditNodeId": workflow_node_id,
|
|
518
|
+
"targetAuditNodeId": target_node_id,
|
|
519
|
+
}
|
|
520
|
+
audit_feedback = self._extract_audit_feedback(payload)
|
|
521
|
+
if audit_feedback:
|
|
522
|
+
action_payload["auditFeedback"] = audit_feedback
|
|
523
|
+
if merged_answers is not None:
|
|
524
|
+
action_payload["answers"] = merged_answers
|
|
525
|
+
return self._approval_tools.record_rollback(
|
|
526
|
+
profile=profile,
|
|
527
|
+
app_key=app_key,
|
|
528
|
+
apply_id=record_id,
|
|
529
|
+
payload=action_payload,
|
|
530
|
+
)
|
|
531
|
+
if normalized_action == "transfer":
|
|
532
|
+
target_member_id = self._extract_positive_int(payload, "target_member_id", aliases=("uid", "targetMemberId"))
|
|
533
|
+
action_payload = {
|
|
534
|
+
"auditNodeId": workflow_node_id,
|
|
535
|
+
"uid": target_member_id,
|
|
536
|
+
}
|
|
537
|
+
audit_feedback = self._extract_audit_feedback(payload)
|
|
538
|
+
if audit_feedback:
|
|
539
|
+
action_payload["auditFeedback"] = audit_feedback
|
|
540
|
+
if merged_answers is not None:
|
|
541
|
+
action_payload["answers"] = merged_answers
|
|
542
|
+
return self._approval_tools.record_transfer(
|
|
543
|
+
profile=profile,
|
|
544
|
+
app_key=app_key,
|
|
545
|
+
apply_id=record_id,
|
|
546
|
+
payload=action_payload,
|
|
547
|
+
)
|
|
548
|
+
if normalized_action == "save_only":
|
|
549
|
+
if normalized_answers is None:
|
|
550
|
+
raise_tool_error(QingflowApiError.config_error("fields is required for action 'save_only'"))
|
|
551
|
+
return self._task_save_only(
|
|
552
|
+
profile=profile,
|
|
553
|
+
app_key=app_key,
|
|
554
|
+
record_id=record_id,
|
|
555
|
+
workflow_node_id=workflow_node_id,
|
|
556
|
+
apply_answers=normalized_answers,
|
|
557
|
+
)
|
|
558
|
+
return self._task_tools.task_urge(
|
|
559
|
+
profile=profile,
|
|
560
|
+
app_key=app_key,
|
|
561
|
+
row_record_id=record_id,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
def _verify_task_action_runtime(
|
|
565
|
+
self,
|
|
566
|
+
*,
|
|
567
|
+
profile: str,
|
|
568
|
+
context: BackendRequestContext,
|
|
569
|
+
app_key: str,
|
|
570
|
+
record_id: int,
|
|
571
|
+
workflow_node_id: int,
|
|
572
|
+
action: str,
|
|
573
|
+
before_apply_status: Any,
|
|
574
|
+
runtime_baseline: dict[str, Any] | None = None,
|
|
575
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
576
|
+
"""执行内部辅助逻辑。"""
|
|
577
|
+
verification: dict[str, Any] = {
|
|
578
|
+
"action_executed": True,
|
|
579
|
+
"runtime_continuation_verified": action == "urge",
|
|
580
|
+
"scope": "workflow_runtime",
|
|
581
|
+
"task_context_visibility_verified": True,
|
|
582
|
+
}
|
|
583
|
+
warnings: list[dict[str, Any]] = []
|
|
584
|
+
if action == "urge":
|
|
585
|
+
return verification, warnings
|
|
586
|
+
|
|
587
|
+
state_after: dict[str, Any] | None = None
|
|
588
|
+
try:
|
|
589
|
+
state_after = self.backend.request(
|
|
590
|
+
"GET",
|
|
591
|
+
context,
|
|
592
|
+
f"/app/{app_key}/apply/{record_id}",
|
|
593
|
+
params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
|
|
594
|
+
)
|
|
595
|
+
verification["record_state_readable"] = True
|
|
596
|
+
verification["before_apply_status"] = before_apply_status
|
|
597
|
+
verification["after_apply_status"] = state_after.get("applyStatus") if isinstance(state_after, dict) else None
|
|
598
|
+
verification["record_state_changed"] = verification["after_apply_status"] != before_apply_status
|
|
599
|
+
except QingflowApiError as error:
|
|
600
|
+
verification["record_state_readable"] = False
|
|
601
|
+
verification["record_state_changed"] = False
|
|
602
|
+
verification["record_state_error"] = {
|
|
603
|
+
"http_status": error.http_status,
|
|
604
|
+
"backend_code": error.backend_code,
|
|
605
|
+
"category": error.category,
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
log_items: list[dict[str, Any]] = []
|
|
609
|
+
try:
|
|
610
|
+
log_page = self.backend.request(
|
|
611
|
+
"POST",
|
|
612
|
+
context,
|
|
613
|
+
"/application/workflow/node/record",
|
|
614
|
+
json_body={
|
|
615
|
+
"key": app_key,
|
|
616
|
+
"rowRecordId": record_id,
|
|
617
|
+
"nodeId": workflow_node_id,
|
|
618
|
+
"role": 3,
|
|
619
|
+
"pageNum": 1,
|
|
620
|
+
"pageSize": 50,
|
|
621
|
+
},
|
|
622
|
+
)
|
|
623
|
+
log_items = self._normalize_workflow_logs(log_page)
|
|
624
|
+
verification["workflow_log_visible"] = True
|
|
625
|
+
verification["workflow_log_count"] = len(log_items)
|
|
626
|
+
except QingflowApiError as error:
|
|
627
|
+
verification["workflow_log_visible"] = False
|
|
628
|
+
verification["workflow_log_count"] = None
|
|
629
|
+
verification["workflow_log_error"] = {
|
|
630
|
+
"http_status": error.http_status,
|
|
631
|
+
"backend_code": error.backend_code,
|
|
632
|
+
"category": error.category,
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
todo_items = self._safe_task_list_items(profile=profile, task_box="todo", app_key=app_key)
|
|
636
|
+
initiated_items = self._safe_task_list_items(profile=profile, task_box="initiated", app_key=app_key)
|
|
637
|
+
downstream_todo_detected = any(
|
|
638
|
+
int(item.get("record_id") or 0) == record_id and int(item.get("workflow_node_id") or 0) != workflow_node_id
|
|
639
|
+
for item in todo_items
|
|
640
|
+
if isinstance(item, dict)
|
|
641
|
+
)
|
|
642
|
+
initiated_visible = any(
|
|
643
|
+
int(item.get("record_id") or 0) == record_id
|
|
644
|
+
for item in initiated_items
|
|
645
|
+
if isinstance(item, dict)
|
|
646
|
+
)
|
|
647
|
+
verification["downstream_todo_detected"] = downstream_todo_detected
|
|
648
|
+
verification["initiated_task_visible"] = initiated_visible
|
|
649
|
+
baseline_downstream_nodes = set()
|
|
650
|
+
baseline_log_count = None
|
|
651
|
+
baseline_log_digest = None
|
|
652
|
+
if isinstance(runtime_baseline, dict):
|
|
653
|
+
baseline_downstream_nodes = set(runtime_baseline.get("downstream_todo_nodes") or [])
|
|
654
|
+
baseline_log_count = runtime_baseline.get("workflow_log_count")
|
|
655
|
+
baseline_log_digest = runtime_baseline.get("workflow_log_digest")
|
|
656
|
+
current_downstream_nodes = {
|
|
657
|
+
int(item.get("workflow_node_id") or 0)
|
|
658
|
+
for item in todo_items
|
|
659
|
+
if isinstance(item, dict)
|
|
660
|
+
and int(item.get("record_id") or 0) == record_id
|
|
661
|
+
and int(item.get("workflow_node_id") or 0) != workflow_node_id
|
|
662
|
+
}
|
|
663
|
+
workflow_log_digest = self._workflow_log_digest(log_items)
|
|
664
|
+
verification["downstream_todo_nodes"] = sorted(node_id for node_id in current_downstream_nodes if node_id > 0)
|
|
665
|
+
verification["downstream_todo_changed"] = current_downstream_nodes != baseline_downstream_nodes
|
|
666
|
+
verification["workflow_log_advanced"] = bool(
|
|
667
|
+
verification.get("workflow_log_visible")
|
|
668
|
+
and (
|
|
669
|
+
(isinstance(baseline_log_count, int) and len(log_items) > baseline_log_count)
|
|
670
|
+
or (baseline_log_digest is not None and workflow_log_digest is not None and workflow_log_digest != baseline_log_digest)
|
|
671
|
+
)
|
|
672
|
+
)
|
|
673
|
+
runtime_verified = bool(
|
|
674
|
+
verification.get("record_state_changed")
|
|
675
|
+
or verification.get("downstream_todo_changed")
|
|
676
|
+
or verification.get("workflow_log_advanced")
|
|
677
|
+
)
|
|
678
|
+
verification["runtime_continuation_verified"] = runtime_verified
|
|
679
|
+
if not runtime_verified:
|
|
680
|
+
warnings.append(
|
|
681
|
+
{
|
|
682
|
+
"code": "WORKFLOW_CONTINUATION_UNVERIFIED",
|
|
683
|
+
"message": "task action executed, but MCP could not verify downstream workflow continuation from record state, workflow logs, or downstream todo tasks.",
|
|
684
|
+
}
|
|
685
|
+
)
|
|
686
|
+
return verification, warnings
|
|
687
|
+
|
|
688
|
+
def _verify_task_save_only(
|
|
689
|
+
self,
|
|
690
|
+
*,
|
|
691
|
+
context: BackendRequestContext,
|
|
692
|
+
app_key: str,
|
|
693
|
+
record_id: int,
|
|
694
|
+
workflow_node_id: int,
|
|
695
|
+
before_apply_status: Any,
|
|
696
|
+
expected_answers: list[dict[str, Any]],
|
|
697
|
+
task_context: dict[str, Any],
|
|
698
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
699
|
+
"""执行内部辅助逻辑。"""
|
|
700
|
+
verification: dict[str, Any] = {
|
|
701
|
+
"action_executed": True,
|
|
702
|
+
"scope": "task_field_save",
|
|
703
|
+
"runtime_continuation_verified": False,
|
|
704
|
+
"task_context_visibility_verified": True,
|
|
705
|
+
"fields_saved_verified": False,
|
|
706
|
+
"task_still_actionable": False,
|
|
707
|
+
"workflow_not_advanced": False,
|
|
708
|
+
"before_apply_status": before_apply_status,
|
|
709
|
+
}
|
|
710
|
+
warnings: list[dict[str, Any]] = []
|
|
711
|
+
try:
|
|
712
|
+
detail = self.backend.request(
|
|
713
|
+
"GET",
|
|
714
|
+
context,
|
|
715
|
+
f"/app/{app_key}/apply/{record_id}",
|
|
716
|
+
params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
|
|
717
|
+
)
|
|
718
|
+
except QingflowApiError as error:
|
|
719
|
+
verification["record_state_readable"] = False
|
|
720
|
+
verification["task_context_visibility_verified"] = False
|
|
721
|
+
verification["transport_error"] = {
|
|
722
|
+
"http_status": error.http_status,
|
|
723
|
+
"backend_code": error.backend_code,
|
|
724
|
+
"category": error.category,
|
|
725
|
+
}
|
|
726
|
+
warnings.append(
|
|
727
|
+
{
|
|
728
|
+
"code": "TASK_SAVE_ONLY_UNVERIFIED",
|
|
729
|
+
"message": "save_only write was sent, but MCP could not re-read the current task context to verify that the node remains actionable and fields were saved.",
|
|
730
|
+
}
|
|
731
|
+
)
|
|
732
|
+
return verification, warnings
|
|
733
|
+
|
|
734
|
+
verification["record_state_readable"] = True
|
|
735
|
+
verification["task_still_actionable"] = True
|
|
736
|
+
after_apply_status = detail.get("applyStatus") if isinstance(detail, dict) else None
|
|
737
|
+
verification["after_apply_status"] = after_apply_status
|
|
738
|
+
verification["workflow_not_advanced"] = after_apply_status == before_apply_status
|
|
739
|
+
current_record = task_context.get("record") if isinstance(task_context.get("record"), dict) else {}
|
|
740
|
+
actual_answers = detail.get("answers") if isinstance(detail, dict) and isinstance(detail.get("answers"), list) else []
|
|
741
|
+
expected_by_id = {
|
|
742
|
+
que_id: answer
|
|
743
|
+
for answer in expected_answers
|
|
744
|
+
if isinstance(answer, dict) and (que_id := _coerce_count(answer.get("queId"))) is not None and que_id > 0
|
|
745
|
+
}
|
|
746
|
+
actual_by_id = {
|
|
747
|
+
que_id: answer
|
|
748
|
+
for answer in actual_answers
|
|
749
|
+
if isinstance(answer, dict) and (que_id := _coerce_count(answer.get("queId"))) is not None and que_id > 0
|
|
750
|
+
}
|
|
751
|
+
update_schema = task_context.get("update_schema") if isinstance(task_context.get("update_schema"), dict) else {}
|
|
752
|
+
writable_titles = {
|
|
753
|
+
item.get("title"): item
|
|
754
|
+
for item in (update_schema.get("writable_fields") or [])
|
|
755
|
+
if isinstance(item, dict) and item.get("title")
|
|
756
|
+
}
|
|
757
|
+
missing_fields: list[dict[str, Any]] = []
|
|
758
|
+
mismatched_fields: list[dict[str, Any]] = []
|
|
759
|
+
for que_id, expected in expected_by_id.items():
|
|
760
|
+
actual = actual_by_id.get(que_id)
|
|
761
|
+
title = None
|
|
762
|
+
if isinstance(current_record, dict):
|
|
763
|
+
for answer in (current_record.get("answers") or []):
|
|
764
|
+
if isinstance(answer, dict) and _coerce_count(answer.get("queId")) == que_id:
|
|
765
|
+
title = answer.get("queTitle")
|
|
766
|
+
break
|
|
767
|
+
if title is None:
|
|
768
|
+
title = next((key for key, item in writable_titles.items() if isinstance(item, dict) and item.get("field_id") == que_id), None)
|
|
769
|
+
field_payload = {"que_id": que_id, "que_title": title}
|
|
770
|
+
if actual is None:
|
|
771
|
+
missing_fields.append(field_payload)
|
|
772
|
+
continue
|
|
773
|
+
expected_value = _canonicalize_answer_value_for_compare(expected, None)
|
|
774
|
+
actual_value = _canonicalize_answer_value_for_compare(actual, None)
|
|
775
|
+
if _canonical_value_is_empty(expected_value):
|
|
776
|
+
continue
|
|
777
|
+
if actual_value != expected_value:
|
|
778
|
+
mismatched_fields.append(
|
|
779
|
+
{
|
|
780
|
+
**field_payload,
|
|
781
|
+
"expected": expected_value,
|
|
782
|
+
"actual": actual_value,
|
|
783
|
+
}
|
|
784
|
+
)
|
|
785
|
+
verification["missing_fields"] = missing_fields
|
|
786
|
+
verification["mismatched_fields"] = mismatched_fields
|
|
787
|
+
verification["fields_saved_verified"] = not missing_fields and not mismatched_fields
|
|
788
|
+
if not verification["workflow_not_advanced"]:
|
|
789
|
+
warnings.append(
|
|
790
|
+
{
|
|
791
|
+
"code": "TASK_SAVE_ONLY_ADVANCED_WORKFLOW",
|
|
792
|
+
"message": "save_only unexpectedly changed the workflow runtime state; the task should have remained on the current node.",
|
|
793
|
+
}
|
|
794
|
+
)
|
|
795
|
+
if not verification["fields_saved_verified"]:
|
|
796
|
+
warnings.append(
|
|
797
|
+
{
|
|
798
|
+
"code": "TASK_SAVE_ONLY_FIELD_VERIFICATION_FAILED",
|
|
799
|
+
"message": "save_only completed, but MCP could not verify that all requested field changes were persisted on the current task node.",
|
|
800
|
+
}
|
|
801
|
+
)
|
|
802
|
+
return verification, warnings
|
|
803
|
+
|
|
804
|
+
def _capture_task_runtime_baseline(
|
|
805
|
+
self,
|
|
806
|
+
*,
|
|
807
|
+
profile: str,
|
|
808
|
+
context: BackendRequestContext,
|
|
809
|
+
app_key: str,
|
|
810
|
+
record_id: int,
|
|
811
|
+
workflow_node_id: int,
|
|
812
|
+
) -> dict[str, Any]:
|
|
813
|
+
"""执行内部辅助逻辑。"""
|
|
814
|
+
baseline: dict[str, Any] = {
|
|
815
|
+
"workflow_log_visible": False,
|
|
816
|
+
"workflow_log_count": None,
|
|
817
|
+
"workflow_log_digest": None,
|
|
818
|
+
"downstream_todo_nodes": [],
|
|
819
|
+
}
|
|
820
|
+
try:
|
|
821
|
+
log_page = self.backend.request(
|
|
822
|
+
"POST",
|
|
823
|
+
context,
|
|
824
|
+
"/application/workflow/node/record",
|
|
825
|
+
json_body={
|
|
826
|
+
"key": app_key,
|
|
827
|
+
"rowRecordId": record_id,
|
|
828
|
+
"nodeId": workflow_node_id,
|
|
829
|
+
"role": 3,
|
|
830
|
+
"pageNum": 1,
|
|
831
|
+
"pageSize": 50,
|
|
832
|
+
},
|
|
833
|
+
)
|
|
834
|
+
log_items = self._normalize_workflow_logs(log_page)
|
|
835
|
+
baseline["workflow_log_visible"] = True
|
|
836
|
+
baseline["workflow_log_count"] = len(log_items)
|
|
837
|
+
baseline["workflow_log_digest"] = self._workflow_log_digest(log_items)
|
|
838
|
+
except QingflowApiError:
|
|
839
|
+
pass
|
|
840
|
+
todo_items = self._safe_task_list_items(profile=profile, task_box="todo", app_key=app_key)
|
|
841
|
+
baseline["downstream_todo_nodes"] = sorted(
|
|
842
|
+
{
|
|
843
|
+
int(item.get("workflow_node_id") or 0)
|
|
844
|
+
for item in todo_items
|
|
845
|
+
if isinstance(item, dict)
|
|
846
|
+
and int(item.get("record_id") or 0) == record_id
|
|
847
|
+
and int(item.get("workflow_node_id") or 0) != workflow_node_id
|
|
848
|
+
}
|
|
849
|
+
)
|
|
850
|
+
return baseline
|
|
851
|
+
|
|
852
|
+
def _task_action_visibility_unverified_response(
|
|
853
|
+
self,
|
|
854
|
+
*,
|
|
855
|
+
profile: str,
|
|
856
|
+
session_profile,
|
|
857
|
+
context: BackendRequestContext,
|
|
858
|
+
app_key: str,
|
|
859
|
+
record_id: int,
|
|
860
|
+
workflow_node_id: int,
|
|
861
|
+
action: str,
|
|
862
|
+
source_error: QingflowApiError,
|
|
863
|
+
before_apply_status: Any,
|
|
864
|
+
) -> dict[str, Any]:
|
|
865
|
+
"""执行内部辅助逻辑。"""
|
|
866
|
+
verification, warnings = self._verify_task_action_runtime(
|
|
867
|
+
profile=profile,
|
|
868
|
+
context=context,
|
|
869
|
+
app_key=app_key,
|
|
870
|
+
record_id=record_id,
|
|
871
|
+
workflow_node_id=workflow_node_id,
|
|
872
|
+
action=action,
|
|
873
|
+
before_apply_status=before_apply_status,
|
|
874
|
+
)
|
|
875
|
+
verification["action_executed"] = False
|
|
876
|
+
verification["task_context_visibility_verified"] = bool(verification.get("runtime_continuation_verified"))
|
|
877
|
+
if verification["task_context_visibility_verified"]:
|
|
878
|
+
warnings.append(
|
|
879
|
+
{
|
|
880
|
+
"code": "TASK_ALREADY_PROCESSED_UNCONFIRMED_ACTOR",
|
|
881
|
+
"message": "the task is no longer actionable in the current context; MCP found downstream workflow evidence and treats it as already processed by another actor.",
|
|
882
|
+
}
|
|
883
|
+
)
|
|
884
|
+
return {
|
|
885
|
+
"profile": profile,
|
|
886
|
+
"ws_id": session_profile.selected_ws_id,
|
|
887
|
+
"ok": True,
|
|
888
|
+
"status": "partial_success",
|
|
889
|
+
"error_code": "TASK_ALREADY_PROCESSED",
|
|
890
|
+
"request_route": self._request_route_payload(context),
|
|
891
|
+
"warnings": warnings,
|
|
892
|
+
"verification": verification,
|
|
893
|
+
"output_profile": "normal",
|
|
894
|
+
"data": {
|
|
895
|
+
"action": action,
|
|
896
|
+
"resource": {
|
|
897
|
+
"app_key": app_key,
|
|
898
|
+
"record_id": record_id,
|
|
899
|
+
"workflow_node_id": workflow_node_id,
|
|
900
|
+
},
|
|
901
|
+
"selection": {"action": action},
|
|
902
|
+
"result": None,
|
|
903
|
+
"human_review": True,
|
|
904
|
+
},
|
|
905
|
+
}
|
|
906
|
+
warnings.append(
|
|
907
|
+
{
|
|
908
|
+
"code": "TASK_CONTEXT_VISIBILITY_UNVERIFIED",
|
|
909
|
+
"message": "the task is no longer actionable, and MCP could not verify from state or workflow logs whether it was already processed.",
|
|
910
|
+
}
|
|
911
|
+
)
|
|
912
|
+
return {
|
|
913
|
+
"profile": profile,
|
|
914
|
+
"ws_id": session_profile.selected_ws_id,
|
|
915
|
+
"ok": False,
|
|
916
|
+
"status": "failed",
|
|
917
|
+
"error_code": "TASK_CONTEXT_VISIBILITY_UNVERIFIED",
|
|
918
|
+
"request_route": self._request_route_payload(context),
|
|
919
|
+
"warnings": warnings,
|
|
920
|
+
"verification": verification,
|
|
921
|
+
"output_profile": "normal",
|
|
922
|
+
"data": {
|
|
923
|
+
"action": action,
|
|
924
|
+
"resource": {
|
|
925
|
+
"app_key": app_key,
|
|
926
|
+
"record_id": record_id,
|
|
927
|
+
"workflow_node_id": workflow_node_id,
|
|
928
|
+
},
|
|
929
|
+
"selection": {"action": action},
|
|
930
|
+
"result": None,
|
|
931
|
+
"human_review": True,
|
|
932
|
+
"transport_error": {
|
|
933
|
+
"http_status": source_error.http_status,
|
|
934
|
+
"backend_code": source_error.backend_code,
|
|
935
|
+
"category": source_error.category,
|
|
936
|
+
},
|
|
937
|
+
},
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
def _safe_task_list_items(self, *, profile: str, task_box: str, app_key: str) -> list[dict[str, Any]]:
|
|
941
|
+
"""执行内部辅助逻辑。"""
|
|
942
|
+
try:
|
|
943
|
+
response = self.task_list(
|
|
944
|
+
profile=profile,
|
|
945
|
+
task_box=task_box,
|
|
946
|
+
flow_status="all",
|
|
947
|
+
app_key=app_key,
|
|
948
|
+
workflow_node_id=None,
|
|
949
|
+
query=None,
|
|
950
|
+
page=1,
|
|
951
|
+
page_size=50,
|
|
952
|
+
)
|
|
953
|
+
except QingflowApiError:
|
|
954
|
+
return []
|
|
955
|
+
data = response.get("data") if isinstance(response, dict) else None
|
|
956
|
+
items = data.get("items") if isinstance(data, dict) else None
|
|
957
|
+
if not isinstance(items, list):
|
|
958
|
+
return []
|
|
959
|
+
return [item for item in items if isinstance(item, dict)]
|
|
960
|
+
|
|
961
|
+
@tool_cn_name("任务关联报表详情")
|
|
962
|
+
def task_associated_report_detail_get(
|
|
963
|
+
self,
|
|
964
|
+
*,
|
|
965
|
+
profile: str,
|
|
966
|
+
app_key: str,
|
|
967
|
+
record_id: int,
|
|
968
|
+
workflow_node_id: int,
|
|
969
|
+
report_id: int,
|
|
970
|
+
page: int,
|
|
971
|
+
page_size: int,
|
|
972
|
+
) -> dict[str, Any]:
|
|
973
|
+
"""执行任务相关逻辑。"""
|
|
974
|
+
self._require_app_record_and_node(app_key, record_id, workflow_node_id)
|
|
975
|
+
if report_id <= 0:
|
|
976
|
+
raise_tool_error(QingflowApiError.config_error("report_id must be positive"))
|
|
977
|
+
if page <= 0 or page_size <= 0:
|
|
978
|
+
raise_tool_error(QingflowApiError.config_error("page and page_size must be positive"))
|
|
979
|
+
|
|
980
|
+
def runner(session_profile, context):
|
|
981
|
+
task_context = self._build_task_context(
|
|
982
|
+
profile=profile,
|
|
983
|
+
context=context,
|
|
984
|
+
app_key=app_key,
|
|
985
|
+
record_id=record_id,
|
|
986
|
+
workflow_node_id=workflow_node_id,
|
|
987
|
+
include_candidates=False,
|
|
988
|
+
include_associated_reports=True,
|
|
989
|
+
current_uid=session_profile.uid,
|
|
990
|
+
)
|
|
991
|
+
report_item = self._find_associated_report(task_context, report_id)
|
|
992
|
+
if report_item is None:
|
|
993
|
+
raise_tool_error(
|
|
994
|
+
QingflowApiError.config_error(
|
|
995
|
+
f"report_id={report_id} is not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
|
|
996
|
+
)
|
|
997
|
+
)
|
|
998
|
+
association_query = self._build_association_query(
|
|
999
|
+
report_item["raw"],
|
|
1000
|
+
task_context.get("record", {}).get("answers") or [],
|
|
1001
|
+
)
|
|
1002
|
+
selection = {
|
|
1003
|
+
"app_key": app_key,
|
|
1004
|
+
"record_id": record_id,
|
|
1005
|
+
"workflow_node_id": workflow_node_id,
|
|
1006
|
+
"report_id": report_id,
|
|
1007
|
+
"target_app_key": report_item.get("target_app_key"),
|
|
1008
|
+
"target_app_name": report_item.get("target_app_name"),
|
|
1009
|
+
"chart_key": report_item.get("chart_key"),
|
|
1010
|
+
"chart_name": report_item.get("chart_name"),
|
|
1011
|
+
}
|
|
1012
|
+
context_payload = {
|
|
1013
|
+
"match_rules": report_item.get("match_rules") or [],
|
|
1014
|
+
"resolved_filters": association_query.get("keyQueValues") or [],
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if report_item.get("graph_type") == "view":
|
|
1018
|
+
viewgraph_key = str(report_item.get("chart_key") or "")
|
|
1019
|
+
body = {
|
|
1020
|
+
"filter": {},
|
|
1021
|
+
"viewgraphKey": viewgraph_key,
|
|
1022
|
+
"equipmentType": 0,
|
|
1023
|
+
"associationQuery": association_query,
|
|
1024
|
+
}
|
|
1025
|
+
result = self.backend.request(
|
|
1026
|
+
"POST",
|
|
1027
|
+
context,
|
|
1028
|
+
f"/view/{viewgraph_key}/apply/filter",
|
|
1029
|
+
json_body=body,
|
|
1030
|
+
)
|
|
1031
|
+
items = _task_page_items(result)
|
|
1032
|
+
return {
|
|
1033
|
+
"profile": profile,
|
|
1034
|
+
"ws_id": session_profile.selected_ws_id,
|
|
1035
|
+
"ok": True,
|
|
1036
|
+
"request_route": self._request_route_payload(context),
|
|
1037
|
+
"warnings": [],
|
|
1038
|
+
"output_profile": "normal",
|
|
1039
|
+
"data": {
|
|
1040
|
+
"result_type": "view_list",
|
|
1041
|
+
"result": {
|
|
1042
|
+
"items": items,
|
|
1043
|
+
"pagination": {
|
|
1044
|
+
"page": page,
|
|
1045
|
+
"page_size": page_size,
|
|
1046
|
+
"returned_items": len(items),
|
|
1047
|
+
"page_amount": _task_page_amount(result),
|
|
1048
|
+
"reported_total": _task_page_total(result),
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
1051
|
+
"selection": selection,
|
|
1052
|
+
"context": context_payload,
|
|
1053
|
+
},
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
chart_key = str(report_item.get("chart_key") or "")
|
|
1057
|
+
source_type = report_item.get("source_type")
|
|
1058
|
+
if source_type == "qingbi":
|
|
1059
|
+
qingbi_context = BackendRequestContext(
|
|
1060
|
+
base_url=_qingbi_base_url(context.base_url),
|
|
1061
|
+
token=context.token,
|
|
1062
|
+
ws_id=context.ws_id,
|
|
1063
|
+
qf_request_id=context.qf_request_id,
|
|
1064
|
+
qf_version=context.qf_version,
|
|
1065
|
+
qf_version_source=context.qf_version_source,
|
|
1066
|
+
)
|
|
1067
|
+
chart_result = self.backend.request(
|
|
1068
|
+
"POST",
|
|
1069
|
+
qingbi_context,
|
|
1070
|
+
f"/qingbi/charts/data/{chart_key}",
|
|
1071
|
+
params={
|
|
1072
|
+
"qfUUID": uuid4().hex,
|
|
1073
|
+
"pageNum": page,
|
|
1074
|
+
"pageSize": page_size,
|
|
1075
|
+
},
|
|
1076
|
+
json_body={
|
|
1077
|
+
"asosChartId": report_id,
|
|
1078
|
+
"keyQueValues": association_query.get("keyQueValues") or [],
|
|
1079
|
+
},
|
|
1080
|
+
)
|
|
1081
|
+
request_route = {
|
|
1082
|
+
"base_url": qingbi_context.base_url,
|
|
1083
|
+
"qf_version": qingbi_context.qf_version,
|
|
1084
|
+
"qf_version_source": qingbi_context.qf_version_source or "context",
|
|
1085
|
+
}
|
|
1086
|
+
elif self._qingflow_chart_uses_apply_filter(context, chart_key):
|
|
1087
|
+
chart_result = self.backend.request(
|
|
1088
|
+
"POST",
|
|
1089
|
+
context,
|
|
1090
|
+
f"/chart/{chart_key}/apply/filter",
|
|
1091
|
+
json_body={
|
|
1092
|
+
"filter": {
|
|
1093
|
+
"pageNum": page,
|
|
1094
|
+
"pageSize": page_size,
|
|
1095
|
+
},
|
|
1096
|
+
"asosChartId": report_id,
|
|
1097
|
+
"keyQueValues": association_query.get("keyQueValues") or [],
|
|
1098
|
+
},
|
|
1099
|
+
)
|
|
1100
|
+
items = _task_page_items(chart_result)
|
|
1101
|
+
return {
|
|
1102
|
+
"profile": profile,
|
|
1103
|
+
"ws_id": session_profile.selected_ws_id,
|
|
1104
|
+
"ok": True,
|
|
1105
|
+
"request_route": self._request_route_payload(context),
|
|
1106
|
+
"warnings": [],
|
|
1107
|
+
"output_profile": "normal",
|
|
1108
|
+
"data": {
|
|
1109
|
+
"result_type": "view_list",
|
|
1110
|
+
"result": {
|
|
1111
|
+
"items": items,
|
|
1112
|
+
"pagination": {
|
|
1113
|
+
"page": page,
|
|
1114
|
+
"page_size": page_size,
|
|
1115
|
+
"returned_items": len(items),
|
|
1116
|
+
"page_amount": _task_page_amount(chart_result),
|
|
1117
|
+
"reported_total": _task_page_total(chart_result),
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
1120
|
+
"selection": selection,
|
|
1121
|
+
"context": context_payload,
|
|
1122
|
+
},
|
|
1123
|
+
}
|
|
1124
|
+
else:
|
|
1125
|
+
chart_result = self.backend.request(
|
|
1126
|
+
"POST",
|
|
1127
|
+
context,
|
|
1128
|
+
f"/chart/{chart_key}/chartData",
|
|
1129
|
+
json_body={
|
|
1130
|
+
"asosChartId": report_id,
|
|
1131
|
+
"keyQueValues": association_query.get("keyQueValues") or [],
|
|
1132
|
+
},
|
|
1133
|
+
)
|
|
1134
|
+
request_route = self._request_route_payload(context)
|
|
1135
|
+
|
|
1136
|
+
return {
|
|
1137
|
+
"profile": profile,
|
|
1138
|
+
"ws_id": session_profile.selected_ws_id,
|
|
1139
|
+
"ok": True,
|
|
1140
|
+
"request_route": request_route,
|
|
1141
|
+
"warnings": [],
|
|
1142
|
+
"output_profile": "normal",
|
|
1143
|
+
"data": {
|
|
1144
|
+
"result_type": "chart_data",
|
|
1145
|
+
"result": self._normalize_chart_result(chart_result),
|
|
1146
|
+
"selection": selection,
|
|
1147
|
+
"context": context_payload,
|
|
1148
|
+
},
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
return self._run(profile, runner)
|
|
1152
|
+
|
|
1153
|
+
@tool_cn_name("任务流程日志")
|
|
1154
|
+
def task_workflow_log_get(
|
|
1155
|
+
self,
|
|
1156
|
+
*,
|
|
1157
|
+
profile: str,
|
|
1158
|
+
app_key: str,
|
|
1159
|
+
record_id: int,
|
|
1160
|
+
workflow_node_id: int,
|
|
1161
|
+
) -> dict[str, Any]:
|
|
1162
|
+
"""执行任务相关逻辑。"""
|
|
1163
|
+
self._require_app_record_and_node(app_key, record_id, workflow_node_id)
|
|
1164
|
+
|
|
1165
|
+
def runner(session_profile, context):
|
|
1166
|
+
task_context = self._build_task_context(
|
|
1167
|
+
profile=profile,
|
|
1168
|
+
context=context,
|
|
1169
|
+
app_key=app_key,
|
|
1170
|
+
record_id=record_id,
|
|
1171
|
+
workflow_node_id=workflow_node_id,
|
|
1172
|
+
include_candidates=False,
|
|
1173
|
+
include_associated_reports=False,
|
|
1174
|
+
current_uid=session_profile.uid,
|
|
1175
|
+
)
|
|
1176
|
+
visibility = task_context.get("visibility") or {}
|
|
1177
|
+
if not visibility.get("audit_record_visible"):
|
|
1178
|
+
raise_tool_error(
|
|
1179
|
+
QingflowApiError.config_error(
|
|
1180
|
+
f"workflow logs are not visible for app_key='{app_key}' record_id={record_id} workflow_node_id={workflow_node_id}"
|
|
1181
|
+
)
|
|
1182
|
+
)
|
|
1183
|
+
page = self.backend.request(
|
|
1184
|
+
"POST",
|
|
1185
|
+
context,
|
|
1186
|
+
"/application/workflow/node/record",
|
|
1187
|
+
json_body={
|
|
1188
|
+
"key": app_key,
|
|
1189
|
+
"rowRecordId": record_id,
|
|
1190
|
+
"nodeId": workflow_node_id,
|
|
1191
|
+
"role": 3,
|
|
1192
|
+
"pageNum": 1,
|
|
1193
|
+
"pageSize": 200,
|
|
1194
|
+
},
|
|
1195
|
+
)
|
|
1196
|
+
items = self._normalize_workflow_logs(page)
|
|
1197
|
+
return {
|
|
1198
|
+
"profile": profile,
|
|
1199
|
+
"ws_id": session_profile.selected_ws_id,
|
|
1200
|
+
"ok": True,
|
|
1201
|
+
"request_route": self._request_route_payload(context),
|
|
1202
|
+
"warnings": [],
|
|
1203
|
+
"output_profile": "normal",
|
|
1204
|
+
"data": {
|
|
1205
|
+
"selection": {
|
|
1206
|
+
"app_key": app_key,
|
|
1207
|
+
"record_id": record_id,
|
|
1208
|
+
"workflow_node_id": workflow_node_id,
|
|
1209
|
+
},
|
|
1210
|
+
"visibility": {
|
|
1211
|
+
"audit_record_visible": visibility.get("audit_record_visible"),
|
|
1212
|
+
"qrobot_record_visible": visibility.get("qrobot_record_visible"),
|
|
1213
|
+
},
|
|
1214
|
+
"items": items,
|
|
1215
|
+
},
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
return self._run(profile, runner)
|
|
1219
|
+
|
|
1220
|
+
def _build_task_context(
|
|
1221
|
+
self,
|
|
1222
|
+
profile: str,
|
|
1223
|
+
context: BackendRequestContext,
|
|
1224
|
+
*,
|
|
1225
|
+
app_key: str,
|
|
1226
|
+
record_id: int,
|
|
1227
|
+
workflow_node_id: int,
|
|
1228
|
+
include_candidates: bool,
|
|
1229
|
+
include_associated_reports: bool,
|
|
1230
|
+
current_uid: int | None = None,
|
|
1231
|
+
) -> dict[str, Any]:
|
|
1232
|
+
"""执行内部辅助逻辑。"""
|
|
1233
|
+
audit_infos = self.backend.request(
|
|
1234
|
+
"GET",
|
|
1235
|
+
context,
|
|
1236
|
+
f"/app/{app_key}/apply/{record_id}/auditInfo",
|
|
1237
|
+
params={"type": 1},
|
|
1238
|
+
)
|
|
1239
|
+
node_info = self._select_task_node(audit_infos, workflow_node_id, app_key=app_key, record_id=record_id)
|
|
1240
|
+
detail = self.backend.request(
|
|
1241
|
+
"GET",
|
|
1242
|
+
context,
|
|
1243
|
+
f"/app/{app_key}/apply/{record_id}",
|
|
1244
|
+
params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
|
|
1245
|
+
)
|
|
1246
|
+
associated_report_visible = self._resolve_associated_report_visible(node_info, detail)
|
|
1247
|
+
associated_reports = {"visible": associated_report_visible, "count": 0, "items": []}
|
|
1248
|
+
if include_associated_reports and associated_report_visible:
|
|
1249
|
+
asos_chart_list = self.backend.request(
|
|
1250
|
+
"GET",
|
|
1251
|
+
context,
|
|
1252
|
+
f"/app/{app_key}/asosChart",
|
|
1253
|
+
params={"role": 3, "auditNodeId": workflow_node_id, "beingDraft": False},
|
|
1254
|
+
)
|
|
1255
|
+
associated_items = [
|
|
1256
|
+
self._normalize_associated_report(item)
|
|
1257
|
+
for item in (asos_chart_list.get("asosCharts") or [])
|
|
1258
|
+
if isinstance(item, dict)
|
|
1259
|
+
]
|
|
1260
|
+
associated_reports = {
|
|
1261
|
+
"visible": True,
|
|
1262
|
+
"count": len(associated_items),
|
|
1263
|
+
"items": associated_items,
|
|
1264
|
+
}
|
|
1265
|
+
rollback_items: list[dict[str, Any]] = []
|
|
1266
|
+
transfer_items: list[dict[str, Any]] = []
|
|
1267
|
+
if include_candidates:
|
|
1268
|
+
rollback_result = self.backend.request(
|
|
1269
|
+
"GET",
|
|
1270
|
+
context,
|
|
1271
|
+
f"/app/{app_key}/apply/{record_id}/revertNode",
|
|
1272
|
+
params={"auditNodeId": workflow_node_id},
|
|
1273
|
+
)
|
|
1274
|
+
rollback_items = self._rollback_candidate_items(rollback_result)
|
|
1275
|
+
transfer_result = self.backend.request(
|
|
1276
|
+
"GET",
|
|
1277
|
+
context,
|
|
1278
|
+
f"/app/{app_key}/apply/{record_id}/transfer/member",
|
|
1279
|
+
params={"pageNum": 1, "pageSize": 20, "auditNodeId": workflow_node_id},
|
|
1280
|
+
)
|
|
1281
|
+
transfer_items = self._filter_transfer_members(_approval_page_items(transfer_result), current_uid=current_uid)
|
|
1282
|
+
|
|
1283
|
+
update_schema_state = self._build_task_update_schema(
|
|
1284
|
+
profile=profile,
|
|
1285
|
+
context=context,
|
|
1286
|
+
app_key=app_key,
|
|
1287
|
+
record_id=record_id,
|
|
1288
|
+
workflow_node_id=workflow_node_id,
|
|
1289
|
+
node_info=node_info,
|
|
1290
|
+
current_answers=detail.get("answers") or [],
|
|
1291
|
+
)
|
|
1292
|
+
update_schema = update_schema_state["public_schema"]
|
|
1293
|
+
save_only_available, capability_warnings, save_only_source = self._resolve_task_save_only_availability(
|
|
1294
|
+
context,
|
|
1295
|
+
app_key=app_key,
|
|
1296
|
+
workflow_node_id=workflow_node_id,
|
|
1297
|
+
)
|
|
1298
|
+
capabilities = self._build_capabilities(
|
|
1299
|
+
node_info,
|
|
1300
|
+
allow_save_only=save_only_available,
|
|
1301
|
+
warnings=capability_warnings,
|
|
1302
|
+
save_only_source=save_only_source,
|
|
1303
|
+
)
|
|
1304
|
+
visibility = self._build_visibility(node_info, detail)
|
|
1305
|
+
return {
|
|
1306
|
+
"task": {
|
|
1307
|
+
"app_key": app_key,
|
|
1308
|
+
"record_id": record_id,
|
|
1309
|
+
"workflow_node_id": workflow_node_id,
|
|
1310
|
+
"workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
|
|
1311
|
+
"actionable": True,
|
|
1312
|
+
},
|
|
1313
|
+
"node": {
|
|
1314
|
+
"workflow_node_id": workflow_node_id,
|
|
1315
|
+
"workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
|
|
1316
|
+
"raw": dict(node_info),
|
|
1317
|
+
},
|
|
1318
|
+
"record": {
|
|
1319
|
+
"apply_id": detail.get("applyId", record_id),
|
|
1320
|
+
"apply_status": detail.get("applyStatus"),
|
|
1321
|
+
"apply_num": detail.get("applyNum"),
|
|
1322
|
+
"custom_apply_num": detail.get("customApplyNum"),
|
|
1323
|
+
"apply_user": detail.get("applyUser"),
|
|
1324
|
+
"apply_time": detail.get("applyTime"),
|
|
1325
|
+
"last_update_time": detail.get("lastUpdateTime"),
|
|
1326
|
+
"answers": detail.get("answers") or [],
|
|
1327
|
+
},
|
|
1328
|
+
"capabilities": capabilities,
|
|
1329
|
+
"field_permissions": {
|
|
1330
|
+
"que_auth_setting": node_info.get("queAuthSetting") or [],
|
|
1331
|
+
"editable_question_ids": update_schema_state["editable_question_ids"],
|
|
1332
|
+
"editable_question_ids_source": update_schema_state["editable_question_ids_source"],
|
|
1333
|
+
},
|
|
1334
|
+
"visibility": visibility,
|
|
1335
|
+
"associated_reports": associated_reports,
|
|
1336
|
+
"candidates": {
|
|
1337
|
+
"rollback_nodes": rollback_items,
|
|
1338
|
+
"transfer_members": transfer_items,
|
|
1339
|
+
},
|
|
1340
|
+
"workflow_log_summary": {
|
|
1341
|
+
"visible": visibility["audit_record_visible"],
|
|
1342
|
+
"available": visibility["audit_record_visible"],
|
|
1343
|
+
"history_count": None,
|
|
1344
|
+
"qrobot_log_visible": visibility["qrobot_record_visible"],
|
|
1345
|
+
},
|
|
1346
|
+
"update_schema": update_schema,
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
def _normalize_task_item(self, raw: dict[str, Any], *, task_box: str, flow_status: str) -> dict[str, Any]:
|
|
1350
|
+
"""执行内部辅助逻辑。"""
|
|
1351
|
+
app_key = raw.get("appKey") or raw.get("app_key")
|
|
1352
|
+
record_id = raw.get("rowRecordId") or raw.get("recordId") or raw.get("applyId")
|
|
1353
|
+
workflow_node_id = raw.get("nodeId") or raw.get("auditNodeId")
|
|
1354
|
+
apply_user = raw.get("applyUser")
|
|
1355
|
+
if apply_user is None:
|
|
1356
|
+
user_uid = raw.get("applyUserUid")
|
|
1357
|
+
user_name = raw.get("applyUserName")
|
|
1358
|
+
if user_uid is not None or user_name is not None:
|
|
1359
|
+
apply_user = {"uid": user_uid, "name": user_name}
|
|
1360
|
+
return {
|
|
1361
|
+
"task_id": raw.get("id") or raw.get("taskId") or record_id,
|
|
1362
|
+
"app_key": app_key,
|
|
1363
|
+
"app_name": raw.get("formTitle") or raw.get("worksheetName") or raw.get("appName"),
|
|
1364
|
+
"record_id": record_id,
|
|
1365
|
+
"workflow_node_id": workflow_node_id,
|
|
1366
|
+
"workflow_node_name": raw.get("nodeName") or raw.get("auditNodeName"),
|
|
1367
|
+
"title": raw.get("title") or raw.get("applyTitle") or raw.get("name") or raw.get("formTitle"),
|
|
1368
|
+
"apply_user": apply_user,
|
|
1369
|
+
"apply_time": raw.get("applyTime") or raw.get("receiveTime"),
|
|
1370
|
+
"task_box": task_box,
|
|
1371
|
+
"flow_status": flow_status,
|
|
1372
|
+
"actionable": task_box == "todo" and bool(record_id) and bool(workflow_node_id),
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
def _select_task_node(self, infos: Any, workflow_node_id: int, *, app_key: str, record_id: int) -> dict[str, Any]:
|
|
1376
|
+
"""执行内部辅助逻辑。"""
|
|
1377
|
+
if not isinstance(infos, list) or not infos:
|
|
1378
|
+
raise_tool_error(
|
|
1379
|
+
QingflowApiError.config_error(
|
|
1380
|
+
f"record_id={record_id} is not currently actionable for the logged-in user in app_key='{app_key}'"
|
|
1381
|
+
)
|
|
1382
|
+
)
|
|
1383
|
+
for item in infos:
|
|
1384
|
+
if not isinstance(item, dict):
|
|
1385
|
+
continue
|
|
1386
|
+
candidate = item.get("auditNodeId")
|
|
1387
|
+
if not isinstance(candidate, int):
|
|
1388
|
+
candidate = item.get("nodeId")
|
|
1389
|
+
if candidate == workflow_node_id:
|
|
1390
|
+
return item
|
|
1391
|
+
raise_tool_error(
|
|
1392
|
+
QingflowApiError.config_error(
|
|
1393
|
+
f"workflow_node_id={workflow_node_id} is not an actionable todo node for app_key='{app_key}' record_id={record_id}"
|
|
1394
|
+
)
|
|
1395
|
+
)
|
|
1396
|
+
|
|
1397
|
+
def _build_capabilities(
|
|
1398
|
+
self,
|
|
1399
|
+
node_info: dict[str, Any],
|
|
1400
|
+
*,
|
|
1401
|
+
allow_save_only: bool,
|
|
1402
|
+
warnings: list[JSONObject] | None = None,
|
|
1403
|
+
save_only_source: str = "workflow_editable_que_ids",
|
|
1404
|
+
) -> dict[str, Any]:
|
|
1405
|
+
"""执行内部辅助逻辑。"""
|
|
1406
|
+
available_actions = ["approve"]
|
|
1407
|
+
if self._coerce_bool(node_info.get("rejectBtnStatus")):
|
|
1408
|
+
available_actions.append("reject")
|
|
1409
|
+
if self._coerce_bool(node_info.get("canRevert")):
|
|
1410
|
+
available_actions.append("rollback")
|
|
1411
|
+
if self._coerce_bool(node_info.get("canTransfer")):
|
|
1412
|
+
available_actions.append("transfer")
|
|
1413
|
+
if self._coerce_bool(node_info.get("canUrge")):
|
|
1414
|
+
available_actions.append("urge")
|
|
1415
|
+
if allow_save_only:
|
|
1416
|
+
available_actions.append("save_only")
|
|
1417
|
+
|
|
1418
|
+
visible_but_unimplemented_actions: list[str] = []
|
|
1419
|
+
if self._coerce_bool(node_info.get("canRevoke")):
|
|
1420
|
+
visible_but_unimplemented_actions.append("revoke")
|
|
1421
|
+
if self._coerce_bool(node_info.get("beingEndWorkflow")):
|
|
1422
|
+
visible_but_unimplemented_actions.append("end_workflow")
|
|
1423
|
+
if self._coerce_bool(node_info.get("beingCanApplyAgain")):
|
|
1424
|
+
visible_but_unimplemented_actions.append("apply_again")
|
|
1425
|
+
|
|
1426
|
+
feedback_required_for = []
|
|
1427
|
+
raw_feedback_required = node_info.get("feedbackRequiredOperationType")
|
|
1428
|
+
if isinstance(raw_feedback_required, list):
|
|
1429
|
+
feedback_required_for = [str(item).strip().lower() for item in raw_feedback_required if str(item).strip()]
|
|
1430
|
+
|
|
1431
|
+
return {
|
|
1432
|
+
"available_actions": available_actions,
|
|
1433
|
+
"visible_but_unimplemented_actions": visible_but_unimplemented_actions,
|
|
1434
|
+
"save_only_source": save_only_source,
|
|
1435
|
+
"warnings": list(warnings or []),
|
|
1436
|
+
"action_constraints": {
|
|
1437
|
+
"feedback_required_for": feedback_required_for,
|
|
1438
|
+
"submit_check_enabled": self._coerce_bool(node_info.get("beingSubmitCheck")),
|
|
1439
|
+
"submit_preview_enabled": self._coerce_bool(node_info.get("beingSubmitPreview")),
|
|
1440
|
+
"can_end_workflow": self._coerce_bool(node_info.get("beingEndWorkflow")),
|
|
1441
|
+
"can_apply_again": self._coerce_bool(node_info.get("beingCanApplyAgain")),
|
|
1442
|
+
},
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
def _build_task_update_schema(
|
|
1446
|
+
self,
|
|
1447
|
+
profile: str,
|
|
1448
|
+
context: BackendRequestContext,
|
|
1449
|
+
*,
|
|
1450
|
+
app_key: str,
|
|
1451
|
+
record_id: int,
|
|
1452
|
+
workflow_node_id: int,
|
|
1453
|
+
node_info: dict[str, Any],
|
|
1454
|
+
current_answers: Any,
|
|
1455
|
+
) -> dict[str, Any]:
|
|
1456
|
+
"""执行内部辅助逻辑。"""
|
|
1457
|
+
try:
|
|
1458
|
+
app_schema = self._record_tools._get_form_schema(profile, context, app_key, force_refresh=False)
|
|
1459
|
+
except QingflowApiError as error:
|
|
1460
|
+
public_schema: JSONObject = {
|
|
1461
|
+
"schema_scope": "task_update_ready",
|
|
1462
|
+
"writable_fields": [],
|
|
1463
|
+
"payload_template": {},
|
|
1464
|
+
"blockers": ["TASK_UPDATE_SCHEMA_UNAVAILABLE"],
|
|
1465
|
+
"warnings": [
|
|
1466
|
+
{
|
|
1467
|
+
"code": "TASK_UPDATE_SCHEMA_UNAVAILABLE",
|
|
1468
|
+
"message": "task detail could not load the form schema for the current app, so node-scoped update schema is unavailable.",
|
|
1469
|
+
}
|
|
1470
|
+
],
|
|
1471
|
+
"selection": {
|
|
1472
|
+
"app_key": app_key,
|
|
1473
|
+
"record_id": record_id,
|
|
1474
|
+
"workflow_node_id": workflow_node_id,
|
|
1475
|
+
},
|
|
1476
|
+
"transport_error": {
|
|
1477
|
+
"http_status": error.http_status,
|
|
1478
|
+
"backend_code": error.backend_code,
|
|
1479
|
+
"category": error.category,
|
|
1480
|
+
},
|
|
1481
|
+
}
|
|
1482
|
+
return {
|
|
1483
|
+
"public_schema": public_schema,
|
|
1484
|
+
"index": None,
|
|
1485
|
+
"editable_question_ids": [],
|
|
1486
|
+
"effective_editable_question_ids": [],
|
|
1487
|
+
"editable_question_ids_source": "schema_unavailable",
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
question_relations = _collect_question_relations(app_schema)
|
|
1491
|
+
linked_field_ids = _collect_linked_required_field_ids(question_relations)
|
|
1492
|
+
base_index = _build_applicant_top_level_field_index(app_schema)
|
|
1493
|
+
linked_field_ids.update(_collect_option_linked_field_ids(base_index))
|
|
1494
|
+
linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
|
|
1495
|
+
app_schema,
|
|
1496
|
+
linked_field_ids=linked_field_ids,
|
|
1497
|
+
)
|
|
1498
|
+
index = _merge_field_indexes(base_index, linked_hidden_index)
|
|
1499
|
+
editable_question_ids, schema_warnings, source = self._resolve_task_editable_question_ids(
|
|
1500
|
+
context,
|
|
1501
|
+
app_key=app_key,
|
|
1502
|
+
workflow_node_id=workflow_node_id,
|
|
1503
|
+
node_info=node_info,
|
|
1504
|
+
)
|
|
1505
|
+
index, augmentation_warnings = self._augment_task_editable_field_index(
|
|
1506
|
+
index=index,
|
|
1507
|
+
current_answers=current_answers,
|
|
1508
|
+
editable_question_ids=editable_question_ids,
|
|
1509
|
+
)
|
|
1510
|
+
schema_warnings.extend(augmentation_warnings)
|
|
1511
|
+
effective_editable_ids = set(editable_question_ids)
|
|
1512
|
+
for field in index.by_id.values():
|
|
1513
|
+
if field.que_type in SUBTABLE_QUE_TYPES and (_subtable_descendant_ids(field) & set(editable_question_ids)):
|
|
1514
|
+
effective_editable_ids.add(field.que_id)
|
|
1515
|
+
writable_fields: list[JSONObject] = []
|
|
1516
|
+
linkage_payloads_by_field_id = _build_static_schema_linkage_payloads(
|
|
1517
|
+
index=index,
|
|
1518
|
+
question_relations=question_relations,
|
|
1519
|
+
)
|
|
1520
|
+
for field in index.by_id.values():
|
|
1521
|
+
if field.que_type in LAYOUT_ONLY_QUE_TYPES or field.que_id not in effective_editable_ids:
|
|
1522
|
+
continue
|
|
1523
|
+
editable_field = _clone_form_field(field, readonly=False)
|
|
1524
|
+
write_hints = self._record_tools._schema_write_hints(editable_field)
|
|
1525
|
+
if not bool(write_hints.get("writable")):
|
|
1526
|
+
continue
|
|
1527
|
+
writable_fields.append(
|
|
1528
|
+
self._record_tools._ready_schema_field_payload(
|
|
1529
|
+
profile,
|
|
1530
|
+
context,
|
|
1531
|
+
editable_field,
|
|
1532
|
+
ws_id=context.ws_id,
|
|
1533
|
+
required_override=False,
|
|
1534
|
+
linkage_payloads_by_field_id=linkage_payloads_by_field_id,
|
|
1535
|
+
)
|
|
1536
|
+
)
|
|
1537
|
+
blockers: list[str] = []
|
|
1538
|
+
if not writable_fields:
|
|
1539
|
+
blockers.append("NO_TASK_EDITABLE_FIELDS")
|
|
1540
|
+
schema_warnings.append(
|
|
1541
|
+
{
|
|
1542
|
+
"code": "NO_TASK_EDITABLE_FIELDS",
|
|
1543
|
+
"message": "the current task node does not expose any writable fields for task-scoped edits.",
|
|
1544
|
+
}
|
|
1545
|
+
)
|
|
1546
|
+
public_schema: JSONObject = {
|
|
1547
|
+
"schema_scope": "task_update_ready",
|
|
1548
|
+
"writable_fields": writable_fields,
|
|
1549
|
+
"payload_template": {
|
|
1550
|
+
item["title"]: self._record_tools._ready_schema_template_value(item)
|
|
1551
|
+
for item in writable_fields
|
|
1552
|
+
if isinstance(item, dict) and item.get("title")
|
|
1553
|
+
},
|
|
1554
|
+
"blockers": blockers,
|
|
1555
|
+
"warnings": schema_warnings,
|
|
1556
|
+
"selection": {
|
|
1557
|
+
"app_key": app_key,
|
|
1558
|
+
"record_id": record_id,
|
|
1559
|
+
"workflow_node_id": workflow_node_id,
|
|
1560
|
+
},
|
|
1561
|
+
}
|
|
1562
|
+
return {
|
|
1563
|
+
"public_schema": public_schema,
|
|
1564
|
+
"index": index,
|
|
1565
|
+
"editable_question_ids": sorted(editable_question_ids),
|
|
1566
|
+
"effective_editable_question_ids": sorted(effective_editable_ids),
|
|
1567
|
+
"editable_question_ids_source": source,
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
def _augment_task_editable_field_index(
|
|
1571
|
+
self,
|
|
1572
|
+
*,
|
|
1573
|
+
index: FieldIndex,
|
|
1574
|
+
current_answers: Any,
|
|
1575
|
+
editable_question_ids: set[int],
|
|
1576
|
+
) -> tuple[FieldIndex, list[JSONObject]]:
|
|
1577
|
+
"""执行内部辅助逻辑。"""
|
|
1578
|
+
if not editable_question_ids:
|
|
1579
|
+
return index, []
|
|
1580
|
+
missing_field_ids = {
|
|
1581
|
+
str(que_id)
|
|
1582
|
+
for que_id in editable_question_ids
|
|
1583
|
+
if que_id > 0 and str(que_id) not in index.by_id
|
|
1584
|
+
}
|
|
1585
|
+
if not missing_field_ids:
|
|
1586
|
+
return index, []
|
|
1587
|
+
answer_backed_index = _build_answer_backed_field_index(
|
|
1588
|
+
current_answers,
|
|
1589
|
+
field_id_filter=missing_field_ids,
|
|
1590
|
+
)
|
|
1591
|
+
if not answer_backed_index.by_id:
|
|
1592
|
+
return index, []
|
|
1593
|
+
augmented_index = _merge_field_indexes(index, answer_backed_index)
|
|
1594
|
+
return augmented_index, [
|
|
1595
|
+
{
|
|
1596
|
+
"code": "TASK_RUNTIME_EDITABLE_FIELDS_AUGMENTED",
|
|
1597
|
+
"message": "task update schema added backend-editable fields from current task answers because applicant schema did not expose them.",
|
|
1598
|
+
"fields": [
|
|
1599
|
+
{
|
|
1600
|
+
"field_id": field.que_id,
|
|
1601
|
+
"title": field.que_title,
|
|
1602
|
+
"que_type": field.que_type,
|
|
1603
|
+
}
|
|
1604
|
+
for field in answer_backed_index.by_id.values()
|
|
1605
|
+
],
|
|
1606
|
+
}
|
|
1607
|
+
]
|
|
1608
|
+
|
|
1609
|
+
def _resolve_task_editable_question_ids(
|
|
1610
|
+
self,
|
|
1611
|
+
context: BackendRequestContext,
|
|
1612
|
+
*,
|
|
1613
|
+
app_key: str,
|
|
1614
|
+
workflow_node_id: int,
|
|
1615
|
+
node_info: dict[str, Any],
|
|
1616
|
+
) -> tuple[set[int], list[JSONObject], str]:
|
|
1617
|
+
"""执行内部辅助逻辑。"""
|
|
1618
|
+
warnings: list[JSONObject] = []
|
|
1619
|
+
try:
|
|
1620
|
+
payload = self.backend.request(
|
|
1621
|
+
"GET",
|
|
1622
|
+
context,
|
|
1623
|
+
f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
|
|
1624
|
+
)
|
|
1625
|
+
question_ids = self._extract_question_ids(payload)
|
|
1626
|
+
if question_ids:
|
|
1627
|
+
return question_ids, warnings, "workflow_editable_que_ids"
|
|
1628
|
+
except QingflowApiError as error:
|
|
1629
|
+
if error.backend_code not in {40002, 40027, 404} and error.http_status != 404:
|
|
1630
|
+
raise
|
|
1631
|
+
warnings.append(
|
|
1632
|
+
{
|
|
1633
|
+
"code": "TASK_EDITABLE_IDS_FALLBACK",
|
|
1634
|
+
"message": "editable question ids endpoint is unavailable in the current route; task update schema fell back to queAuthSetting and may be conservative.",
|
|
1635
|
+
}
|
|
1636
|
+
)
|
|
1637
|
+
fallback_ids = self._editable_ids_from_que_auth_setting(node_info.get("queAuthSetting"))
|
|
1638
|
+
return fallback_ids, warnings, "que_auth_setting"
|
|
1639
|
+
|
|
1640
|
+
def _resolve_task_save_only_availability(
|
|
1641
|
+
self,
|
|
1642
|
+
context: BackendRequestContext,
|
|
1643
|
+
*,
|
|
1644
|
+
app_key: str,
|
|
1645
|
+
workflow_node_id: int,
|
|
1646
|
+
) -> tuple[bool, list[JSONObject], str]:
|
|
1647
|
+
"""执行内部辅助逻辑。"""
|
|
1648
|
+
try:
|
|
1649
|
+
payload = self.backend.request(
|
|
1650
|
+
"GET",
|
|
1651
|
+
context,
|
|
1652
|
+
f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
|
|
1653
|
+
)
|
|
1654
|
+
except QingflowApiError as error:
|
|
1655
|
+
warning: JSONObject = {
|
|
1656
|
+
"code": "TASK_SAVE_ONLY_SIGNAL_UNAVAILABLE",
|
|
1657
|
+
"message": "save_only is hidden because backend editableQueIds is unavailable for the current node; MCP no longer infers save_only from local schema reconstruction.",
|
|
1658
|
+
}
|
|
1659
|
+
if error.backend_code is not None:
|
|
1660
|
+
warning["backend_code"] = error.backend_code
|
|
1661
|
+
if error.http_status is not None:
|
|
1662
|
+
warning["http_status"] = error.http_status
|
|
1663
|
+
return False, [warning], "backend_editable_que_ids_unavailable"
|
|
1664
|
+
return bool(self._extract_question_ids(payload)), [], "workflow_editable_que_ids"
|
|
1665
|
+
|
|
1666
|
+
def _extract_question_ids(self, payload: Any) -> set[int]:
|
|
1667
|
+
"""执行内部辅助逻辑。"""
|
|
1668
|
+
candidates: list[Any] = []
|
|
1669
|
+
if isinstance(payload, list):
|
|
1670
|
+
candidates = payload
|
|
1671
|
+
elif isinstance(payload, dict):
|
|
1672
|
+
for key in ("editableQueIds", "editableQuestionIds", "queIds", "questionIds", "ids", "list", "result"):
|
|
1673
|
+
value = payload.get(key)
|
|
1674
|
+
if isinstance(value, list):
|
|
1675
|
+
candidates = value
|
|
1676
|
+
break
|
|
1677
|
+
question_ids: set[int] = set()
|
|
1678
|
+
for item in candidates:
|
|
1679
|
+
if isinstance(item, int) and item > 0:
|
|
1680
|
+
question_ids.add(item)
|
|
1681
|
+
continue
|
|
1682
|
+
if isinstance(item, dict):
|
|
1683
|
+
for key in ("queId", "questionId", "id"):
|
|
1684
|
+
value = _coerce_count(item.get(key))
|
|
1685
|
+
if value is not None and value > 0:
|
|
1686
|
+
question_ids.add(value)
|
|
1687
|
+
break
|
|
1688
|
+
return question_ids
|
|
1689
|
+
|
|
1690
|
+
def _editable_ids_from_que_auth_setting(self, payload: Any) -> set[int]:
|
|
1691
|
+
"""执行内部辅助逻辑。"""
|
|
1692
|
+
if not isinstance(payload, list):
|
|
1693
|
+
return set()
|
|
1694
|
+
editable_ids: set[int] = set()
|
|
1695
|
+
for item in payload:
|
|
1696
|
+
if not isinstance(item, dict):
|
|
1697
|
+
continue
|
|
1698
|
+
que_id = _coerce_count(item.get("queId") or item.get("questionId"))
|
|
1699
|
+
if que_id is None or que_id <= 0:
|
|
1700
|
+
continue
|
|
1701
|
+
explicit_editable_keys = ("editable", "writable", "canEdit", "editStatus", "beingEditable")
|
|
1702
|
+
explicit_readonly_keys = ("readonly", "beingReadonly")
|
|
1703
|
+
if any(key in item for key in explicit_editable_keys):
|
|
1704
|
+
if any(bool(item.get(key)) for key in explicit_editable_keys):
|
|
1705
|
+
editable_ids.add(que_id)
|
|
1706
|
+
continue
|
|
1707
|
+
if any(bool(item.get(key)) for key in explicit_readonly_keys):
|
|
1708
|
+
continue
|
|
1709
|
+
if item.get("readable") is False:
|
|
1710
|
+
continue
|
|
1711
|
+
if item.get("readable") is True or item.get("visible") is True:
|
|
1712
|
+
editable_ids.add(que_id)
|
|
1713
|
+
return editable_ids
|
|
1714
|
+
|
|
1715
|
+
def _prepare_task_field_update(
|
|
1716
|
+
self,
|
|
1717
|
+
*,
|
|
1718
|
+
profile: str,
|
|
1719
|
+
context: BackendRequestContext,
|
|
1720
|
+
app_key: str,
|
|
1721
|
+
record_id: int,
|
|
1722
|
+
workflow_node_id: int,
|
|
1723
|
+
task_context: dict[str, Any],
|
|
1724
|
+
fields: dict[str, Any],
|
|
1725
|
+
) -> dict[str, Any]:
|
|
1726
|
+
"""执行内部辅助逻辑。"""
|
|
1727
|
+
record = task_context.get("record") if isinstance(task_context.get("record"), dict) else {}
|
|
1728
|
+
current_answers = record.get("answers") if isinstance(record.get("answers"), list) else []
|
|
1729
|
+
node = task_context.get("node") if isinstance(task_context.get("node"), dict) else {}
|
|
1730
|
+
node_info = node.get("raw") if isinstance(node.get("raw"), dict) else {}
|
|
1731
|
+
schema_state = self._build_task_update_schema(
|
|
1732
|
+
profile=profile,
|
|
1733
|
+
context=context,
|
|
1734
|
+
app_key=app_key,
|
|
1735
|
+
record_id=record_id,
|
|
1736
|
+
workflow_node_id=workflow_node_id,
|
|
1737
|
+
node_info=node_info,
|
|
1738
|
+
current_answers=current_answers,
|
|
1739
|
+
)
|
|
1740
|
+
update_schema = schema_state["public_schema"]
|
|
1741
|
+
if update_schema.get("blockers"):
|
|
1742
|
+
raise_tool_error(
|
|
1743
|
+
QingflowApiError(
|
|
1744
|
+
category="config",
|
|
1745
|
+
message="task field update is blocked because the current node does not expose a usable update schema",
|
|
1746
|
+
details={
|
|
1747
|
+
"error_code": "TASK_UPDATE_SCHEMA_BLOCKED",
|
|
1748
|
+
"update_schema": update_schema,
|
|
1749
|
+
},
|
|
1750
|
+
)
|
|
1751
|
+
)
|
|
1752
|
+
index = schema_state["index"]
|
|
1753
|
+
preflight = self._record_tools._build_record_write_preflight(
|
|
1754
|
+
profile=profile,
|
|
1755
|
+
context=context,
|
|
1756
|
+
operation="update",
|
|
1757
|
+
app_key=app_key,
|
|
1758
|
+
apply_id=record_id,
|
|
1759
|
+
answers=[],
|
|
1760
|
+
fields=fields,
|
|
1761
|
+
force_refresh_form=False,
|
|
1762
|
+
view_id=None,
|
|
1763
|
+
list_type=None,
|
|
1764
|
+
view_key=None,
|
|
1765
|
+
view_name=None,
|
|
1766
|
+
existing_answers_override=current_answers,
|
|
1767
|
+
field_index_override=index,
|
|
1768
|
+
)
|
|
1769
|
+
effective_editable_ids = set(schema_state["effective_editable_question_ids"])
|
|
1770
|
+
scoped_field_errors = self._task_scope_field_errors(
|
|
1771
|
+
normalized_answers=preflight.get("normalized_answers") or [],
|
|
1772
|
+
index=index,
|
|
1773
|
+
effective_editable_ids=effective_editable_ids,
|
|
1774
|
+
)
|
|
1775
|
+
field_errors = list(preflight.get("field_errors") or [])
|
|
1776
|
+
field_errors.extend(scoped_field_errors)
|
|
1777
|
+
blockers = list(preflight.get("blockers") or [])
|
|
1778
|
+
if scoped_field_errors:
|
|
1779
|
+
blockers.append("payload writes fields that are not editable on the current task node")
|
|
1780
|
+
confirmation_requests = list(preflight.get("confirmation_requests") or [])
|
|
1781
|
+
if field_errors or confirmation_requests or blockers:
|
|
1782
|
+
raise_tool_error(
|
|
1783
|
+
QingflowApiError(
|
|
1784
|
+
category="config",
|
|
1785
|
+
message="task field update preflight was blocked",
|
|
1786
|
+
details={
|
|
1787
|
+
"error_code": "TASK_FIELD_PLAN_BLOCKED",
|
|
1788
|
+
"blockers": blockers,
|
|
1789
|
+
"field_errors": field_errors,
|
|
1790
|
+
"confirmation_requests": confirmation_requests,
|
|
1791
|
+
"update_schema": update_schema,
|
|
1792
|
+
"recommended_next_actions": preflight.get("recommended_next_actions") or [],
|
|
1793
|
+
},
|
|
1794
|
+
)
|
|
1795
|
+
)
|
|
1796
|
+
normalized_answers = [item for item in (preflight.get("normalized_answers") or []) if isinstance(item, dict)]
|
|
1797
|
+
merged_answers = self._record_tools._merge_record_answers(current_answers, normalized_answers)
|
|
1798
|
+
return {
|
|
1799
|
+
"update_schema": update_schema,
|
|
1800
|
+
"normalized_answers": normalized_answers,
|
|
1801
|
+
"merged_answers": merged_answers,
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
def _task_scope_field_errors(
|
|
1805
|
+
self,
|
|
1806
|
+
*,
|
|
1807
|
+
normalized_answers: list[dict[str, Any]],
|
|
1808
|
+
index: Any,
|
|
1809
|
+
effective_editable_ids: set[int],
|
|
1810
|
+
) -> list[dict[str, Any]]:
|
|
1811
|
+
"""执行内部辅助逻辑。"""
|
|
1812
|
+
if index is None:
|
|
1813
|
+
return []
|
|
1814
|
+
field_errors: list[dict[str, Any]] = []
|
|
1815
|
+
for answer in normalized_answers:
|
|
1816
|
+
que_id = _coerce_count(answer.get("queId"))
|
|
1817
|
+
if que_id is None or que_id <= 0:
|
|
1818
|
+
continue
|
|
1819
|
+
field = index.by_id.get(str(que_id))
|
|
1820
|
+
field_payload = _field_ref_payload(field) if field is not None else {"que_id": que_id}
|
|
1821
|
+
if que_id not in effective_editable_ids:
|
|
1822
|
+
field_errors.append(
|
|
1823
|
+
{
|
|
1824
|
+
"location": field.que_title if field is not None else str(que_id),
|
|
1825
|
+
"message": "field is not editable on the current task node",
|
|
1826
|
+
"error_code": "TASK_FIELD_NOT_EDITABLE",
|
|
1827
|
+
"field": field_payload,
|
|
1828
|
+
}
|
|
1829
|
+
)
|
|
1830
|
+
continue
|
|
1831
|
+
if field is None or field.que_type not in SUBTABLE_QUE_TYPES:
|
|
1832
|
+
continue
|
|
1833
|
+
table_values = answer.get("tableValues") if isinstance(answer.get("tableValues"), list) else []
|
|
1834
|
+
subtable_index = self._record_tools._subtable_field_index_optional(field)
|
|
1835
|
+
for row_ordinal, row in enumerate(table_values, start=1):
|
|
1836
|
+
row_cells = [item for item in row if isinstance(item, dict)] if isinstance(row, list) else []
|
|
1837
|
+
for cell in row_cells:
|
|
1838
|
+
cell_que_id = _coerce_count(cell.get("queId"))
|
|
1839
|
+
if cell_que_id is None or cell_que_id <= 0 or cell_que_id in effective_editable_ids:
|
|
1840
|
+
continue
|
|
1841
|
+
subfield = subtable_index.by_id.get(str(cell_que_id)) if subtable_index is not None else None
|
|
1842
|
+
field_errors.append(
|
|
1843
|
+
{
|
|
1844
|
+
"location": f"{field.que_title}[{row_ordinal}].{subfield.que_title if subfield is not None else cell_que_id}",
|
|
1845
|
+
"message": "subtable field is not editable on the current task node",
|
|
1846
|
+
"error_code": "TASK_FIELD_NOT_EDITABLE",
|
|
1847
|
+
"field": _field_ref_payload(subfield) if subfield is not None else {"que_id": cell_que_id},
|
|
1848
|
+
}
|
|
1849
|
+
)
|
|
1850
|
+
return field_errors
|
|
1851
|
+
|
|
1852
|
+
def _task_save_only(
|
|
1853
|
+
self,
|
|
1854
|
+
*,
|
|
1855
|
+
profile: str,
|
|
1856
|
+
app_key: str,
|
|
1857
|
+
record_id: int,
|
|
1858
|
+
workflow_node_id: int,
|
|
1859
|
+
apply_answers: list[dict[str, Any]],
|
|
1860
|
+
) -> dict[str, Any]:
|
|
1861
|
+
"""执行内部辅助逻辑。"""
|
|
1862
|
+
def runner(session_profile, context):
|
|
1863
|
+
result = self.backend.request(
|
|
1864
|
+
"POST",
|
|
1865
|
+
context,
|
|
1866
|
+
f"/app/{app_key}/apply/{record_id}",
|
|
1867
|
+
json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": apply_answers},
|
|
1868
|
+
)
|
|
1869
|
+
return {
|
|
1870
|
+
"profile": profile,
|
|
1871
|
+
"ws_id": session_profile.selected_ws_id,
|
|
1872
|
+
"app_key": app_key,
|
|
1873
|
+
"apply_id": record_id,
|
|
1874
|
+
"result": result,
|
|
1875
|
+
"request_route": self._request_route_payload(context),
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
return self._run(profile, runner)
|
|
1879
|
+
|
|
1880
|
+
def _build_visibility(self, node_info: dict[str, Any], detail: dict[str, Any]) -> dict[str, bool]:
|
|
1881
|
+
"""执行内部辅助逻辑。"""
|
|
1882
|
+
return {
|
|
1883
|
+
"comment_visible": self._coerce_bool(node_info.get("commentStatus")),
|
|
1884
|
+
"audit_record_visible": self._coerce_bool(node_info.get("auditRecordVisible")),
|
|
1885
|
+
"workflow_future_visible": self._coerce_bool(node_info.get("beingWorkflowNodeFutureListVisible")),
|
|
1886
|
+
"qrobot_record_visible": self._coerce_bool(node_info.get("qrobotRecordBeingVisible")),
|
|
1887
|
+
"associated_report_visible": self._resolve_associated_report_visible(node_info, detail),
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
def _resolve_associated_report_visible(self, node_info: dict[str, Any], detail: dict[str, Any]) -> bool:
|
|
1891
|
+
"""执行内部辅助逻辑。"""
|
|
1892
|
+
node_visible = node_info.get("asosChartVisible")
|
|
1893
|
+
if node_visible is not None:
|
|
1894
|
+
return self._coerce_bool(node_visible)
|
|
1895
|
+
return self._coerce_bool(detail.get("viewAsosChartVisible"))
|
|
1896
|
+
|
|
1897
|
+
def _normalize_associated_report(self, raw: dict[str, Any]) -> dict[str, Any]:
|
|
1898
|
+
"""执行内部辅助逻辑。"""
|
|
1899
|
+
graph_type = str(raw.get("graphType") or "").strip().lower()
|
|
1900
|
+
source_type = str(raw.get("sourceType") or "").strip().lower()
|
|
1901
|
+
return {
|
|
1902
|
+
"report_id": raw.get("id"),
|
|
1903
|
+
"chart_key": raw.get("chartKey"),
|
|
1904
|
+
"chart_name": raw.get("chartName"),
|
|
1905
|
+
"graph_type": "view" if graph_type.endswith("view") or graph_type == "view" else "chart",
|
|
1906
|
+
"source_type": source_type or "qingflow",
|
|
1907
|
+
"target_app_key": raw.get("appKey"),
|
|
1908
|
+
"target_app_name": raw.get("formTitle"),
|
|
1909
|
+
"match_rules": raw.get("matchRules") or [],
|
|
1910
|
+
"raw": dict(raw),
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
def _rollback_candidate_items(self, payload: Any) -> list[dict[str, Any]]:
|
|
1914
|
+
"""执行内部辅助逻辑。"""
|
|
1915
|
+
if isinstance(payload, dict):
|
|
1916
|
+
revert_nodes = payload.get("revertNodes")
|
|
1917
|
+
if isinstance(revert_nodes, list):
|
|
1918
|
+
return [item for item in revert_nodes if isinstance(item, dict)]
|
|
1919
|
+
return [item for item in _approval_page_items(payload) if isinstance(item, dict)]
|
|
1920
|
+
|
|
1921
|
+
def _filter_transfer_members(self, items: Any, *, current_uid: int | None) -> list[dict[str, Any]]:
|
|
1922
|
+
"""执行内部辅助逻辑。"""
|
|
1923
|
+
if not isinstance(items, list):
|
|
1924
|
+
return []
|
|
1925
|
+
filtered: list[dict[str, Any]] = []
|
|
1926
|
+
for item in items:
|
|
1927
|
+
if not isinstance(item, dict):
|
|
1928
|
+
continue
|
|
1929
|
+
if current_uid is not None and item.get("uid") == current_uid:
|
|
1930
|
+
continue
|
|
1931
|
+
filtered.append(item)
|
|
1932
|
+
return filtered
|
|
1933
|
+
|
|
1934
|
+
def _find_associated_report(self, task_context: dict[str, Any], report_id: int) -> dict[str, Any] | None:
|
|
1935
|
+
"""执行内部辅助逻辑。"""
|
|
1936
|
+
associated_reports = ((task_context.get("associated_reports") or {}).get("items") or [])
|
|
1937
|
+
for item in associated_reports:
|
|
1938
|
+
if isinstance(item, dict) and item.get("report_id") == report_id:
|
|
1939
|
+
return item
|
|
1940
|
+
return None
|
|
1941
|
+
|
|
1942
|
+
def _build_association_query(self, asos_chart: dict[str, Any], answers: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1943
|
+
"""执行内部辅助逻辑。"""
|
|
1944
|
+
key_que_ids = self._collect_match_rule_question_ids(asos_chart.get("matchRules") or [])
|
|
1945
|
+
key_values: list[dict[str, Any]] = []
|
|
1946
|
+
for answer in answers:
|
|
1947
|
+
if not isinstance(answer, dict):
|
|
1948
|
+
continue
|
|
1949
|
+
answer_que_id = answer.get("queId")
|
|
1950
|
+
if isinstance(answer_que_id, int) and answer_que_id in key_que_ids:
|
|
1951
|
+
extracted_values = self._extract_answer_values(answer)
|
|
1952
|
+
key_values.append({"keyQueId": answer_que_id, "values": extracted_values or None})
|
|
1953
|
+
table_values = answer.get("tableValues")
|
|
1954
|
+
if not isinstance(table_values, list):
|
|
1955
|
+
continue
|
|
1956
|
+
for idx, row in enumerate(table_values, start=1):
|
|
1957
|
+
if not isinstance(row, list):
|
|
1958
|
+
continue
|
|
1959
|
+
for sub_answer in row:
|
|
1960
|
+
if not isinstance(sub_answer, dict):
|
|
1961
|
+
continue
|
|
1962
|
+
sub_que_id = sub_answer.get("queId")
|
|
1963
|
+
if isinstance(sub_que_id, int) and sub_que_id in key_que_ids:
|
|
1964
|
+
extracted_values = self._extract_answer_values(sub_answer)
|
|
1965
|
+
key_values.append(
|
|
1966
|
+
{
|
|
1967
|
+
"keyQueId": sub_que_id,
|
|
1968
|
+
"ordinal": idx,
|
|
1969
|
+
"values": extracted_values or None,
|
|
1970
|
+
}
|
|
1971
|
+
)
|
|
1972
|
+
return {
|
|
1973
|
+
"asosChart": self._sanitize_associated_chart(asos_chart),
|
|
1974
|
+
"keyQueValues": key_values,
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
def _collect_match_rule_question_ids(self, match_rules: Any) -> set[int]:
|
|
1978
|
+
"""执行内部辅助逻辑。"""
|
|
1979
|
+
question_ids: set[int] = set()
|
|
1980
|
+
|
|
1981
|
+
def visit(node: Any) -> None:
|
|
1982
|
+
if isinstance(node, list):
|
|
1983
|
+
for item in node:
|
|
1984
|
+
visit(item)
|
|
1985
|
+
return
|
|
1986
|
+
if not isinstance(node, dict):
|
|
1987
|
+
return
|
|
1988
|
+
for key in ("queId", "judgeQueId"):
|
|
1989
|
+
value = node.get(key)
|
|
1990
|
+
if isinstance(value, int):
|
|
1991
|
+
question_ids.add(value)
|
|
1992
|
+
for value in node.values():
|
|
1993
|
+
if isinstance(value, (list, dict)):
|
|
1994
|
+
visit(value)
|
|
1995
|
+
|
|
1996
|
+
visit(match_rules)
|
|
1997
|
+
return question_ids
|
|
1998
|
+
|
|
1999
|
+
def _extract_answer_values(self, answer: dict[str, Any]) -> list[str]:
|
|
2000
|
+
"""执行内部辅助逻辑。"""
|
|
2001
|
+
values = answer.get("values")
|
|
2002
|
+
if not isinstance(values, list):
|
|
2003
|
+
return []
|
|
2004
|
+
normalized: list[str] = []
|
|
2005
|
+
for item in values:
|
|
2006
|
+
if item is None:
|
|
2007
|
+
continue
|
|
2008
|
+
if isinstance(item, (str, int, float, bool)):
|
|
2009
|
+
normalized.append(str(item))
|
|
2010
|
+
continue
|
|
2011
|
+
if not isinstance(item, dict):
|
|
2012
|
+
continue
|
|
2013
|
+
for key in ("value", "id", "uid", "userId", "applyId", "phone", "email", "name", "title", "label"):
|
|
2014
|
+
value = item.get(key)
|
|
2015
|
+
if value not in (None, ""):
|
|
2016
|
+
normalized.append(str(value))
|
|
2017
|
+
break
|
|
2018
|
+
deduped: list[str] = []
|
|
2019
|
+
seen: set[str] = set()
|
|
2020
|
+
for item in normalized:
|
|
2021
|
+
if item in seen:
|
|
2022
|
+
continue
|
|
2023
|
+
deduped.append(item)
|
|
2024
|
+
seen.add(item)
|
|
2025
|
+
return deduped
|
|
2026
|
+
|
|
2027
|
+
def _sanitize_associated_chart(self, asos_chart: dict[str, Any]) -> dict[str, Any]:
|
|
2028
|
+
"""执行内部辅助逻辑。"""
|
|
2029
|
+
return {
|
|
2030
|
+
"id": asos_chart.get("id"),
|
|
2031
|
+
"appKey": asos_chart.get("appKey"),
|
|
2032
|
+
"formTitle": asos_chart.get("formTitle"),
|
|
2033
|
+
"chartKey": asos_chart.get("chartKey"),
|
|
2034
|
+
"chartName": asos_chart.get("chartName"),
|
|
2035
|
+
"chartType": asos_chart.get("chartType"),
|
|
2036
|
+
"matchRules": asos_chart.get("matchRules") or [],
|
|
2037
|
+
"sourceType": asos_chart.get("sourceType"),
|
|
2038
|
+
"graphType": asos_chart.get("graphType"),
|
|
2039
|
+
"viewType": asos_chart.get("viewType"),
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
def _qingflow_chart_uses_apply_filter(self, context: BackendRequestContext, chart_key: str) -> bool:
|
|
2043
|
+
"""执行内部辅助逻辑。"""
|
|
2044
|
+
if not chart_key:
|
|
2045
|
+
return False
|
|
2046
|
+
try:
|
|
2047
|
+
auth = self.backend.request(
|
|
2048
|
+
"GET",
|
|
2049
|
+
context,
|
|
2050
|
+
f"/chart/{chart_key}/auth",
|
|
2051
|
+
)
|
|
2052
|
+
except QingflowApiError:
|
|
2053
|
+
return False
|
|
2054
|
+
if not isinstance(auth, dict):
|
|
2055
|
+
return False
|
|
2056
|
+
return self._coerce_bool(auth.get("detailedViewStatus")) and auth.get("lastViewType") == 1
|
|
2057
|
+
|
|
2058
|
+
def _normalize_chart_result(self, payload: Any) -> dict[str, Any]:
|
|
2059
|
+
"""执行内部辅助逻辑。"""
|
|
2060
|
+
if isinstance(payload, dict):
|
|
2061
|
+
rows = payload.get("rows")
|
|
2062
|
+
if not isinstance(rows, list):
|
|
2063
|
+
rows = payload.get("list") if isinstance(payload.get("list"), list) else []
|
|
2064
|
+
series = payload.get("series")
|
|
2065
|
+
if not isinstance(series, list):
|
|
2066
|
+
series = payload.get("xAxis") if isinstance(payload.get("xAxis"), list) else []
|
|
2067
|
+
metrics = payload.get("metrics")
|
|
2068
|
+
if not isinstance(metrics, list):
|
|
2069
|
+
metrics = payload.get("yAxis") if isinstance(payload.get("yAxis"), list) else []
|
|
2070
|
+
summary = payload.get("summary") if isinstance(payload.get("summary"), dict) else {}
|
|
2071
|
+
return {
|
|
2072
|
+
"summary": summary,
|
|
2073
|
+
"rows": rows or [],
|
|
2074
|
+
"series": series or [],
|
|
2075
|
+
"metrics": metrics or [],
|
|
2076
|
+
}
|
|
2077
|
+
if isinstance(payload, list):
|
|
2078
|
+
return {"summary": {}, "rows": payload, "series": [], "metrics": []}
|
|
2079
|
+
return {"summary": {}, "rows": [], "series": [], "metrics": []}
|
|
2080
|
+
|
|
2081
|
+
def _normalize_workflow_logs(self, payload: Any) -> list[dict[str, Any]]:
|
|
2082
|
+
"""执行内部辅助逻辑。"""
|
|
2083
|
+
if isinstance(payload, dict):
|
|
2084
|
+
page = payload.get("list") if isinstance(payload.get("list"), list) else payload.get("rows")
|
|
2085
|
+
if not isinstance(page, list):
|
|
2086
|
+
nested = payload.get("data")
|
|
2087
|
+
if isinstance(nested, dict):
|
|
2088
|
+
page = nested.get("list") if isinstance(nested.get("list"), list) else nested.get("rows")
|
|
2089
|
+
if not isinstance(page, list):
|
|
2090
|
+
page = []
|
|
2091
|
+
elif isinstance(payload, list):
|
|
2092
|
+
page = payload
|
|
2093
|
+
else:
|
|
2094
|
+
page = []
|
|
2095
|
+
|
|
2096
|
+
items: list[dict[str, Any]] = []
|
|
2097
|
+
for node_record in page:
|
|
2098
|
+
if not isinstance(node_record, dict):
|
|
2099
|
+
continue
|
|
2100
|
+
node_id = node_record.get("nodeId")
|
|
2101
|
+
node_name = node_record.get("nodeName")
|
|
2102
|
+
operation_record_list = node_record.get("operationRecordList")
|
|
2103
|
+
if not isinstance(operation_record_list, list):
|
|
2104
|
+
operation_record_list = []
|
|
2105
|
+
for operation in operation_record_list:
|
|
2106
|
+
if not isinstance(operation, dict):
|
|
2107
|
+
continue
|
|
2108
|
+
detail = self._first_nested_operation_detail(operation)
|
|
2109
|
+
items.append(
|
|
2110
|
+
{
|
|
2111
|
+
"log_id": operation.get("workflowNodeOperationRecordId")
|
|
2112
|
+
or node_record.get("workflowNodeProcessRecordId"),
|
|
2113
|
+
"node_id": node_id,
|
|
2114
|
+
"node_name": node_name,
|
|
2115
|
+
"operator": operation.get("operator"),
|
|
2116
|
+
"operation": operation.get("operationType"),
|
|
2117
|
+
"operation_result": detail,
|
|
2118
|
+
"operation_time": operation.get("operationTime"),
|
|
2119
|
+
"remark": self._extract_remark(detail),
|
|
2120
|
+
"signature_url": self._extract_signature_url(detail),
|
|
2121
|
+
"attachments": self._extract_attachments(detail),
|
|
2122
|
+
"qrobot_related": any(
|
|
2123
|
+
operation.get(key)
|
|
2124
|
+
for key in ("qRobotAdd", "qRobotUpdate", "qRobotSMS", "qRobotMail", "webhook")
|
|
2125
|
+
),
|
|
2126
|
+
}
|
|
2127
|
+
)
|
|
2128
|
+
return items
|
|
2129
|
+
|
|
2130
|
+
def _workflow_log_digest(self, items: list[dict[str, Any]]) -> str | None:
|
|
2131
|
+
"""执行内部辅助逻辑。"""
|
|
2132
|
+
if not items:
|
|
2133
|
+
return None
|
|
2134
|
+
try:
|
|
2135
|
+
return json.dumps(items, ensure_ascii=False, sort_keys=True, default=str)
|
|
2136
|
+
except TypeError:
|
|
2137
|
+
return str(items)
|
|
2138
|
+
|
|
2139
|
+
def _first_nested_operation_detail(self, operation: dict[str, Any]) -> Any:
|
|
2140
|
+
"""执行内部辅助逻辑。"""
|
|
2141
|
+
for key in ("approval", "filling", "cc", "applicant", "qRobotAdd", "qRobotUpdate", "webhook", "qRobotSMS", "qRobotMail"):
|
|
2142
|
+
value = operation.get(key)
|
|
2143
|
+
if value is not None:
|
|
2144
|
+
return value
|
|
2145
|
+
return None
|
|
2146
|
+
|
|
2147
|
+
def _extract_remark(self, detail: Any) -> Any:
|
|
2148
|
+
"""执行内部辅助逻辑。"""
|
|
2149
|
+
if not isinstance(detail, dict):
|
|
2150
|
+
return None
|
|
2151
|
+
for key in ("remark", "feedback", "comment", "content"):
|
|
2152
|
+
value = detail.get(key)
|
|
2153
|
+
if value not in (None, ""):
|
|
2154
|
+
return value
|
|
2155
|
+
return None
|
|
2156
|
+
|
|
2157
|
+
def _extract_signature_url(self, detail: Any) -> Any:
|
|
2158
|
+
"""执行内部辅助逻辑。"""
|
|
2159
|
+
if not isinstance(detail, dict):
|
|
2160
|
+
return None
|
|
2161
|
+
for key in ("signatureUrl", "handSignImageUrl"):
|
|
2162
|
+
value = detail.get(key)
|
|
2163
|
+
if value not in (None, ""):
|
|
2164
|
+
return value
|
|
2165
|
+
return None
|
|
2166
|
+
|
|
2167
|
+
def _extract_attachments(self, detail: Any) -> Any:
|
|
2168
|
+
"""执行内部辅助逻辑。"""
|
|
2169
|
+
if not isinstance(detail, dict):
|
|
2170
|
+
return []
|
|
2171
|
+
for key in ("attachments", "files", "uploadFiles"):
|
|
2172
|
+
value = detail.get(key)
|
|
2173
|
+
if isinstance(value, list):
|
|
2174
|
+
return value
|
|
2175
|
+
return []
|
|
2176
|
+
|
|
2177
|
+
def _extract_audit_feedback(self, payload: dict[str, Any]) -> str | None:
|
|
2178
|
+
"""执行内部辅助逻辑。"""
|
|
2179
|
+
for key in ("audit_feedback", "auditFeedback"):
|
|
2180
|
+
value = payload.get(key)
|
|
2181
|
+
if isinstance(value, str) and value.strip():
|
|
2182
|
+
return value.strip()
|
|
2183
|
+
return None
|
|
2184
|
+
|
|
2185
|
+
def _extract_positive_int(self, payload: dict[str, Any], key: str, *, aliases: tuple[str, ...] = ()) -> int:
|
|
2186
|
+
"""执行内部辅助逻辑。"""
|
|
2187
|
+
candidates = (key, *aliases)
|
|
2188
|
+
value: Any = None
|
|
2189
|
+
for candidate in candidates:
|
|
2190
|
+
if candidate in payload:
|
|
2191
|
+
value = payload.get(candidate)
|
|
2192
|
+
break
|
|
2193
|
+
if not isinstance(value, int) or value <= 0:
|
|
2194
|
+
names = ", ".join(candidates)
|
|
2195
|
+
raise_tool_error(QingflowApiError.config_error(f"one of [{names}] must be a positive integer"))
|
|
2196
|
+
return value
|
|
2197
|
+
|
|
2198
|
+
def _require_app_record_and_node(self, app_key: str, record_id: int, workflow_node_id: int) -> None:
|
|
2199
|
+
"""执行内部辅助逻辑。"""
|
|
2200
|
+
if not app_key:
|
|
2201
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
2202
|
+
if record_id <= 0:
|
|
2203
|
+
raise_tool_error(QingflowApiError.config_error("record_id must be positive"))
|
|
2204
|
+
if workflow_node_id <= 0:
|
|
2205
|
+
raise_tool_error(QingflowApiError.config_error("workflow_node_id must be positive"))
|
|
2206
|
+
|
|
2207
|
+
def _coerce_bool(self, value: Any) -> bool:
|
|
2208
|
+
"""执行内部辅助逻辑。"""
|
|
2209
|
+
if isinstance(value, bool):
|
|
2210
|
+
return value
|
|
2211
|
+
if isinstance(value, int):
|
|
2212
|
+
return value != 0
|
|
2213
|
+
if isinstance(value, str):
|
|
2214
|
+
return value.strip().lower() in {"1", "true", "yes", "y", "show", "visible", "enabled"}
|
|
2215
|
+
return bool(value)
|
|
2216
|
+
|
|
2217
|
+
def _request_route_payload(self, context: BackendRequestContext) -> dict[str, Any]:
|
|
2218
|
+
"""执行内部辅助逻辑。"""
|
|
2219
|
+
describe_route = getattr(self.backend, "describe_route", None)
|
|
2220
|
+
if callable(describe_route):
|
|
2221
|
+
payload = describe_route(context)
|
|
2222
|
+
if isinstance(payload, dict):
|
|
2223
|
+
return payload
|
|
2224
|
+
return {
|
|
2225
|
+
"base_url": context.base_url,
|
|
2226
|
+
"qf_version": context.qf_version,
|
|
2227
|
+
"qf_version_source": context.qf_version_source or ("context" if context.qf_version else "unknown"),
|
|
2228
|
+
}
|