@josephyan/qingflow-app-builder-mcp 0.2.0-beta.4 → 0.2.0-beta.41

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.
Files changed (39) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +3 -1
  4. package/skills/qingflow-app-builder/SKILL.md +154 -22
  5. package/skills/qingflow-app-builder/references/create-app.md +51 -21
  6. package/skills/qingflow-app-builder/references/environments.md +1 -1
  7. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  8. package/skills/qingflow-app-builder/references/gotchas.md +28 -1
  9. package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
  10. package/skills/qingflow-app-builder/references/tool-selection.md +45 -17
  11. package/skills/qingflow-app-builder/references/update-flow.md +112 -25
  12. package/skills/qingflow-app-builder/references/update-layout.md +11 -24
  13. package/skills/qingflow-app-builder/references/update-schema.md +1 -23
  14. package/skills/qingflow-app-builder/references/update-views.md +87 -21
  15. package/src/qingflow_mcp/__init__.py +1 -1
  16. package/src/qingflow_mcp/backend_client.py +189 -0
  17. package/src/qingflow_mcp/builder_facade/models.py +584 -1
  18. package/src/qingflow_mcp/builder_facade/service.py +4698 -262
  19. package/src/qingflow_mcp/config.py +39 -0
  20. package/src/qingflow_mcp/import_store.py +121 -0
  21. package/src/qingflow_mcp/list_type_labels.py +24 -0
  22. package/src/qingflow_mcp/server.py +131 -16
  23. package/src/qingflow_mcp/server_app_builder.py +132 -72
  24. package/src/qingflow_mcp/server_app_user.py +143 -187
  25. package/src/qingflow_mcp/solution/compiler/form_compiler.py +14 -4
  26. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
  27. package/src/qingflow_mcp/solution/executor.py +44 -7
  28. package/src/qingflow_mcp/tools/ai_builder_tools.py +1567 -144
  29. package/src/qingflow_mcp/tools/app_tools.py +243 -14
  30. package/src/qingflow_mcp/tools/approval_tools.py +411 -76
  31. package/src/qingflow_mcp/tools/directory_tools.py +203 -31
  32. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  33. package/src/qingflow_mcp/tools/file_tools.py +1 -0
  34. package/src/qingflow_mcp/tools/import_tools.py +1164 -0
  35. package/src/qingflow_mcp/tools/portal_tools.py +31 -0
  36. package/src/qingflow_mcp/tools/record_tools.py +4943 -1025
  37. package/src/qingflow_mcp/tools/task_context_tools.py +1335 -0
  38. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  39. package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
@@ -1,34 +1,153 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from datetime import date
4
+
3
5
  from mcp.server.fastmcp import FastMCP
4
6
 
5
7
  from .backend_client import BackendClient
6
8
  from .config import DEFAULT_PROFILE
7
9
  from .session_store import SessionStore
8
- from .tools.approval_tools import ApprovalTools
10
+ from .tools.app_tools import AppTools
9
11
  from .tools.auth_tools import AuthTools
10
12
  from .tools.directory_tools import DirectoryTools
13
+ from .tools.feedback_tools import FeedbackTools
11
14
  from .tools.file_tools import FileTools
15
+ from .tools.import_tools import ImportTools
12
16
  from .tools.record_tools import RecordTools
13
- from .tools.task_tools import TaskTools
17
+ from .tools.task_context_tools import TaskContextTools
14
18
  from .tools.workspace_tools import WorkspaceTools
15
19
 
16
20
 
17
21
  def build_user_server() -> FastMCP:
