@josephyan/qingflow-cli 0.2.0-beta.61 → 0.2.0-beta.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/cli/commands/task.py +3 -1
- package/src/qingflow_mcp/cli/formatters.py +36 -0
- package/src/qingflow_mcp/tools/approval_tools.py +147 -12
- package/src/qingflow_mcp/tools/task_context_tools.py +621 -29
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-cli@0.2.0-beta.
|
|
6
|
+
npm install @josephyan/qingflow-cli@0.2.0-beta.62
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-cli@0.2.0-beta.
|
|
12
|
+
npx -y -p @josephyan/qingflow-cli@0.2.0-beta.62 qingflow
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@josephyan/qingflow-cli",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.62",
|
|
4
4
|
"description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/pyproject.toml
CHANGED
|
@@ -26,7 +26,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
26
26
|
get.add_argument("--workflow-node-id", required=True, type=int)
|
|
27
27
|
get.add_argument("--include-candidates", action=argparse.BooleanOptionalAction, default=True)
|
|
28
28
|
get.add_argument("--include-associated-reports", action=argparse.BooleanOptionalAction, default=True)
|
|
29
|
-
get.set_defaults(handler=_handle_get, format_hint="")
|
|
29
|
+
get.set_defaults(handler=_handle_get, format_hint="task_get")
|
|
30
30
|
|
|
31
31
|
action = task_subparsers.add_parser("action", help="执行待办动作")
|
|
32
32
|
action.add_argument("--app-key", required=True)
|
|
@@ -34,6 +34,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
|
|
|
34
34
|
action.add_argument("--workflow-node-id", required=True, type=int)
|
|
35
35
|
action.add_argument("--action", required=True)
|
|
36
36
|
action.add_argument("--payload-file")
|
|
37
|
+
action.add_argument("--fields-file")
|
|
37
38
|
action.set_defaults(handler=_handle_action, format_hint="")
|
|
38
39
|
|
|
39
40
|
log = task_subparsers.add_parser("log", help="读取流程日志")
|
|
@@ -75,6 +76,7 @@ def _handle_action(args: argparse.Namespace, context: CliContext) -> dict:
|
|
|
75
76
|
workflow_node_id=args.workflow_node_id,
|
|
76
77
|
action=args.action,
|
|
77
78
|
payload=load_object_arg(args.payload_file, option_name="--payload-file") or {},
|
|
79
|
+
fields=load_object_arg(args.fields_file, option_name="--fields-file") or {},
|
|
78
80
|
)
|
|
79
81
|
|
|
80
82
|
|
|
@@ -137,6 +137,41 @@ def _format_task_list(result: dict[str, Any]) -> str:
|
|
|
137
137
|
return "\n".join(lines) + "\n"
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def _format_task_get(result: dict[str, Any]) -> str:
|
|
141
|
+
data = result.get("data") if isinstance(result.get("data"), dict) else {}
|
|
142
|
+
task = data.get("task") if isinstance(data.get("task"), dict) else {}
|
|
143
|
+
record = data.get("record") if isinstance(data.get("record"), dict) else {}
|
|
144
|
+
capabilities = data.get("capabilities") if isinstance(data.get("capabilities"), dict) else {}
|
|
145
|
+
update_schema = data.get("update_schema") if isinstance(data.get("update_schema"), dict) else {}
|
|
146
|
+
writable_fields = update_schema.get("writable_fields") if isinstance(update_schema.get("writable_fields"), list) else []
|
|
147
|
+
lines = [
|
|
148
|
+
f"Task: {task.get('app_key') or '-'} / {task.get('record_id') or '-'} / {task.get('workflow_node_id') or '-'}",
|
|
149
|
+
f"Node: {task.get('workflow_node_name') or '-'}",
|
|
150
|
+
f"Apply Status: {record.get('apply_status')}",
|
|
151
|
+
f"Available Actions: {', '.join(str(item) for item in (capabilities.get('available_actions') or [])) or '-'}",
|
|
152
|
+
f"Editable Fields: {len(writable_fields)}",
|
|
153
|
+
]
|
|
154
|
+
if writable_fields:
|
|
155
|
+
for item in writable_fields[:10]:
|
|
156
|
+
if isinstance(item, dict):
|
|
157
|
+
lines.append(f"- {item.get('title') or '-'} ({item.get('kind') or 'field'})")
|
|
158
|
+
blockers = update_schema.get("blockers") if isinstance(update_schema.get("blockers"), list) else []
|
|
159
|
+
if blockers:
|
|
160
|
+
lines.append("Update Schema Blockers:")
|
|
161
|
+
for item in blockers:
|
|
162
|
+
lines.append(f"- {item}")
|
|
163
|
+
schema_warnings = update_schema.get("warnings") if isinstance(update_schema.get("warnings"), list) else []
|
|
164
|
+
if schema_warnings:
|
|
165
|
+
lines.append("Update Schema Warnings:")
|
|
166
|
+
for item in schema_warnings:
|
|
167
|
+
if isinstance(item, dict):
|
|
168
|
+
lines.append(f"- {item.get('code') or 'WARNING'}: {item.get('message') or ''}".rstrip())
|
|
169
|
+
else:
|
|
170
|
+
lines.append(f"- {item}")
|
|
171
|
+
_append_warnings(lines, result.get("warnings"))
|
|
172
|
+
return "\n".join(lines) + "\n"
|
|
173
|
+
|
|
174
|
+
|
|
140
175
|
def _format_import_verify(result: dict[str, Any]) -> str:
|
|
141
176
|
lines = [
|
|
142
177
|
f"App Key: {result.get('app_key') or '-'}",
|
|
@@ -263,6 +298,7 @@ _FORMATTERS = {
|
|
|
263
298
|
"app_get": _format_app_get,
|
|
264
299
|
"record_list": _format_record_list,
|
|
265
300
|
"task_list": _format_task_list,
|
|
301
|
+
"task_get": _format_task_get,
|
|
266
302
|
"import_verify": _format_import_verify,
|
|
267
303
|
"import_status": _format_import_status,
|
|
268
304
|
"builder_summary": _format_builder_summary,
|
|
@@ -69,12 +69,24 @@ class ApprovalTools(ToolBase):
|
|
|
69
69
|
return self.record_comment_mark_read(profile=profile, app_key=app_key, apply_id=record_id)
|
|
70
70
|
|
|
71
71
|
@mcp.tool(description=self._high_risk_tool_description(operation="approve", target="workflow task"))
|
|
72
|
-
def task_approve(
|
|
73
|
-
|
|
72
|
+
def task_approve(
|
|
73
|
+
profile: str = DEFAULT_PROFILE,
|
|
74
|
+
app_key: str = "",
|
|
75
|
+
record_id: int = 0,
|
|
76
|
+
payload: dict[str, Any] | None = None,
|
|
77
|
+
fields: dict[str, Any] | None = None,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
return self.task_approve(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
|
|
74
80
|
|
|
75
81
|
@mcp.tool(description=self._high_risk_tool_description(operation="reject", target="workflow task"))
|
|
76
|
-
def task_reject(
|
|
77
|
-
|
|
82
|
+
def task_reject(
|
|
83
|
+
profile: str = DEFAULT_PROFILE,
|
|
84
|
+
app_key: str = "",
|
|
85
|
+
record_id: int = 0,
|
|
86
|
+
payload: dict[str, Any] | None = None,
|
|
87
|
+
fields: dict[str, Any] | None = None,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
return self.task_reject(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
|
|
78
90
|
|
|
79
91
|
@mcp.tool()
|
|
80
92
|
def task_rollback_candidates(
|
|
@@ -86,12 +98,40 @@ class ApprovalTools(ToolBase):
|
|
|
86
98
|
return self.task_rollback_candidates(profile=profile, app_key=app_key, record_id=record_id, workflow_node_id=workflow_node_id)
|
|
87
99
|
|
|
88
100
|
@mcp.tool()
|
|
89
|
-
def task_rollback(
|
|
90
|
-
|
|
101
|
+
def task_rollback(
|
|
102
|
+
profile: str = DEFAULT_PROFILE,
|
|
103
|
+
app_key: str = "",
|
|
104
|
+
record_id: int = 0,
|
|
105
|
+
payload: dict[str, Any] | None = None,
|
|
106
|
+
fields: dict[str, Any] | None = None,
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
return self.task_rollback(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
|
|
91
109
|
|
|
92
110
|
@mcp.tool()
|
|
93
|
-
def task_transfer(
|
|
94
|
-
|
|
111
|
+
def task_transfer(
|
|
112
|
+
profile: str = DEFAULT_PROFILE,
|
|
113
|
+
app_key: str = "",
|
|
114
|
+
record_id: int = 0,
|
|
115
|
+
payload: dict[str, Any] | None = None,
|
|
116
|
+
fields: dict[str, Any] | None = None,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
return self.task_transfer(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {}, fields=fields or {})
|
|
119
|
+
|
|
120
|
+
@mcp.tool(description=self._high_risk_tool_description(operation="save", target="workflow task fields without advancing the workflow"))
|
|
121
|
+
def task_save_only(
|
|
122
|
+
profile: str = DEFAULT_PROFILE,
|
|
123
|
+
app_key: str = "",
|
|
124
|
+
record_id: int = 0,
|
|
125
|
+
workflow_node_id: int = 0,
|
|
126
|
+
fields: dict[str, Any] | None = None,
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
return self.task_save_only(
|
|
129
|
+
profile=profile,
|
|
130
|
+
app_key=app_key,
|
|
131
|
+
record_id=record_id,
|
|
132
|
+
workflow_node_id=workflow_node_id,
|
|
133
|
+
fields=fields or {},
|
|
134
|
+
)
|
|
95
135
|
|
|
96
136
|
@mcp.tool()
|
|
97
137
|
def task_transfer_candidates(
|
|
@@ -156,7 +196,17 @@ class ApprovalTools(ToolBase):
|
|
|
156
196
|
selection={"app_key": app_key, "record_id": record_id, "list_type": list_type, "keyword": keyword},
|
|
157
197
|
)
|
|
158
198
|
|
|
159
|
-
def task_approve(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
|
|
199
|
+
def task_approve(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any], fields: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
200
|
+
if fields:
|
|
201
|
+
return self._delegate_task_action(
|
|
202
|
+
profile=profile,
|
|
203
|
+
app_key=app_key,
|
|
204
|
+
record_id=record_id,
|
|
205
|
+
action="approve",
|
|
206
|
+
payload=payload,
|
|
207
|
+
fields=fields,
|
|
208
|
+
public_action="task_approve",
|
|
209
|
+
)
|
|
160
210
|
raw = self.record_approve(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
|
|
161
211
|
return self._public_action_response(
|
|
162
212
|
raw,
|
|
@@ -166,7 +216,17 @@ class ApprovalTools(ToolBase):
|
|
|
166
216
|
human_review=True,
|
|
167
217
|
)
|
|
168
218
|
|
|
169
|
-
def task_reject(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
|
|
219
|
+
def task_reject(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any], fields: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
220
|
+
if fields:
|
|
221
|
+
return self._delegate_task_action(
|
|
222
|
+
profile=profile,
|
|
223
|
+
app_key=app_key,
|
|
224
|
+
record_id=record_id,
|
|
225
|
+
action="reject",
|
|
226
|
+
payload=payload,
|
|
227
|
+
fields=fields,
|
|
228
|
+
public_action="task_reject",
|
|
229
|
+
)
|
|
170
230
|
raw = self.record_reject(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
|
|
171
231
|
return self._public_action_response(
|
|
172
232
|
raw,
|
|
@@ -186,7 +246,17 @@ class ApprovalTools(ToolBase):
|
|
|
186
246
|
selection={"app_key": app_key, "record_id": record_id, "workflow_node_id": workflow_node_id},
|
|
187
247
|
)
|
|
188
248
|
|
|
189
|
-
def task_rollback(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
|
|
249
|
+
def task_rollback(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any], fields: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
250
|
+
if fields:
|
|
251
|
+
return self._delegate_task_action(
|
|
252
|
+
profile=profile,
|
|
253
|
+
app_key=app_key,
|
|
254
|
+
record_id=record_id,
|
|
255
|
+
action="rollback",
|
|
256
|
+
payload=payload,
|
|
257
|
+
fields=fields,
|
|
258
|
+
public_action="task_rollback",
|
|
259
|
+
)
|
|
190
260
|
raw = self.record_rollback(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
|
|
191
261
|
return self._public_action_response(
|
|
192
262
|
raw,
|
|
@@ -196,7 +266,17 @@ class ApprovalTools(ToolBase):
|
|
|
196
266
|
human_review=True,
|
|
197
267
|
)
|
|
198
268
|
|
|
199
|
-
def task_transfer(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
|
|
269
|
+
def task_transfer(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any], fields: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
270
|
+
if fields:
|
|
271
|
+
return self._delegate_task_action(
|
|
272
|
+
profile=profile,
|
|
273
|
+
app_key=app_key,
|
|
274
|
+
record_id=record_id,
|
|
275
|
+
action="transfer",
|
|
276
|
+
payload=payload,
|
|
277
|
+
fields=fields,
|
|
278
|
+
public_action="task_transfer",
|
|
279
|
+
)
|
|
200
280
|
raw = self.record_transfer(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
|
|
201
281
|
return self._public_action_response(
|
|
202
282
|
raw,
|
|
@@ -206,6 +286,26 @@ class ApprovalTools(ToolBase):
|
|
|
206
286
|
human_review=True,
|
|
207
287
|
)
|
|
208
288
|
|
|
289
|
+
def task_save_only(
|
|
290
|
+
self,
|
|
291
|
+
*,
|
|
292
|
+
profile: str,
|
|
293
|
+
app_key: str,
|
|
294
|
+
record_id: int,
|
|
295
|
+
workflow_node_id: int,
|
|
296
|
+
fields: dict[str, Any],
|
|
297
|
+
) -> dict[str, Any]:
|
|
298
|
+
return self._delegate_task_action(
|
|
299
|
+
profile=profile,
|
|
300
|
+
app_key=app_key,
|
|
301
|
+
record_id=record_id,
|
|
302
|
+
action="save_only",
|
|
303
|
+
payload={},
|
|
304
|
+
fields=fields,
|
|
305
|
+
public_action="task_save_only",
|
|
306
|
+
workflow_node_id=workflow_node_id,
|
|
307
|
+
)
|
|
308
|
+
|
|
209
309
|
def task_transfer_candidates(
|
|
210
310
|
self,
|
|
211
311
|
*,
|
|
@@ -749,6 +849,41 @@ class ApprovalTools(ToolBase):
|
|
|
749
849
|
if payload.get("handSignImageUrl"):
|
|
750
850
|
raise_tool_error(QingflowApiError.not_supported("NOT_SUPPORTED_IN_V1: handSignImageUrl is not supported"))
|
|
751
851
|
|
|
852
|
+
def _delegate_task_action(
|
|
853
|
+
self,
|
|
854
|
+
*,
|
|
855
|
+
profile: str,
|
|
856
|
+
app_key: str,
|
|
857
|
+
record_id: int,
|
|
858
|
+
action: str,
|
|
859
|
+
payload: dict[str, Any],
|
|
860
|
+
fields: dict[str, Any],
|
|
861
|
+
public_action: str,
|
|
862
|
+
workflow_node_id: int | None = None,
|
|
863
|
+
) -> dict[str, Any]:
|
|
864
|
+
from .task_context_tools import TaskContextTools
|
|
865
|
+
|
|
866
|
+
node_id = workflow_node_id
|
|
867
|
+
if node_id is None:
|
|
868
|
+
node_payload = dict(payload or {})
|
|
869
|
+
node_id = self._extract_node_id(node_payload)
|
|
870
|
+
delegated = TaskContextTools(self.sessions, self.backend).task_action_execute(
|
|
871
|
+
profile=profile,
|
|
872
|
+
app_key=app_key,
|
|
873
|
+
record_id=record_id,
|
|
874
|
+
workflow_node_id=node_id,
|
|
875
|
+
action=action,
|
|
876
|
+
payload=payload or {},
|
|
877
|
+
fields=fields or {},
|
|
878
|
+
)
|
|
879
|
+
response = dict(delegated)
|
|
880
|
+
data = response.get("data")
|
|
881
|
+
if isinstance(data, dict):
|
|
882
|
+
payload_data = dict(data)
|
|
883
|
+
payload_data["action"] = public_action
|
|
884
|
+
response["data"] = payload_data
|
|
885
|
+
return response
|
|
886
|
+
|
|
752
887
|
def _public_page_response(
|
|
753
888
|
self,
|
|
754
889
|
raw: dict[str, Any],
|
|
@@ -13,6 +13,24 @@ from ..json_types import JSONObject
|
|
|
13
13
|
from .approval_tools import ApprovalTools, _approval_page_amount, _approval_page_items, _approval_page_total
|
|
14
14
|
from .base import ToolBase
|
|
15
15
|
from .qingbi_report_tools import _qingbi_base_url
|
|
16
|
+
from .record_tools import (
|
|
17
|
+
LAYOUT_ONLY_QUE_TYPES,
|
|
18
|
+
SUBTABLE_QUE_TYPES,
|
|
19
|
+
RecordTools,
|
|
20
|
+
_build_applicant_hidden_linked_top_level_field_index,
|
|
21
|
+
_build_applicant_top_level_field_index,
|
|
22
|
+
_build_static_schema_linkage_payloads,
|
|
23
|
+
_canonical_value_is_empty,
|
|
24
|
+
_canonicalize_answer_value_for_compare,
|
|
25
|
+
_clone_form_field,
|
|
26
|
+
_coerce_count,
|
|
27
|
+
_collect_linked_required_field_ids,
|
|
28
|
+
_collect_option_linked_field_ids,
|
|
29
|
+
_collect_question_relations,
|
|
30
|
+
_field_ref_payload,
|
|
31
|
+
_merge_field_indexes,
|
|
32
|
+
_subtable_descendant_ids,
|
|
33
|
+
)
|
|
16
34
|
from .task_tools import TaskTools, _task_page_amount, _task_page_items, _task_page_total
|
|
17
35
|
|
|
18
36
|
|
|
@@ -21,6 +39,7 @@ class TaskContextTools(ToolBase):
|
|
|
21
39
|
super().__init__(sessions, backend)
|
|
22
40
|
self._task_tools = TaskTools(sessions, backend)
|
|
23
41
|
self._approval_tools = ApprovalTools(sessions, backend)
|
|
42
|
+
self._record_tools = RecordTools(sessions, backend)
|
|
24
43
|
|
|
25
44
|
def register(self, mcp: FastMCP) -> None:
|
|
26
45
|
@mcp.tool()
|
|
@@ -71,6 +90,7 @@ class TaskContextTools(ToolBase):
|
|
|
71
90
|
workflow_node_id: int = 0,
|
|
72
91
|
action: str = "",
|
|
73
92
|
payload: dict[str, Any] | None = None,
|
|
93
|
+
fields: dict[str, Any] | None = None,
|
|
74
94
|
) -> dict[str, Any]:
|
|
75
95
|
return self.task_action_execute(
|
|
76
96
|
profile=profile,
|
|
@@ -79,6 +99,7 @@ class TaskContextTools(ToolBase):
|
|
|
79
99
|
workflow_node_id=workflow_node_id,
|
|
80
100
|
action=action,
|
|
81
101
|
payload=payload or {},
|
|
102
|
+
fields=fields or {},
|
|
82
103
|
)
|
|
83
104
|
|
|
84
105
|
@mcp.tool()
|
|
@@ -186,7 +207,8 @@ class TaskContextTools(ToolBase):
|
|
|
186
207
|
|
|
187
208
|
def runner(session_profile, context):
|
|
188
209
|
data = self._build_task_context(
|
|
189
|
-
|
|
210
|
+
profile=profile,
|
|
211
|
+
context=context,
|
|
190
212
|
app_key=app_key,
|
|
191
213
|
record_id=record_id,
|
|
192
214
|
workflow_node_id=workflow_node_id,
|
|
@@ -214,21 +236,30 @@ class TaskContextTools(ToolBase):
|
|
|
214
236
|
workflow_node_id: int,
|
|
215
237
|
action: str,
|
|
216
238
|
payload: dict[str, Any],
|
|
239
|
+
fields: dict[str, Any] | None = None,
|
|
217
240
|
) -> dict[str, Any]:
|
|
218
241
|
self._require_app_record_and_node(app_key, record_id, workflow_node_id)
|
|
219
242
|
normalized_action = (action or "").strip().lower()
|
|
220
|
-
if normalized_action not in {"approve", "reject", "rollback", "transfer", "urge"}:
|
|
243
|
+
if normalized_action not in {"approve", "reject", "rollback", "transfer", "urge", "save_only"}:
|
|
221
244
|
raise_tool_error(
|
|
222
245
|
QingflowApiError.not_supported(
|
|
223
|
-
"TASK_ACTION_UNSUPPORTED: action must be one of approve, reject, rollback, transfer, or
|
|
246
|
+
"TASK_ACTION_UNSUPPORTED: action must be one of approve, reject, rollback, transfer, urge, or save_only"
|
|
224
247
|
)
|
|
225
248
|
)
|
|
226
249
|
body = dict(payload or {})
|
|
250
|
+
field_updates = dict(fields or {})
|
|
251
|
+
if field_updates and body.get("answers") is not None:
|
|
252
|
+
raise_tool_error(
|
|
253
|
+
QingflowApiError.config_error(
|
|
254
|
+
"task actions must not provide payload.answers and fields at the same time; pass field changes through fields only"
|
|
255
|
+
)
|
|
256
|
+
)
|
|
227
257
|
|
|
228
258
|
def runner(session_profile, context):
|
|
229
259
|
try:
|
|
230
260
|
task_context = self._build_task_context(
|
|
231
|
-
|
|
261
|
+
profile=profile,
|
|
262
|
+
context=context,
|
|
232
263
|
app_key=app_key,
|
|
233
264
|
record_id=record_id,
|
|
234
265
|
workflow_node_id=workflow_node_id,
|
|
@@ -249,6 +280,10 @@ class TaskContextTools(ToolBase):
|
|
|
249
280
|
before_apply_status=None,
|
|
250
281
|
)
|
|
251
282
|
raise
|
|
283
|
+
if normalized_action == "save_only" and not field_updates:
|
|
284
|
+
raise_tool_error(
|
|
285
|
+
QingflowApiError.config_error("fields is required and must be non-empty for action 'save_only'")
|
|
286
|
+
)
|
|
252
287
|
capabilities = task_context.get("capabilities") or {}
|
|
253
288
|
available_actions = capabilities.get("available_actions") or []
|
|
254
289
|
if normalized_action not in available_actions:
|
|
@@ -264,14 +299,33 @@ class TaskContextTools(ToolBase):
|
|
|
264
299
|
f"payload.audit_feedback is required for action '{normalized_action}' on the current node"
|
|
265
300
|
)
|
|
266
301
|
)
|
|
302
|
+
if normalized_action == "urge" and field_updates:
|
|
303
|
+
raise_tool_error(
|
|
304
|
+
QingflowApiError.not_supported(
|
|
305
|
+
"TASK_ACTION_FIELDS_NOT_SUPPORTED: action 'urge' does not support fields because the downstream route does not accept task answers"
|
|
306
|
+
)
|
|
307
|
+
)
|
|
308
|
+
prepared_fields = None
|
|
309
|
+
if field_updates:
|
|
310
|
+
prepared_fields = self._prepare_task_field_update(
|
|
311
|
+
profile=profile,
|
|
312
|
+
context=context,
|
|
313
|
+
app_key=app_key,
|
|
314
|
+
record_id=record_id,
|
|
315
|
+
workflow_node_id=workflow_node_id,
|
|
316
|
+
task_context=task_context,
|
|
317
|
+
fields=field_updates,
|
|
318
|
+
)
|
|
267
319
|
before_apply_status = ((task_context.get("record") or {}).get("apply_status"))
|
|
268
|
-
runtime_baseline =
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
320
|
+
runtime_baseline = None
|
|
321
|
+
if normalized_action != "save_only":
|
|
322
|
+
runtime_baseline = self._capture_task_runtime_baseline(
|
|
323
|
+
profile=profile,
|
|
324
|
+
context=context,
|
|
325
|
+
app_key=app_key,
|
|
326
|
+
record_id=record_id,
|
|
327
|
+
workflow_node_id=workflow_node_id,
|
|
328
|
+
)
|
|
275
329
|
try:
|
|
276
330
|
raw = self._execute_task_action(
|
|
277
331
|
profile=profile,
|
|
@@ -280,6 +334,7 @@ class TaskContextTools(ToolBase):
|
|
|
280
334
|
workflow_node_id=workflow_node_id,
|
|
281
335
|
normalized_action=normalized_action,
|
|
282
336
|
payload=body,
|
|
337
|
+
prepared_fields=prepared_fields,
|
|
283
338
|
)
|
|
284
339
|
except QingflowApiError as error:
|
|
285
340
|
if error.backend_code == 46001:
|
|
@@ -296,24 +351,39 @@ class TaskContextTools(ToolBase):
|
|
|
296
351
|
)
|
|
297
352
|
raise
|
|
298
353
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
354
|
+
if normalized_action == "save_only":
|
|
355
|
+
verification, warnings = self._verify_task_save_only(
|
|
356
|
+
context=context,
|
|
357
|
+
app_key=app_key,
|
|
358
|
+
record_id=record_id,
|
|
359
|
+
workflow_node_id=workflow_node_id,
|
|
360
|
+
before_apply_status=before_apply_status,
|
|
361
|
+
expected_answers=((prepared_fields or {}).get("normalized_answers") or []),
|
|
362
|
+
task_context=task_context,
|
|
363
|
+
)
|
|
364
|
+
save_verified = bool(verification.get("fields_saved_verified")) and bool(verification.get("task_still_actionable"))
|
|
365
|
+
status = "success" if save_verified else "failed"
|
|
366
|
+
error_code = None if save_verified else "TASK_SAVE_ONLY_VERIFICATION_FAILED"
|
|
367
|
+
else:
|
|
368
|
+
verification, warnings = self._verify_task_action_runtime(
|
|
369
|
+
profile=profile,
|
|
370
|
+
context=context,
|
|
371
|
+
app_key=app_key,
|
|
372
|
+
record_id=record_id,
|
|
373
|
+
workflow_node_id=workflow_node_id,
|
|
374
|
+
action=normalized_action,
|
|
375
|
+
before_apply_status=before_apply_status,
|
|
376
|
+
runtime_baseline=runtime_baseline,
|
|
377
|
+
)
|
|
378
|
+
runtime_verified = bool(verification.get("runtime_continuation_verified"))
|
|
379
|
+
status = "success" if runtime_verified else "partial_success"
|
|
380
|
+
error_code = None if runtime_verified else "WORKFLOW_CONTINUATION_UNVERIFIED"
|
|
311
381
|
return {
|
|
312
382
|
"profile": raw.get("profile", profile),
|
|
313
383
|
"ws_id": raw.get("ws_id", session_profile.selected_ws_id),
|
|
314
|
-
"ok": bool(raw.get("ok", True)),
|
|
384
|
+
"ok": bool(raw.get("ok", True)) and status != "failed",
|
|
315
385
|
"status": status,
|
|
316
|
-
"error_code":
|
|
386
|
+
"error_code": error_code,
|
|
317
387
|
"request_route": raw.get("request_route") or self._request_route_payload(context),
|
|
318
388
|
"warnings": warnings,
|
|
319
389
|
"verification": verification,
|
|
@@ -328,6 +398,7 @@ class TaskContextTools(ToolBase):
|
|
|
328
398
|
"selection": {"action": normalized_action},
|
|
329
399
|
"result": raw.get("result"),
|
|
330
400
|
"human_review": True,
|
|
401
|
+
"field_update_applied": bool(field_updates),
|
|
331
402
|
},
|
|
332
403
|
}
|
|
333
404
|
|
|
@@ -342,10 +413,18 @@ class TaskContextTools(ToolBase):
|
|
|
342
413
|
workflow_node_id: int,
|
|
343
414
|
normalized_action: str,
|
|
344
415
|
payload: dict[str, Any],
|
|
416
|
+
prepared_fields: dict[str, Any] | None,
|
|
345
417
|
) -> dict[str, Any]:
|
|
418
|
+
merged_answers = None
|
|
419
|
+
if isinstance(prepared_fields, dict):
|
|
420
|
+
candidate_answers = prepared_fields.get("merged_answers")
|
|
421
|
+
if isinstance(candidate_answers, list):
|
|
422
|
+
merged_answers = candidate_answers
|
|
346
423
|
if normalized_action == "approve":
|
|
347
424
|
action_payload = dict(payload)
|
|
348
425
|
action_payload["nodeId"] = workflow_node_id
|
|
426
|
+
if merged_answers is not None:
|
|
427
|
+
action_payload["answers"] = merged_answers
|
|
349
428
|
return self._approval_tools.record_approve(
|
|
350
429
|
profile=profile,
|
|
351
430
|
app_key=app_key,
|
|
@@ -355,6 +434,8 @@ class TaskContextTools(ToolBase):
|
|
|
355
434
|
if normalized_action == "reject":
|
|
356
435
|
action_payload = dict(payload)
|
|
357
436
|
action_payload["nodeId"] = workflow_node_id
|
|
437
|
+
if merged_answers is not None:
|
|
438
|
+
action_payload["answers"] = merged_answers
|
|
358
439
|
if not self._extract_audit_feedback(action_payload):
|
|
359
440
|
raise_tool_error(QingflowApiError.config_error("payload.audit_feedback is required for reject"))
|
|
360
441
|
return self._approval_tools.record_reject(
|
|
@@ -372,6 +453,8 @@ class TaskContextTools(ToolBase):
|
|
|
372
453
|
audit_feedback = self._extract_audit_feedback(payload)
|
|
373
454
|
if audit_feedback:
|
|
374
455
|
action_payload["auditFeedback"] = audit_feedback
|
|
456
|
+
if merged_answers is not None:
|
|
457
|
+
action_payload["answers"] = merged_answers
|
|
375
458
|
return self._approval_tools.record_rollback(
|
|
376
459
|
profile=profile,
|
|
377
460
|
app_key=app_key,
|
|
@@ -387,12 +470,24 @@ class TaskContextTools(ToolBase):
|
|
|
387
470
|
audit_feedback = self._extract_audit_feedback(payload)
|
|
388
471
|
if audit_feedback:
|
|
389
472
|
action_payload["auditFeedback"] = audit_feedback
|
|
473
|
+
if merged_answers is not None:
|
|
474
|
+
action_payload["answers"] = merged_answers
|
|
390
475
|
return self._approval_tools.record_transfer(
|
|
391
476
|
profile=profile,
|
|
392
477
|
app_key=app_key,
|
|
393
478
|
apply_id=record_id,
|
|
394
479
|
payload=action_payload,
|
|
395
480
|
)
|
|
481
|
+
if normalized_action == "save_only":
|
|
482
|
+
if merged_answers is None:
|
|
483
|
+
raise_tool_error(QingflowApiError.config_error("fields is required for action 'save_only'"))
|
|
484
|
+
return self._task_save_only(
|
|
485
|
+
profile=profile,
|
|
486
|
+
app_key=app_key,
|
|
487
|
+
record_id=record_id,
|
|
488
|
+
workflow_node_id=workflow_node_id,
|
|
489
|
+
merged_answers=merged_answers,
|
|
490
|
+
)
|
|
396
491
|
return self._task_tools.task_urge(
|
|
397
492
|
profile=profile,
|
|
398
493
|
app_key=app_key,
|
|
@@ -522,6 +617,121 @@ class TaskContextTools(ToolBase):
|
|
|
522
617
|
)
|
|
523
618
|
return verification, warnings
|
|
524
619
|
|
|
620
|
+
def _verify_task_save_only(
|
|
621
|
+
self,
|
|
622
|
+
*,
|
|
623
|
+
context: BackendRequestContext,
|
|
624
|
+
app_key: str,
|
|
625
|
+
record_id: int,
|
|
626
|
+
workflow_node_id: int,
|
|
627
|
+
before_apply_status: Any,
|
|
628
|
+
expected_answers: list[dict[str, Any]],
|
|
629
|
+
task_context: dict[str, Any],
|
|
630
|
+
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
631
|
+
verification: dict[str, Any] = {
|
|
632
|
+
"action_executed": True,
|
|
633
|
+
"scope": "task_field_save",
|
|
634
|
+
"runtime_continuation_verified": False,
|
|
635
|
+
"task_context_visibility_verified": True,
|
|
636
|
+
"fields_saved_verified": False,
|
|
637
|
+
"task_still_actionable": False,
|
|
638
|
+
"workflow_not_advanced": False,
|
|
639
|
+
"before_apply_status": before_apply_status,
|
|
640
|
+
}
|
|
641
|
+
warnings: list[dict[str, Any]] = []
|
|
642
|
+
try:
|
|
643
|
+
detail = self.backend.request(
|
|
644
|
+
"GET",
|
|
645
|
+
context,
|
|
646
|
+
f"/app/{app_key}/apply/{record_id}",
|
|
647
|
+
params={"role": 3, "listType": 1, "auditNodeId": workflow_node_id},
|
|
648
|
+
)
|
|
649
|
+
except QingflowApiError as error:
|
|
650
|
+
verification["record_state_readable"] = False
|
|
651
|
+
verification["task_context_visibility_verified"] = False
|
|
652
|
+
verification["transport_error"] = {
|
|
653
|
+
"http_status": error.http_status,
|
|
654
|
+
"backend_code": error.backend_code,
|
|
655
|
+
"category": error.category,
|
|
656
|
+
}
|
|
657
|
+
warnings.append(
|
|
658
|
+
{
|
|
659
|
+
"code": "TASK_SAVE_ONLY_UNVERIFIED",
|
|
660
|
+
"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.",
|
|
661
|
+
}
|
|
662
|
+
)
|
|
663
|
+
return verification, warnings
|
|
664
|
+
|
|
665
|
+
verification["record_state_readable"] = True
|
|
666
|
+
verification["task_still_actionable"] = True
|
|
667
|
+
after_apply_status = detail.get("applyStatus") if isinstance(detail, dict) else None
|
|
668
|
+
verification["after_apply_status"] = after_apply_status
|
|
669
|
+
verification["workflow_not_advanced"] = after_apply_status == before_apply_status
|
|
670
|
+
current_record = task_context.get("record") if isinstance(task_context.get("record"), dict) else {}
|
|
671
|
+
actual_answers = detail.get("answers") if isinstance(detail, dict) and isinstance(detail.get("answers"), list) else []
|
|
672
|
+
expected_by_id = {
|
|
673
|
+
que_id: answer
|
|
674
|
+
for answer in expected_answers
|
|
675
|
+
if isinstance(answer, dict) and (que_id := _coerce_count(answer.get("queId"))) is not None and que_id > 0
|
|
676
|
+
}
|
|
677
|
+
actual_by_id = {
|
|
678
|
+
que_id: answer
|
|
679
|
+
for answer in actual_answers
|
|
680
|
+
if isinstance(answer, dict) and (que_id := _coerce_count(answer.get("queId"))) is not None and que_id > 0
|
|
681
|
+
}
|
|
682
|
+
update_schema = task_context.get("update_schema") if isinstance(task_context.get("update_schema"), dict) else {}
|
|
683
|
+
writable_titles = {
|
|
684
|
+
item.get("title"): item
|
|
685
|
+
for item in (update_schema.get("writable_fields") or [])
|
|
686
|
+
if isinstance(item, dict) and item.get("title")
|
|
687
|
+
}
|
|
688
|
+
missing_fields: list[dict[str, Any]] = []
|
|
689
|
+
mismatched_fields: list[dict[str, Any]] = []
|
|
690
|
+
for que_id, expected in expected_by_id.items():
|
|
691
|
+
actual = actual_by_id.get(que_id)
|
|
692
|
+
title = None
|
|
693
|
+
if isinstance(current_record, dict):
|
|
694
|
+
for answer in (current_record.get("answers") or []):
|
|
695
|
+
if isinstance(answer, dict) and _coerce_count(answer.get("queId")) == que_id:
|
|
696
|
+
title = answer.get("queTitle")
|
|
697
|
+
break
|
|
698
|
+
if title is None:
|
|
699
|
+
title = next((key for key, item in writable_titles.items() if isinstance(item, dict) and item.get("field_id") == que_id), None)
|
|
700
|
+
field_payload = {"que_id": que_id, "que_title": title}
|
|
701
|
+
if actual is None:
|
|
702
|
+
missing_fields.append(field_payload)
|
|
703
|
+
continue
|
|
704
|
+
expected_value = _canonicalize_answer_value_for_compare(expected, None)
|
|
705
|
+
actual_value = _canonicalize_answer_value_for_compare(actual, None)
|
|
706
|
+
if _canonical_value_is_empty(expected_value):
|
|
707
|
+
continue
|
|
708
|
+
if actual_value != expected_value:
|
|
709
|
+
mismatched_fields.append(
|
|
710
|
+
{
|
|
711
|
+
**field_payload,
|
|
712
|
+
"expected": expected_value,
|
|
713
|
+
"actual": actual_value,
|
|
714
|
+
}
|
|
715
|
+
)
|
|
716
|
+
verification["missing_fields"] = missing_fields
|
|
717
|
+
verification["mismatched_fields"] = mismatched_fields
|
|
718
|
+
verification["fields_saved_verified"] = not missing_fields and not mismatched_fields
|
|
719
|
+
if not verification["workflow_not_advanced"]:
|
|
720
|
+
warnings.append(
|
|
721
|
+
{
|
|
722
|
+
"code": "TASK_SAVE_ONLY_ADVANCED_WORKFLOW",
|
|
723
|
+
"message": "save_only unexpectedly changed the workflow runtime state; the task should have remained on the current node.",
|
|
724
|
+
}
|
|
725
|
+
)
|
|
726
|
+
if not verification["fields_saved_verified"]:
|
|
727
|
+
warnings.append(
|
|
728
|
+
{
|
|
729
|
+
"code": "TASK_SAVE_ONLY_FIELD_VERIFICATION_FAILED",
|
|
730
|
+
"message": "save_only completed, but MCP could not verify that all requested field changes were persisted on the current task node.",
|
|
731
|
+
}
|
|
732
|
+
)
|
|
733
|
+
return verification, warnings
|
|
734
|
+
|
|
525
735
|
def _capture_task_runtime_baseline(
|
|
526
736
|
self,
|
|
527
737
|
*,
|
|
@@ -695,7 +905,8 @@ class TaskContextTools(ToolBase):
|
|
|
695
905
|
|
|
696
906
|
def runner(session_profile, context):
|
|
697
907
|
task_context = self._build_task_context(
|
|
698
|
-
|
|
908
|
+
profile=profile,
|
|
909
|
+
context=context,
|
|
699
910
|
app_key=app_key,
|
|
700
911
|
record_id=record_id,
|
|
701
912
|
workflow_node_id=workflow_node_id,
|
|
@@ -876,7 +1087,8 @@ class TaskContextTools(ToolBase):
|
|
|
876
1087
|
|
|
877
1088
|
def runner(session_profile, context):
|
|
878
1089
|
task_context = self._build_task_context(
|
|
879
|
-
|
|
1090
|
+
profile=profile,
|
|
1091
|
+
context=context,
|
|
880
1092
|
app_key=app_key,
|
|
881
1093
|
record_id=record_id,
|
|
882
1094
|
workflow_node_id=workflow_node_id,
|
|
@@ -929,6 +1141,7 @@ class TaskContextTools(ToolBase):
|
|
|
929
1141
|
|
|
930
1142
|
def _build_task_context(
|
|
931
1143
|
self,
|
|
1144
|
+
profile: str,
|
|
932
1145
|
context: BackendRequestContext,
|
|
933
1146
|
*,
|
|
934
1147
|
app_key: str,
|
|
@@ -987,7 +1200,20 @@ class TaskContextTools(ToolBase):
|
|
|
987
1200
|
)
|
|
988
1201
|
transfer_items = _approval_page_items(transfer_result)
|
|
989
1202
|
|
|
990
|
-
|
|
1203
|
+
update_schema_state = self._build_task_update_schema(
|
|
1204
|
+
profile=profile,
|
|
1205
|
+
context=context,
|
|
1206
|
+
app_key=app_key,
|
|
1207
|
+
record_id=record_id,
|
|
1208
|
+
workflow_node_id=workflow_node_id,
|
|
1209
|
+
node_info=node_info,
|
|
1210
|
+
current_answers=detail.get("answers") or [],
|
|
1211
|
+
)
|
|
1212
|
+
update_schema = update_schema_state["public_schema"]
|
|
1213
|
+
capabilities = self._build_capabilities(
|
|
1214
|
+
node_info,
|
|
1215
|
+
allow_save_only=bool(update_schema.get("writable_fields")),
|
|
1216
|
+
)
|
|
991
1217
|
visibility = self._build_visibility(node_info, detail)
|
|
992
1218
|
return {
|
|
993
1219
|
"task": {
|
|
@@ -997,6 +1223,11 @@ class TaskContextTools(ToolBase):
|
|
|
997
1223
|
"workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
|
|
998
1224
|
"actionable": True,
|
|
999
1225
|
},
|
|
1226
|
+
"node": {
|
|
1227
|
+
"workflow_node_id": workflow_node_id,
|
|
1228
|
+
"workflow_node_name": node_info.get("auditNodeName") or node_info.get("nodeName"),
|
|
1229
|
+
"raw": dict(node_info),
|
|
1230
|
+
},
|
|
1000
1231
|
"record": {
|
|
1001
1232
|
"apply_id": detail.get("applyId", record_id),
|
|
1002
1233
|
"apply_status": detail.get("applyStatus"),
|
|
@@ -1010,6 +1241,8 @@ class TaskContextTools(ToolBase):
|
|
|
1010
1241
|
"capabilities": capabilities,
|
|
1011
1242
|
"field_permissions": {
|
|
1012
1243
|
"que_auth_setting": node_info.get("queAuthSetting") or [],
|
|
1244
|
+
"editable_question_ids": update_schema_state["editable_question_ids"],
|
|
1245
|
+
"editable_question_ids_source": update_schema_state["editable_question_ids_source"],
|
|
1013
1246
|
},
|
|
1014
1247
|
"visibility": visibility,
|
|
1015
1248
|
"associated_reports": associated_reports,
|
|
@@ -1023,6 +1256,7 @@ class TaskContextTools(ToolBase):
|
|
|
1023
1256
|
"history_count": None,
|
|
1024
1257
|
"qrobot_log_visible": visibility["qrobot_record_visible"],
|
|
1025
1258
|
},
|
|
1259
|
+
"update_schema": update_schema,
|
|
1026
1260
|
}
|
|
1027
1261
|
|
|
1028
1262
|
def _normalize_task_item(self, raw: dict[str, Any], *, task_box: str, flow_status: str) -> dict[str, Any]:
|
|
@@ -1071,7 +1305,7 @@ class TaskContextTools(ToolBase):
|
|
|
1071
1305
|
)
|
|
1072
1306
|
)
|
|
1073
1307
|
|
|
1074
|
-
def _build_capabilities(self, node_info: dict[str, Any]) -> dict[str, Any]:
|
|
1308
|
+
def _build_capabilities(self, node_info: dict[str, Any], *, allow_save_only: bool) -> dict[str, Any]:
|
|
1075
1309
|
available_actions = ["approve"]
|
|
1076
1310
|
if self._coerce_bool(node_info.get("rejectBtnStatus")):
|
|
1077
1311
|
available_actions.append("reject")
|
|
@@ -1081,6 +1315,8 @@ class TaskContextTools(ToolBase):
|
|
|
1081
1315
|
available_actions.append("transfer")
|
|
1082
1316
|
if self._coerce_bool(node_info.get("canUrge")):
|
|
1083
1317
|
available_actions.append("urge")
|
|
1318
|
+
if allow_save_only:
|
|
1319
|
+
available_actions.append("save_only")
|
|
1084
1320
|
|
|
1085
1321
|
visible_but_unimplemented_actions: list[str] = []
|
|
1086
1322
|
if self._coerce_bool(node_info.get("canRevoke")):
|
|
@@ -1107,6 +1343,362 @@ class TaskContextTools(ToolBase):
|
|
|
1107
1343
|
},
|
|
1108
1344
|
}
|
|
1109
1345
|
|
|
1346
|
+
def _build_task_update_schema(
|
|
1347
|
+
self,
|
|
1348
|
+
profile: str,
|
|
1349
|
+
context: BackendRequestContext,
|
|
1350
|
+
*,
|
|
1351
|
+
app_key: str,
|
|
1352
|
+
record_id: int,
|
|
1353
|
+
workflow_node_id: int,
|
|
1354
|
+
node_info: dict[str, Any],
|
|
1355
|
+
current_answers: Any,
|
|
1356
|
+
) -> dict[str, Any]:
|
|
1357
|
+
try:
|
|
1358
|
+
app_schema = self._record_tools._get_form_schema(profile, context, app_key, force_refresh=False)
|
|
1359
|
+
except QingflowApiError as error:
|
|
1360
|
+
public_schema: JSONObject = {
|
|
1361
|
+
"schema_scope": "task_update_ready",
|
|
1362
|
+
"writable_fields": [],
|
|
1363
|
+
"payload_template": {},
|
|
1364
|
+
"blockers": ["TASK_UPDATE_SCHEMA_UNAVAILABLE"],
|
|
1365
|
+
"warnings": [
|
|
1366
|
+
{
|
|
1367
|
+
"code": "TASK_UPDATE_SCHEMA_UNAVAILABLE",
|
|
1368
|
+
"message": "task detail could not load the form schema for the current app, so node-scoped update schema is unavailable.",
|
|
1369
|
+
}
|
|
1370
|
+
],
|
|
1371
|
+
"selection": {
|
|
1372
|
+
"app_key": app_key,
|
|
1373
|
+
"record_id": record_id,
|
|
1374
|
+
"workflow_node_id": workflow_node_id,
|
|
1375
|
+
},
|
|
1376
|
+
"transport_error": {
|
|
1377
|
+
"http_status": error.http_status,
|
|
1378
|
+
"backend_code": error.backend_code,
|
|
1379
|
+
"category": error.category,
|
|
1380
|
+
},
|
|
1381
|
+
}
|
|
1382
|
+
return {
|
|
1383
|
+
"public_schema": public_schema,
|
|
1384
|
+
"index": None,
|
|
1385
|
+
"editable_question_ids": [],
|
|
1386
|
+
"effective_editable_question_ids": [],
|
|
1387
|
+
"editable_question_ids_source": "schema_unavailable",
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
question_relations = _collect_question_relations(app_schema)
|
|
1391
|
+
linked_field_ids = _collect_linked_required_field_ids(question_relations)
|
|
1392
|
+
base_index = _build_applicant_top_level_field_index(app_schema)
|
|
1393
|
+
linked_field_ids.update(_collect_option_linked_field_ids(base_index))
|
|
1394
|
+
linked_hidden_index = _build_applicant_hidden_linked_top_level_field_index(
|
|
1395
|
+
app_schema,
|
|
1396
|
+
linked_field_ids=linked_field_ids,
|
|
1397
|
+
)
|
|
1398
|
+
index = _merge_field_indexes(base_index, linked_hidden_index)
|
|
1399
|
+
editable_question_ids, schema_warnings, source = self._resolve_task_editable_question_ids(
|
|
1400
|
+
context,
|
|
1401
|
+
app_key=app_key,
|
|
1402
|
+
workflow_node_id=workflow_node_id,
|
|
1403
|
+
node_info=node_info,
|
|
1404
|
+
)
|
|
1405
|
+
effective_editable_ids = set(editable_question_ids)
|
|
1406
|
+
for field in index.by_id.values():
|
|
1407
|
+
if field.que_type in SUBTABLE_QUE_TYPES and (_subtable_descendant_ids(field) & set(editable_question_ids)):
|
|
1408
|
+
effective_editable_ids.add(field.que_id)
|
|
1409
|
+
writable_fields: list[JSONObject] = []
|
|
1410
|
+
linkage_payloads_by_field_id = _build_static_schema_linkage_payloads(
|
|
1411
|
+
index=index,
|
|
1412
|
+
question_relations=question_relations,
|
|
1413
|
+
)
|
|
1414
|
+
for field in index.by_id.values():
|
|
1415
|
+
if field.que_type in LAYOUT_ONLY_QUE_TYPES or field.que_id not in effective_editable_ids:
|
|
1416
|
+
continue
|
|
1417
|
+
editable_field = _clone_form_field(field, readonly=False)
|
|
1418
|
+
write_hints = self._record_tools._schema_write_hints(editable_field)
|
|
1419
|
+
if not bool(write_hints.get("writable")):
|
|
1420
|
+
continue
|
|
1421
|
+
writable_fields.append(
|
|
1422
|
+
self._record_tools._ready_schema_field_payload(
|
|
1423
|
+
profile,
|
|
1424
|
+
context,
|
|
1425
|
+
editable_field,
|
|
1426
|
+
ws_id=context.ws_id,
|
|
1427
|
+
required_override=False,
|
|
1428
|
+
linkage_payloads_by_field_id=linkage_payloads_by_field_id,
|
|
1429
|
+
)
|
|
1430
|
+
)
|
|
1431
|
+
blockers: list[str] = []
|
|
1432
|
+
if not writable_fields:
|
|
1433
|
+
blockers.append("NO_TASK_EDITABLE_FIELDS")
|
|
1434
|
+
schema_warnings.append(
|
|
1435
|
+
{
|
|
1436
|
+
"code": "NO_TASK_EDITABLE_FIELDS",
|
|
1437
|
+
"message": "the current task node does not expose any writable fields for task-scoped edits.",
|
|
1438
|
+
}
|
|
1439
|
+
)
|
|
1440
|
+
public_schema: JSONObject = {
|
|
1441
|
+
"schema_scope": "task_update_ready",
|
|
1442
|
+
"writable_fields": writable_fields,
|
|
1443
|
+
"payload_template": {
|
|
1444
|
+
item["title"]: self._record_tools._ready_schema_template_value(item)
|
|
1445
|
+
for item in writable_fields
|
|
1446
|
+
if isinstance(item, dict) and item.get("title")
|
|
1447
|
+
},
|
|
1448
|
+
"blockers": blockers,
|
|
1449
|
+
"warnings": schema_warnings,
|
|
1450
|
+
"selection": {
|
|
1451
|
+
"app_key": app_key,
|
|
1452
|
+
"record_id": record_id,
|
|
1453
|
+
"workflow_node_id": workflow_node_id,
|
|
1454
|
+
},
|
|
1455
|
+
}
|
|
1456
|
+
return {
|
|
1457
|
+
"public_schema": public_schema,
|
|
1458
|
+
"index": index,
|
|
1459
|
+
"editable_question_ids": sorted(editable_question_ids),
|
|
1460
|
+
"effective_editable_question_ids": sorted(effective_editable_ids),
|
|
1461
|
+
"editable_question_ids_source": source,
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
def _resolve_task_editable_question_ids(
|
|
1465
|
+
self,
|
|
1466
|
+
context: BackendRequestContext,
|
|
1467
|
+
*,
|
|
1468
|
+
app_key: str,
|
|
1469
|
+
workflow_node_id: int,
|
|
1470
|
+
node_info: dict[str, Any],
|
|
1471
|
+
) -> tuple[set[int], list[JSONObject], str]:
|
|
1472
|
+
warnings: list[JSONObject] = []
|
|
1473
|
+
try:
|
|
1474
|
+
payload = self.backend.request(
|
|
1475
|
+
"GET",
|
|
1476
|
+
context,
|
|
1477
|
+
f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
|
|
1478
|
+
)
|
|
1479
|
+
question_ids = self._extract_question_ids(payload)
|
|
1480
|
+
if question_ids:
|
|
1481
|
+
return question_ids, warnings, "workflow_editable_que_ids"
|
|
1482
|
+
except QingflowApiError as error:
|
|
1483
|
+
if error.backend_code not in {40002, 40027, 404} and error.http_status != 404:
|
|
1484
|
+
raise
|
|
1485
|
+
warnings.append(
|
|
1486
|
+
{
|
|
1487
|
+
"code": "TASK_EDITABLE_IDS_FALLBACK",
|
|
1488
|
+
"message": "editable question ids endpoint is unavailable in the current route; task update schema fell back to queAuthSetting and may be conservative.",
|
|
1489
|
+
}
|
|
1490
|
+
)
|
|
1491
|
+
fallback_ids = self._editable_ids_from_que_auth_setting(node_info.get("queAuthSetting"))
|
|
1492
|
+
return fallback_ids, warnings, "que_auth_setting"
|
|
1493
|
+
|
|
1494
|
+
def _extract_question_ids(self, payload: Any) -> set[int]:
|
|
1495
|
+
candidates: list[Any] = []
|
|
1496
|
+
if isinstance(payload, list):
|
|
1497
|
+
candidates = payload
|
|
1498
|
+
elif isinstance(payload, dict):
|
|
1499
|
+
for key in ("editableQueIds", "editableQuestionIds", "queIds", "questionIds", "ids", "list", "result"):
|
|
1500
|
+
value = payload.get(key)
|
|
1501
|
+
if isinstance(value, list):
|
|
1502
|
+
candidates = value
|
|
1503
|
+
break
|
|
1504
|
+
question_ids: set[int] = set()
|
|
1505
|
+
for item in candidates:
|
|
1506
|
+
if isinstance(item, int) and item > 0:
|
|
1507
|
+
question_ids.add(item)
|
|
1508
|
+
continue
|
|
1509
|
+
if isinstance(item, dict):
|
|
1510
|
+
for key in ("queId", "questionId", "id"):
|
|
1511
|
+
value = _coerce_count(item.get(key))
|
|
1512
|
+
if value is not None and value > 0:
|
|
1513
|
+
question_ids.add(value)
|
|
1514
|
+
break
|
|
1515
|
+
return question_ids
|
|
1516
|
+
|
|
1517
|
+
def _editable_ids_from_que_auth_setting(self, payload: Any) -> set[int]:
|
|
1518
|
+
if not isinstance(payload, list):
|
|
1519
|
+
return set()
|
|
1520
|
+
editable_ids: set[int] = set()
|
|
1521
|
+
for item in payload:
|
|
1522
|
+
if not isinstance(item, dict):
|
|
1523
|
+
continue
|
|
1524
|
+
que_id = _coerce_count(item.get("queId") or item.get("questionId"))
|
|
1525
|
+
if que_id is None or que_id <= 0:
|
|
1526
|
+
continue
|
|
1527
|
+
explicit_editable_keys = ("editable", "writable", "canEdit", "editStatus", "beingEditable")
|
|
1528
|
+
explicit_readonly_keys = ("readonly", "beingReadonly")
|
|
1529
|
+
if any(key in item for key in explicit_editable_keys):
|
|
1530
|
+
if any(bool(item.get(key)) for key in explicit_editable_keys):
|
|
1531
|
+
editable_ids.add(que_id)
|
|
1532
|
+
continue
|
|
1533
|
+
if any(bool(item.get(key)) for key in explicit_readonly_keys):
|
|
1534
|
+
continue
|
|
1535
|
+
if item.get("readable") is False:
|
|
1536
|
+
continue
|
|
1537
|
+
if item.get("readable") is True or item.get("visible") is True:
|
|
1538
|
+
editable_ids.add(que_id)
|
|
1539
|
+
return editable_ids
|
|
1540
|
+
|
|
1541
|
+
def _prepare_task_field_update(
|
|
1542
|
+
self,
|
|
1543
|
+
*,
|
|
1544
|
+
profile: str,
|
|
1545
|
+
context: BackendRequestContext,
|
|
1546
|
+
app_key: str,
|
|
1547
|
+
record_id: int,
|
|
1548
|
+
workflow_node_id: int,
|
|
1549
|
+
task_context: dict[str, Any],
|
|
1550
|
+
fields: dict[str, Any],
|
|
1551
|
+
) -> dict[str, Any]:
|
|
1552
|
+
record = task_context.get("record") if isinstance(task_context.get("record"), dict) else {}
|
|
1553
|
+
current_answers = record.get("answers") if isinstance(record.get("answers"), list) else []
|
|
1554
|
+
node = task_context.get("node") if isinstance(task_context.get("node"), dict) else {}
|
|
1555
|
+
node_info = node.get("raw") if isinstance(node.get("raw"), dict) else {}
|
|
1556
|
+
schema_state = self._build_task_update_schema(
|
|
1557
|
+
profile=profile,
|
|
1558
|
+
context=context,
|
|
1559
|
+
app_key=app_key,
|
|
1560
|
+
record_id=record_id,
|
|
1561
|
+
workflow_node_id=workflow_node_id,
|
|
1562
|
+
node_info=node_info,
|
|
1563
|
+
current_answers=current_answers,
|
|
1564
|
+
)
|
|
1565
|
+
update_schema = schema_state["public_schema"]
|
|
1566
|
+
if update_schema.get("blockers"):
|
|
1567
|
+
raise_tool_error(
|
|
1568
|
+
QingflowApiError(
|
|
1569
|
+
category="config",
|
|
1570
|
+
message="task field update is blocked because the current node does not expose a usable update schema",
|
|
1571
|
+
details={
|
|
1572
|
+
"error_code": "TASK_UPDATE_SCHEMA_BLOCKED",
|
|
1573
|
+
"update_schema": update_schema,
|
|
1574
|
+
},
|
|
1575
|
+
)
|
|
1576
|
+
)
|
|
1577
|
+
preflight = self._record_tools._build_record_write_preflight(
|
|
1578
|
+
profile=profile,
|
|
1579
|
+
context=context,
|
|
1580
|
+
operation="update",
|
|
1581
|
+
app_key=app_key,
|
|
1582
|
+
apply_id=record_id,
|
|
1583
|
+
answers=[],
|
|
1584
|
+
fields=fields,
|
|
1585
|
+
force_refresh_form=False,
|
|
1586
|
+
view_id=None,
|
|
1587
|
+
list_type=None,
|
|
1588
|
+
view_key=None,
|
|
1589
|
+
view_name=None,
|
|
1590
|
+
existing_answers_override=current_answers,
|
|
1591
|
+
)
|
|
1592
|
+
index = schema_state["index"]
|
|
1593
|
+
effective_editable_ids = set(schema_state["effective_editable_question_ids"])
|
|
1594
|
+
scoped_field_errors = self._task_scope_field_errors(
|
|
1595
|
+
normalized_answers=preflight.get("normalized_answers") or [],
|
|
1596
|
+
index=index,
|
|
1597
|
+
effective_editable_ids=effective_editable_ids,
|
|
1598
|
+
)
|
|
1599
|
+
field_errors = list(preflight.get("field_errors") or [])
|
|
1600
|
+
field_errors.extend(scoped_field_errors)
|
|
1601
|
+
blockers = list(preflight.get("blockers") or [])
|
|
1602
|
+
if scoped_field_errors:
|
|
1603
|
+
blockers.append("payload writes fields that are not editable on the current task node")
|
|
1604
|
+
confirmation_requests = list(preflight.get("confirmation_requests") or [])
|
|
1605
|
+
if field_errors or confirmation_requests or blockers:
|
|
1606
|
+
raise_tool_error(
|
|
1607
|
+
QingflowApiError(
|
|
1608
|
+
category="config",
|
|
1609
|
+
message="task field update preflight was blocked",
|
|
1610
|
+
details={
|
|
1611
|
+
"error_code": "TASK_FIELD_PLAN_BLOCKED",
|
|
1612
|
+
"blockers": blockers,
|
|
1613
|
+
"field_errors": field_errors,
|
|
1614
|
+
"confirmation_requests": confirmation_requests,
|
|
1615
|
+
"update_schema": update_schema,
|
|
1616
|
+
"recommended_next_actions": preflight.get("recommended_next_actions") or [],
|
|
1617
|
+
},
|
|
1618
|
+
)
|
|
1619
|
+
)
|
|
1620
|
+
normalized_answers = [item for item in (preflight.get("normalized_answers") or []) if isinstance(item, dict)]
|
|
1621
|
+
merged_answers = self._record_tools._merge_record_answers(current_answers, normalized_answers)
|
|
1622
|
+
return {
|
|
1623
|
+
"update_schema": update_schema,
|
|
1624
|
+
"normalized_answers": normalized_answers,
|
|
1625
|
+
"merged_answers": merged_answers,
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
def _task_scope_field_errors(
|
|
1629
|
+
self,
|
|
1630
|
+
*,
|
|
1631
|
+
normalized_answers: list[dict[str, Any]],
|
|
1632
|
+
index: Any,
|
|
1633
|
+
effective_editable_ids: set[int],
|
|
1634
|
+
) -> list[dict[str, Any]]:
|
|
1635
|
+
if index is None:
|
|
1636
|
+
return []
|
|
1637
|
+
field_errors: list[dict[str, Any]] = []
|
|
1638
|
+
for answer in normalized_answers:
|
|
1639
|
+
que_id = _coerce_count(answer.get("queId"))
|
|
1640
|
+
if que_id is None or que_id <= 0:
|
|
1641
|
+
continue
|
|
1642
|
+
field = index.by_id.get(str(que_id))
|
|
1643
|
+
field_payload = _field_ref_payload(field) if field is not None else {"que_id": que_id}
|
|
1644
|
+
if que_id not in effective_editable_ids:
|
|
1645
|
+
field_errors.append(
|
|
1646
|
+
{
|
|
1647
|
+
"location": field.que_title if field is not None else str(que_id),
|
|
1648
|
+
"message": "field is not editable on the current task node",
|
|
1649
|
+
"error_code": "TASK_FIELD_NOT_EDITABLE",
|
|
1650
|
+
"field": field_payload,
|
|
1651
|
+
}
|
|
1652
|
+
)
|
|
1653
|
+
continue
|
|
1654
|
+
if field is None or field.que_type not in SUBTABLE_QUE_TYPES:
|
|
1655
|
+
continue
|
|
1656
|
+
table_values = answer.get("tableValues") if isinstance(answer.get("tableValues"), list) else []
|
|
1657
|
+
subtable_index = self._record_tools._subtable_field_index_optional(field)
|
|
1658
|
+
for row_ordinal, row in enumerate(table_values, start=1):
|
|
1659
|
+
row_cells = [item for item in row if isinstance(item, dict)] if isinstance(row, list) else []
|
|
1660
|
+
for cell in row_cells:
|
|
1661
|
+
cell_que_id = _coerce_count(cell.get("queId"))
|
|
1662
|
+
if cell_que_id is None or cell_que_id <= 0 or cell_que_id in effective_editable_ids:
|
|
1663
|
+
continue
|
|
1664
|
+
subfield = subtable_index.by_id.get(str(cell_que_id)) if subtable_index is not None else None
|
|
1665
|
+
field_errors.append(
|
|
1666
|
+
{
|
|
1667
|
+
"location": f"{field.que_title}[{row_ordinal}].{subfield.que_title if subfield is not None else cell_que_id}",
|
|
1668
|
+
"message": "subtable field is not editable on the current task node",
|
|
1669
|
+
"error_code": "TASK_FIELD_NOT_EDITABLE",
|
|
1670
|
+
"field": _field_ref_payload(subfield) if subfield is not None else {"que_id": cell_que_id},
|
|
1671
|
+
}
|
|
1672
|
+
)
|
|
1673
|
+
return field_errors
|
|
1674
|
+
|
|
1675
|
+
def _task_save_only(
|
|
1676
|
+
self,
|
|
1677
|
+
*,
|
|
1678
|
+
profile: str,
|
|
1679
|
+
app_key: str,
|
|
1680
|
+
record_id: int,
|
|
1681
|
+
workflow_node_id: int,
|
|
1682
|
+
merged_answers: list[dict[str, Any]],
|
|
1683
|
+
) -> dict[str, Any]:
|
|
1684
|
+
def runner(session_profile, context):
|
|
1685
|
+
result = self.backend.request(
|
|
1686
|
+
"POST",
|
|
1687
|
+
context,
|
|
1688
|
+
f"/app/{app_key}/apply/{record_id}",
|
|
1689
|
+
json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": merged_answers},
|
|
1690
|
+
)
|
|
1691
|
+
return {
|
|
1692
|
+
"profile": profile,
|
|
1693
|
+
"ws_id": session_profile.selected_ws_id,
|
|
1694
|
+
"app_key": app_key,
|
|
1695
|
+
"apply_id": record_id,
|
|
1696
|
+
"result": result,
|
|
1697
|
+
"request_route": self._request_route_payload(context),
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
return self._run(profile, runner)
|
|
1701
|
+
|
|
1110
1702
|
def _build_visibility(self, node_info: dict[str, Any], detail: dict[str, Any]) -> dict[str, bool]:
|
|
1111
1703
|
return {
|
|
1112
1704
|
"comment_visible": self._coerce_bool(node_info.get("commentStatus")),
|