22
+ today = date.today()
23
+ current_year = today.year
18
24
  server = FastMCP(
19
25
  "Qingflow App User MCP",
20
- instructions=(
21
- "Use this server for Qingflow record queries, record writes, task center operations, "
22
- "directory lookups, and approval actions. Avoid builder-side app or schema changes here."
23
- ),
26
+ instructions=f"""Use this server for Qingflow operational workflows. Current date: `{today.isoformat()}`.
27
+
28
+ ## App Discovery
29
+
30
+ If `app_key` is unknown, use `app_list` or `app_search` first.
31
+ If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
32
+ If an accessible view has `analysis_supported=false`, do not use it for `record_list` or `record_analyze`. `boardView` and `ganttView` are special UI views, not list/analyze targets.
33
+
34
+ ## Shared Helper
35
+
36
+ `feedback_submit` is always available as a cross-cutting helper.
37
+
38
+ - Use it when the current MCP capability is unsupported, awkward, or still cannot satisfy the user's need after reasonable use.
39
+ - It does not require Qingflow login or workspace selection.
40
+ - Call it only after the user explicitly confirms submission.
41
+
42
+ ## Schema-First Rule
43
+
44
+ Call `record_schema_get(schema_mode="applicant")` before `record_write`.
45
+ Call `app_get` first when the data range is unclear, then use `record_schema_get(schema_mode="browse", view_id=...)` before `record_list`, `record_get`, or `record_analyze`.
46
+
47
+ - All `field_id` values must come from the schema response.
48
+ - Never guess field names or ids.
49
+
50
+ ## Schema Scope
51
+
52
+ `record_schema_get(schema_mode="applicant")` returns the current user's applicant-node visible fields for write/create.
53
+ `record_schema_get(schema_mode="browse", view_id=...)` returns browse-schema fields for the selected accessible view.
54
+
55
+ - Hidden fields are omitted.
56
+ - Missing fields mean the field is not visible in the current permission scope.
57
+ - Read `fields` and `suggested_*` from the top level of the schema response.
58
+
59
+ ## Analytics Path
60
+
61
+ `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_analyze`
62
+
63
+ Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
64
+
65
+ Use this DSL shape:
66
+
67
+ - `dimensions`: `{{field_id, alias, bucket}}`
68
+ - `metrics`: `{{op, field_id, alias}}`
69
+ - `filters`: `{{field_id, op, value}}`
70
+ - `sort`: `{{by, order}}`
71
+
72
+ Important key rules:
73
+
74
+ - Use `op`
75
+ - Do **not** use `type`
76
+ - Do **not** use `agg`
77
+ - Do **not** use `aggregation`
78
+ - Do **not** use `operator`
79
+
80
+ Analysis answers must include concrete numbers. When applicable, include percentages based on the returned totals.
81
+
82
+ ## Record CRUD Path
83
+
84
+ `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_list / record_get`
85
+ `record_schema_get(schema_mode="applicant") -> record_write`
86
+
87
+ - Use `columns` as `[{{field_id}}]`
88
+ - Use `where` items as `{{field_id, op, value}}`
89
+ - Use `order_by` items as `{{field_id, direction}}`
90
+ - Legacy forms such as bare integer `field_id`, `fieldId`, `operator`, `values`, or `order` may still parse, but they are compatibility-only and not the canonical DSL
91
+
92
+ `record_write` uses SQL-like JSON clauses:
93
+
94
+ - `insert` -> `values`
95
+ - `update` -> `record_id + set`
96
+ - `delete` -> `record_id` or `record_ids`
97
+
98
+ - Read relation targets from `record_schema_get.target_app_key` / `target_app_name` before preparing relation writes.
99
+ - If a member or department field id is known but candidate ids are not, use `record_member_candidates` or `record_department_candidates` before `record_write`.
100
+ - For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
101
+
102
+ ## Import Path
103
+
104
+ `record_import_template_get -> record_import_verify -> (optional authorized record_import_repair_local) -> record_import_start -> record_import_status_get`
105
+
106
+ - Import must go through `verify -> start`; do not start directly from a raw file path.
107
+ - `record_import_start` requires an explicit `being_enter_auditing` choice. Do not assume a default.
108
+ - Do not modify user-uploaded files unless the user explicitly authorizes repair.
109
+ - If repair is authorized, keep the original file and repair a copy, then run `record_import_verify` again before `record_import_start`.
110
+
111
+ ## Task Workflow Path
112
+
113
+ `task_list -> task_get -> task_action_execute`
114
+
115
+ - Use `task_associated_report_detail_get` for associated view or report details.
116
+ - Use `task_workflow_log_get` for full workflow log history.
117
+ - Task actions operate on `app_key + record_id + workflow_node_id`, not `task_id`.
118
+
119
+ ## Time Handling
120
+
121
+ Normalize relative dates before building DSL.
122
+
123
+ - If the user says `3月` without a year, use the current year: `{current_year}`
124
+ - Convert month-only phrases into explicit legal date ranges
125
+ - Never send impossible dates such as `2026-02-29`
126
+
127
+ ## Environment
128
+
129
+ Default to `prod` unless the user explicitly specifies `test`.
130
+
131
+ ## Constraints
132
+
133
+ Avoid builder-side app or schema changes here.
134
+
135
+ ## Feedback Path
136
+
137
+ If the current MCP capability is unsupported, the workflow is awkward, or the user's need still cannot be satisfied after reasonable use, offer to submit product feedback.
138
+
139
+ - First summarize what is still not working
140
+ - Ask the user whether to submit feedback
141
+ - Call `feedback_submit` only after explicit user confirmation""",
24
142
  )
25
143
  sessions = SessionStore()
26
144
  backend = BackendClient()
27
145
  auth = AuthTools(sessions, backend)
146
+ apps = AppTools(sessions, backend)
28
147
  workspace = WorkspaceTools(sessions, backend)
29
148
  files = FileTools(sessions, backend)
30
- approvals = ApprovalTools(sessions, backend)
31
- tasks = TaskTools(sessions, backend)
149
+ imports = ImportTools(sessions, backend)
150
+ feedback = FeedbackTools(backend, mcp_side="App User MCP")
32
151
 
33
152
  @server.tool()
34
153
  def auth_login(
@@ -92,6 +211,18 @@ def build_user_server() -> FastMCP:
92
211
  def workspace_select(profile: str = DEFAULT_PROFILE, ws_id: int = 0) -> dict:
93
212
  return workspace.workspace_select(profile=profile, ws_id=ws_id)
94
213
 
214
+ @server.tool()
215
+ def app_list(profile: str = DEFAULT_PROFILE) -> dict:
216
+ return apps.app_list(profile=profile)
217
+
218
+ @server.tool()
219
+ def app_search(profile: str = DEFAULT_PROFILE, keyword: str = "", page_num: int = 1, page_size: int = 50) -> dict:
220
+ return apps.app_search(profile=profile, keyword=keyword, page_num=page_num, page_size=page_size)
221
+
222
+ @server.tool()
223
+ def app_get(profile: str = DEFAULT_PROFILE, app_key: str = "") -> dict:
224
+ return apps.app_get(profile=profile, app_key=app_key)
225
+
95
226
  @server.tool()
96
227
  def file_get_upload_info(
97
228
  profile: str = DEFAULT_PROFILE,
@@ -138,188 +269,13 @@ def build_user_server() -> FastMCP:
138
269
  file_related_url=file_related_url,
139
270
  )
140
271
 
272
+ imports.register(server)
273
+
274
+ feedback.register(server)
141
275
  RecordTools(sessions, backend).register(server)
276
+ TaskContextTools(sessions, backend).register(server)
142
277
  DirectoryTools(sessions, backend).register(server)
143
278
 
144
- @server.tool()
145
- def record_comment_add(
146
- profile: str = DEFAULT_PROFILE,
147
- app_key: str = "",
148
- apply_id: int = 0,
149
- payload: dict | None = None,
150
- ) -> dict:
151
- return approvals.record_comment_add(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
152
-
153
- @server.tool()
154
- def record_comment_list(
155
- profile: str = DEFAULT_PROFILE,
156
- app_key: str = "",
157
- apply_id: int = 0,
158
- page_size: int = 20,
159
- list_type: int | None = None,
160
- page_num: int | None = 1,
161
- ) -> dict:
162
- return approvals.record_comment_list(
163
- profile=profile,
164
- app_key=app_key,
165
- apply_id=apply_id,
166
- page_size=page_size,
167
- list_type=list_type,
168
- page_num=page_num,
169
- )
170
-
171
- @server.tool()
172
- def record_comment_mention_candidates(
173
- profile: str = DEFAULT_PROFILE,
174
- app_key: str = "",
175
- apply_id: int = 0,
176
- page_size: int = 20,
177
- page_num: int = 1,
178
- list_type: int | None = None,
179
- keyword: str | None = None,
180
- ) -> dict:
181
- return approvals.record_comment_mention_candidates(
182
- profile=profile,
183
- app_key=app_key,
184
- apply_id=apply_id,
185
- page_size=page_size,
186
- page_num=page_num,
187
- list_type=list_type,
188
- keyword=keyword,
189
- )
190
-
191
- @server.tool()
192
- def record_comment_mark_read(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0) -> dict:
193
- return approvals.record_comment_mark_read(profile=profile, app_key=app_key, apply_id=apply_id)
194
-
195
- @server.tool()
196
- def record_comment_stats(profile: str = DEFAULT_PROFILE, app_key: str = "", apply_id: int = 0) -> dict:
197
- return approvals.record_comment_stats(profile=profile, app_key=app_key, apply_id=apply_id)
198
-
199
- @server.tool()
200
- def task_list(
201
- profile: str = DEFAULT_PROFILE,
202
- type: int = 1,
203
- process_status: int = 1,
204
- app_key: str | None = None,
205
- node_id: int | None = None,
206
- search_key: str | None = None,
207
- page_num: int = 1,
208
- page_size: int = 20,
209
- create_time_asc: bool | None = None,
210
- ) -> dict:
211
- return tasks.task_list(
212
- profile=profile,
213
- type=type,
214
- process_status=process_status,
215
- app_key=app_key,
216
- node_id=node_id,
217
- search_key=search_key,
218
- page_num=page_num,
219
- page_size=page_size,
220
- create_time_asc=create_time_asc,
221
- )
222
-
223
- @server.tool()
224
- def task_list_grouped(
225
- profile: str = DEFAULT_PROFILE,
226
- type: int = 1,
227
- process_status: int = 1,
228
- app_key: str | None = None,
229
- node_id: int | None = None,
230
- search_key: str | None = None,
231
- page_num: int = 1,
232
- page_size: int = 20,
233
- ) -> dict:
234
- return tasks.task_list_grouped(
235
- profile=profile,
236
- type=type,
237
- process_status=process_status,
238
- app_key=app_key,
239
- node_id=node_id,
240
- search_key=search_key,
241
- page_num=page_num,
242
- page_size=page_size,
243
- )
244
-
245
- @server.tool()
246
- def task_statistics(profile: str = DEFAULT_PROFILE, app_key: str | None = None) -> dict:
247
- return tasks.task_statistics(profile=profile, app_key=app_key)
248
-
249
- @server.tool()
250
- def task_urge(profile: str = DEFAULT_PROFILE, app_key: str = "", row_record_id: int = 0) -> dict:
251
- return tasks.task_urge(profile=profile, app_key=app_key, row_record_id=row_record_id)
252
-
253
- @server.tool(description=approvals._high_risk_tool_description(operation="approve", target="workflow task"))
254
- def task_approve(
255
- profile: str = DEFAULT_PROFILE,
256
- app_key: str = "",
257
- apply_id: int = 0,
258
- payload: dict | None = None,
259
- ) -> dict:
260
- return approvals.record_approve(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
261
-
262
- @server.tool(description=approvals._high_risk_tool_description(operation="reject", target="workflow task"))
263
- def task_reject(
264
- profile: str = DEFAULT_PROFILE,
265
- app_key: str = "",
266
- apply_id: int = 0,
267
- payload: dict | None = None,
268
- ) -> dict:
269
- return approvals.record_reject(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
270
-
271
- @server.tool()
272
- def task_rollback_candidates(
273
- profile: str = DEFAULT_PROFILE,
274
- app_key: str = "",
275
- apply_id: int = 0,
276
- audit_node_id: int = 0,
277
- ) -> dict:
278
- return approvals.record_rollback_candidates(
279
- profile=profile,
280
- app_key=app_key,
281
- apply_id=apply_id,
282
- audit_node_id=audit_node_id,
283
- )
284
-
285
- @server.tool()
286
- def task_rollback(
287
- profile: str = DEFAULT_PROFILE,
288
- app_key: str = "",
289
- apply_id: int = 0,
290
- payload: dict | None = None,
291
- ) -> dict:
292
- return approvals.record_rollback(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
293
-
294
- @server.tool()
295
- def task_transfer_candidates(
296
- profile: str = DEFAULT_PROFILE,
297
- app_key: str = "",
298
- apply_id: int = 0,
299
- page_size: int = 20,
300
- page_num: int = 1,
301
- audit_node_id: int = 0,
302
- keyword: str | None = None,
303
- ) -> dict:
304
- return approvals.record_transfer_candidates(
305
- profile=profile,
306
- app_key=app_key,
307
- apply_id=apply_id,
308
- page_size=page_size,
309
- page_num=page_num,
310
- audit_node_id=audit_node_id,
311
- keyword=keyword,
312
- )
313
-
314
- @server.tool()
315
- def task_transfer(
316
- profile: str = DEFAULT_PROFILE,
317
- app_key: str = "",
318
- apply_id: int = 0,
319
- payload: dict | None = None,
320
- ) -> dict:
321
- return approvals.record_transfer(profile=profile, app_key=app_key, apply_id=apply_id, payload=payload or {})
322
-
323
279
  return server
324
280
 
325
281
 
@@ -249,7 +249,7 @@ def build_question(field: dict[str, Any], temp_id: int) -> tuple[dict[str, Any],
249
249
  sub_question, next_temp_id = build_question(subfield, next_temp_id)
250
250
  sub_questions.append(sub_question)
251
251
  question["subQuestions"] = sub_questions
252
- question["innerQuestions"] = deepcopy(sub_questions)
252
+ question["innerQuestions"] = [deepcopy(sub_questions)]
253
253
  question["queDefaultValues"] = {"queId": temp_id, "queTitle": field["label"], "queType": que_type, "values": [], "tableValues": []}
254
254
  return question, next_temp_id
255
255
 
@@ -257,13 +257,16 @@ def build_question(field: dict[str, Any], temp_id: int) -> tuple[dict[str, Any],
257
257
  def build_reference_config(field: dict[str, Any], temp_id: int) -> dict[str, Any]:
258
258
  config = field.get("config") or {}
259
259
  display_field_id = field.get("target_field_id") or "title"
260
+ display_field_que_id = field.get("target_field_que_id") or config.get("target_field_que_id") or 0
260
261
  display_field_label = config.get("target_field_label") or "__TARGET_FIELD_LABEL__"
261
262
  refer_field_ids = list(config.get("refer_field_ids") or [display_field_id])
263
+ refer_field_que_ids = list(config.get("refer_field_que_ids") or [])
262
264
  refer_field_labels = config.get("refer_field_labels") or [display_field_label]
263
265
  refer_field_types = config.get("refer_field_types")
264
266
  refer_questions = []
265
267
  for ordinal, field_id in enumerate(refer_field_ids, start=1):
266
268
  label = refer_field_labels[ordinal - 1] if ordinal - 1 < len(refer_field_labels) else field_id
269
+ que_id = refer_field_que_ids[ordinal - 1] if ordinal - 1 < len(refer_field_que_ids) else 0
267
270
  raw_type = _reference_field_type_value(
268
271
  refer_field_types,
269
272
  field_id=field_id,
@@ -272,7 +275,7 @@ def build_reference_config(field: dict[str, Any], temp_id: int) -> dict[str, Any
272
275
  )
273
276
  refer_questions.append(
274
277
  {
275
- "queId": 0,
278
+ "queId": que_id,
276
279
  "queTitle": label,
277
280
  "queType": _normalize_reference_que_type(raw_type) or "2",
278
281
  "queAuth": 1,
@@ -282,15 +285,22 @@ def build_reference_config(field: dict[str, Any], temp_id: int) -> dict[str, Any
282
285
  }
283
286
  )
284
287
  auth_field_ids = list(config.get("auth_field_ids") or refer_field_ids)
288
+ auth_field_que_ids = list(config.get("auth_field_que_ids") or [])
289
+ auth_ques = deepcopy(config.get("refer_auth_ques") or [])
290
+ if not auth_ques:
291
+ auth_ques = []
292
+ for ordinal, field_id in enumerate(auth_field_ids, start=1):
293
+ que_id = auth_field_que_ids[ordinal - 1] if ordinal - 1 < len(auth_field_que_ids) else 0
294
+ auth_ques.append({"queId": que_id, "queAuth": 1, "_field_id": field_id})
285
295
  return {
286
296
  "referAppKey": "__TARGET_APP_KEY__",
287
- "referQueId": 0,
297
+ "referQueId": display_field_que_id,
288
298
  "customButtonText": config.get("custom_button_text") or "选择数据",
289
299
  "beingTableSource": False,
290
300
  "referQuestions": refer_questions,
291
301
  "referMatchRules": deepcopy(config.get("refer_match_rules") or []),
292
302
  "referFillRules": deepcopy(config.get("refer_fill_rules") or []),
293
- "referAuthQues": deepcopy(config.get("refer_auth_ques") or [{"queId": 0, "queAuth": 1, "_field_id": field_id} for field_id in auth_field_ids]),
303
+ "referAuthQues": auth_ques,
294
304
  "canAddData": bool(config.get("can_add_data", False)),
295
305
  "dataAdditionButtonText": config.get("data_addition_button_text") or "新增数据",
296
306
  "canViewProcessLog": bool(config.get("can_view_process_log", True)),
@@ -36,6 +36,11 @@ def compile_workflow(entity: EntitySpec) -> dict[str, Any] | None:
36
36
  actions: list[dict[str, Any]] = []
37
37
  seen_node_ids: set[str] = set()
38
38
  created_extra_branch_lanes: set[str] = set()
39
+ start_node_ids = {
40
+ node.node_id
41
+ for node in workflow.nodes
42
+ if node.node_type == WorkflowNodeType.start
43
+ }
39
44
  for node in workflow.nodes:
40
45
  if node.node_type == WorkflowNodeType.start:
41
46
  seen_node_ids.add(node.node_id)
@@ -58,6 +63,26 @@ def compile_workflow(entity: EntitySpec) -> dict[str, Any] | None:
58
63
  }
59
64
  )
60
65
  created_extra_branch_lanes.add(branch_lane_ref)
66
+ lane_only = bool((node.config or {}).get("__lane_only__")) and node.node_type == WorkflowNodeType.condition and branch_lane_ref
67
+ if lane_only:
68
+ lane_payload = {key: value for key, value in node.config.items() if key != "__lane_only__"}
69
+ actions.append(
70
+ {
71
+ "action": "update_node",
72
+ "node_id": branch_lane_ref,
73
+ "node_name": node.name,
74
+ "node_type": node.node_type.value,
75
+ "payload": {
76
+ "editVersionNo": 1,
77
+ "auditNodeName": node.name,
78
+ "type": WORKFLOW_TYPE_MAP[node.node_type]["type"],
79
+ "dealType": WORKFLOW_TYPE_MAP[node.node_type]["dealType"],
80
+ **lane_payload,
81
+ },
82
+ }
83
+ )
84
+ seen_node_ids.add(node.node_id)
85
+ continue
61
86
  actions.append(
62
87
  {
63
88
  "action": "add_node",
@@ -69,7 +94,7 @@ def compile_workflow(entity: EntitySpec) -> dict[str, Any] | None:
69
94
  "auditNodeName": node.name,
70
95
  "type": WORKFLOW_TYPE_MAP[node.node_type]["type"],
71
96
  "dealType": WORKFLOW_TYPE_MAP[node.node_type]["dealType"],
72
- "prevNodeRef": _prev_node_ref(node, branch_lane_ref),
97
+ "prevNodeRef": _prev_node_ref(node, branch_lane_ref, start_node_ids),
73
98
  "auditUserInfos": _build_audit_user_infos(node)
74
99
  if node.node_type in {WorkflowNodeType.audit, WorkflowNodeType.fill, WorkflowNodeType.copy}
75
100
  else None,
@@ -100,17 +125,31 @@ def _build_audit_user_infos(node) -> dict[str, Any]:
100
125
  role_refs = assignees.get("role_refs") or []
101
126
  if role_refs:
102
127
  audit_user_infos["role_refs"] = [role_ref for role_ref in role_refs if role_ref]
128
+ role_entries = assignees.get("role_entries") or []
129
+ if role_entries:
130
+ audit_user_infos["role"] = [
131
+ {
132
+ "roleId": int(entry.get("roleId") or entry.get("role_id")),
133
+ "roleName": entry.get("roleName") or entry.get("role_name") or str(entry.get("roleId") or entry.get("role_id")),
134
+ "roleIcon": entry.get("roleIcon") or entry.get("role_icon") or "ex-user-outlined",
135
+ "beingFrontendConfig": True,
136
+ }
137
+ for entry in role_entries
138
+ if isinstance(entry, dict) and isinstance(entry.get("roleId") or entry.get("role_id"), int) and int(entry.get("roleId") or entry.get("role_id")) > 0
139
+ ]
103
140
  include_sub_departs = assignees.get("include_sub_departs")
104
141
  if include_sub_departs is not None:
105
142
  audit_user_infos["includeSubDeparts"] = bool(include_sub_departs)
106
143
  return audit_user_infos
107
144
 
108
145
 
109
- def _prev_node_ref(node, branch_lane_ref: str | None) -> str:
146
+ def _prev_node_ref(node, branch_lane_ref: str | None, start_node_ids: set[str]) -> str:
110
147
  if branch_lane_ref:
111
148
  if node.parent_node_id and node.parent_node_id != node.branch_parent_id:
112
149
  return node.parent_node_id
113
150
  return branch_lane_ref
151
+ if node.parent_node_id in start_node_ids:
152
+ return "__applicant__"
114
153
  return node.parent_node_id or "__applicant__"
115
154
 
116
155
 
@@ -415,13 +415,13 @@ class SolutionExecutor:
415
415
  current_nodes = _coerce_workflow_nodes(existing_nodes)
416
416
  existing_nodes_by_name = {
417
417
  node.get("auditNodeName"): int(node_id)
418
- for node_id, node in existing_nodes.items()
418
+ for node_id, node in current_nodes.items()
419
419
  if isinstance(node, dict) and node.get("auditNodeName")
420
420
  }
421
421
  applicant_node_id = next(
422
422
  (
423
423
  int(node_id)
424
- for node_id, node in existing_nodes.items()
424
+ for node_id, node in current_nodes.items()
425
425
  if isinstance(node, dict) and node.get("type") == 0 and node.get("dealType") == 3
426
426
  ),
427
427
  None,
@@ -429,11 +429,24 @@ class SolutionExecutor:
429
429
  if applicant_node_id is not None:
430
430
  node_artifacts.setdefault("__applicant__", applicant_node_id)
431
431
 
432
- current_global_settings = self.workflow_tools.workflow_get_global_settings(profile=profile, app_key=app_key).get("result") or {}
433
- global_settings = deepcopy(current_global_settings if isinstance(current_global_settings, dict) else {})
434
- global_settings.update(entity.workflow_plan["global_settings"])
435
- global_settings["editVersionNo"] = workflow_edit_version_no or global_settings.get("editVersionNo") or 1
436
- self.workflow_tools.workflow_update_global_settings(profile=profile, app_key=app_key, payload=global_settings)
432
+ desired_global_settings = deepcopy(entity.workflow_plan["global_settings"])
433
+ explicit_global_settings = _has_explicit_workflow_global_settings(desired_global_settings)
434
+ current_global_settings: dict[str, Any] = {}
435
+ if explicit_global_settings:
436
+ current_global_settings = self.workflow_tools.workflow_get_global_settings(profile=profile, app_key=app_key).get("result") or {}
437
+ else:
438
+ try:
439
+ current_global_settings = self.workflow_tools.workflow_get_global_settings(profile=profile, app_key=app_key).get("result") or {}
440
+ except (QingflowApiError, RuntimeError) as error:
441
+ api_error = QingflowApiError(**_coerce_nested_error_payload(error))
442
+ if api_error.http_status != 404:
443
+ raise
444
+ current_global_settings = {}
445
+ if explicit_global_settings:
446
+ global_settings = deepcopy(current_global_settings if isinstance(current_global_settings, dict) else {})
447
+ global_settings.update(desired_global_settings)
448
+ global_settings["editVersionNo"] = workflow_edit_version_no or global_settings.get("editVersionNo") or 1
449
+ self.workflow_tools.workflow_update_global_settings(profile=profile, app_key=app_key, payload=global_settings)
437
450
  for action in entity.workflow_plan["actions"]:
438
451
  if action["action"] == "create_sub_branch" and node_artifacts.get(action["node_id"]) is not None:
439
452
  continue
@@ -462,6 +475,16 @@ class SolutionExecutor:
462
475
  payload["editVersionNo"] = int(workflow_edit_version_no)
463
476
  if action["action"] == "create_sub_branch":
464
477
  result = self.workflow_tools.workflow_create_sub_branch(profile=profile, app_key=app_key, payload=payload)
478
+ elif action["action"] == "update_node":
479
+ target_node_id = node_artifacts.get(action["node_id"])
480
+ if target_node_id is None:
481
+ raise RuntimeError(f"workflow lane '{action['node_id']}' could not be resolved before update")
482
+ result = self.workflow_tools.workflow_update_node(
483
+ profile=profile,
484
+ app_key=app_key,
485
+ audit_node_id=target_node_id,
486
+ payload=payload,
487
+ )
465
488
  else:
466
489
  result = self.workflow_tools.workflow_add_node(profile=profile, app_key=app_key, payload=payload)
467
490
  expected_type = 1 if action.get("node_type") == "branch" else None
@@ -2046,6 +2069,20 @@ def _find_created_sub_branch_lane_id(
2046
2069
  return candidates[0] if candidates else None
2047
2070
 
2048
2071
 
2072
+ def _has_explicit_workflow_global_settings(global_settings: dict[str, Any] | None) -> bool:
2073
+ if not isinstance(global_settings, dict):
2074
+ return False
2075
+ for key, value in global_settings.items():
2076
+ if key == "editVersionNo":
2077
+ continue
2078
+ if value is None:
2079
+ continue
2080
+ if isinstance(value, (list, dict)) and not value:
2081
+ continue
2082
+ return True
2083
+ return False
2084
+
2085
+
2049
2086
  def _is_navigation_plugin_unavailable(error: QingflowApiError) -> bool:
2050
2087
  try:
2051
2088
  backend_code = int(error.backend_code)