@josephyan/qingflow-app-builder-mcp 0.2.0-beta.26 → 0.2.0-beta.27
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
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.
|
|
6
|
+
npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.27
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.27 qingflow-app-builder-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
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
|
|
@@ -23,25 +25,88 @@ from .tools.workspace_tools import WorkspaceTools
|
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
def build_server() -> FastMCP:
|
|
28
|
+
today = date.today()
|
|
29
|
+
current_year = today.year
|
|
26
30
|
server = FastMCP(
|
|
27
31
|
"Qingflow MCP",
|
|
28
|
-
instructions=(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
32
|
+
instructions=f"""Use this server for Qingflow operational workflows. Current date: `{today.isoformat()}`.
|
|
33
|
+
|
|
34
|
+
## Authentication
|
|
35
|
+
|
|
36
|
+
Use `auth_login` first, then `workspace_list` and `workspace_select`.
|
|
37
|
+
All resource tools operate with the logged-in user's Qingflow permissions.
|
|
38
|
+
|
|
39
|
+
## App Discovery
|
|
40
|
+
|
|
41
|
+
If `app_key` is unknown, use `app_list` or `app_search` first.
|
|
42
|
+
|
|
43
|
+
## Schema-First Rule
|
|
44
|
+
|
|
45
|
+
Always call `record_schema_get` before `record_list`, `record_get`, `record_write`, 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` returns the current user's applicant-node visible fields only.
|
|
53
|
+
|
|
54
|
+
- Hidden fields are omitted.
|
|
55
|
+
- Missing fields mean the field is not visible in the current permission scope.
|
|
56
|
+
|
|
57
|
+
## Analytics Path
|
|
58
|
+
|
|
59
|
+
`record_schema_get -> record_analyze`
|
|
60
|
+
|
|
61
|
+
Use this DSL shape:
|
|
62
|
+
|
|
63
|
+
- `dimensions`: `{{field_id, alias, bucket}}`
|
|
64
|
+
- `metrics`: `{{op, field_id, alias}}`
|
|
65
|
+
- `filters`: `{{field_id, op, value}}`
|
|
66
|
+
- `sort`: `{{by, order}}`
|
|
67
|
+
|
|
68
|
+
Important key rules:
|
|
69
|
+
|
|
70
|
+
- Use `op`
|
|
71
|
+
- Do **not** use `type`
|
|
72
|
+
- Do **not** use `agg`
|
|
73
|
+
- Do **not** use `aggregation`
|
|
74
|
+
- Do **not** use `operator`
|
|
75
|
+
|
|
76
|
+
Analysis answers must include concrete numbers. When applicable, include percentages based on the returned totals.
|
|
77
|
+
|
|
78
|
+
## Record CRUD Path
|
|
79
|
+
|
|
80
|
+
`record_schema_get -> record_list / record_get / record_write`
|
|
81
|
+
|
|
82
|
+
`record_write` uses SQL-like JSON clauses:
|
|
83
|
+
|
|
84
|
+
- `insert` -> `values`
|
|
85
|
+
- `update` -> `record_id + set`
|
|
86
|
+
- `delete` -> `record_id` or `record_ids`
|
|
87
|
+
|
|
88
|
+
- Read relation targets from `record_schema_get.target_app_key` / `target_app_name` before preparing relation writes.
|
|
89
|
+
- 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`.
|
|
90
|
+
|
|
91
|
+
## Task Center Path
|
|
92
|
+
|
|
93
|
+
`task_summary -> task_list / task_facets -> task action`
|
|
94
|
+
|
|
95
|
+
## Time Handling
|
|
96
|
+
|
|
97
|
+
Normalize relative dates before building DSL.
|
|
98
|
+
|
|
99
|
+
- If the user says `3月` without a year, use the current year: `{current_year}`
|
|
100
|
+
- Convert month-only phrases into explicit legal date ranges
|
|
101
|
+
- Never send impossible dates such as `2026-02-29`
|
|
102
|
+
|
|
103
|
+
## Environment
|
|
104
|
+
|
|
105
|
+
Default to `prod` unless the user explicitly specifies `test`.
|
|
106
|
+
|
|
107
|
+
## Constraints
|
|
108
|
+
|
|
109
|
+
Avoid builder-side app or schema changes here.""",
|
|
45
110
|
)
|
|
46
111
|
sessions = SessionStore()
|
|
47
112
|
backend = BackendClient()
|
|
@@ -1,5 +1,7 @@
|
|
|
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
|
|
@@ -16,17 +18,83 @@ from .tools.workspace_tools import WorkspaceTools
|
|
|
16
18
|
|
|
17
19
|
|
|
18
20
|
def build_user_server() -> FastMCP:
|
|
21
|
+
today = date.today()
|
|
22
|
+
current_year = today.year
|
|
19
23
|
server = FastMCP(
|
|
20
24
|
"Qingflow App User MCP",
|
|
21
|
-
instructions=(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
instructions=f"""Use this server for Qingflow operational workflows. Current date: `{today.isoformat()}`.
|
|
26
|
+
|
|
27
|
+
## App Discovery
|
|
28
|
+
|
|
29
|
+
If `app_key` is unknown, use `app_list` or `app_search` first.
|
|
30
|
+
|
|
31
|
+
## Schema-First Rule
|
|
32
|
+
|
|
33
|
+
Always call `record_schema_get` before `record_list`, `record_get`, `record_write`, or `record_analyze`.
|
|
34
|
+
|
|
35
|
+
- All `field_id` values must come from the schema response.
|
|
36
|
+
- Never guess field names or ids.
|
|
37
|
+
|
|
38
|
+
## Schema Scope
|
|
39
|
+
|
|
40
|
+
`record_schema_get` returns the current user's applicant-node visible fields only.
|
|
41
|
+
|
|
42
|
+
- Hidden fields are omitted.
|
|
43
|
+
- Missing fields mean the field is not visible in the current permission scope.
|
|
44
|
+
|
|
45
|
+
## Analytics Path
|
|
46
|
+
|
|
47
|
+
`record_schema_get -> record_analyze`
|
|
48
|
+
|
|
49
|
+
Use this DSL shape:
|
|
50
|
+
|
|
51
|
+
- `dimensions`: `{{field_id, alias, bucket}}`
|
|
52
|
+
- `metrics`: `{{op, field_id, alias}}`
|
|
53
|
+
- `filters`: `{{field_id, op, value}}`
|
|
54
|
+
- `sort`: `{{by, order}}`
|
|
55
|
+
|
|
56
|
+
Important key rules:
|
|
57
|
+
|
|
58
|
+
- Use `op`
|
|
59
|
+
- Do **not** use `type`
|
|
60
|
+
- Do **not** use `agg`
|
|
61
|
+
- Do **not** use `aggregation`
|
|
62
|
+
- Do **not** use `operator`
|
|
63
|
+
|
|
64
|
+
Analysis answers must include concrete numbers. When applicable, include percentages based on the returned totals.
|
|
65
|
+
|
|
66
|
+
## Record CRUD Path
|
|
67
|
+
|
|
68
|
+
`record_schema_get -> record_list / record_get / record_write`
|
|
69
|
+
|
|
70
|
+
`record_write` uses SQL-like JSON clauses:
|
|
71
|
+
|
|
72
|
+
- `insert` -> `values`
|
|
73
|
+
- `update` -> `record_id + set`
|
|
74
|
+
- `delete` -> `record_id` or `record_ids`
|
|
75
|
+
|
|
76
|
+
- Read relation targets from `record_schema_get.target_app_key` / `target_app_name` before preparing relation writes.
|
|
77
|
+
- 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`.
|
|
78
|
+
|
|
79
|
+
## Task Center Path
|
|
80
|
+
|
|
81
|
+
`task_summary -> task_list / task_facets -> task action`
|
|
82
|
+
|
|
83
|
+
## Time Handling
|
|
84
|
+
|
|
85
|
+
Normalize relative dates before building DSL.
|
|
86
|
+
|
|
87
|
+
- If the user says `3月` without a year, use the current year: `{current_year}`
|
|
88
|
+
- Convert month-only phrases into explicit legal date ranges
|
|
89
|
+
- Never send impossible dates such as `2026-02-29`
|
|
90
|
+
|
|
91
|
+
## Environment
|
|
92
|
+
|
|
93
|
+
Default to `prod` unless the user explicitly specifies `test`.
|
|
94
|
+
|
|
95
|
+
## Constraints
|
|
96
|
+
|
|
97
|
+
Avoid builder-side app or schema changes here.""",
|
|
30
98
|
)
|
|
31
99
|
sessions = SessionStore()
|
|
32
100
|
backend = BackendClient()
|
|
@@ -5,7 +5,7 @@ import re
|
|
|
5
5
|
import time
|
|
6
6
|
from dataclasses import dataclass
|
|
7
7
|
from datetime import UTC, datetime
|
|
8
|
-
from typing import cast
|
|
8
|
+
from typing import Any, cast
|
|
9
9
|
|
|
10
10
|
from mcp.server.fastmcp import FastMCP
|
|
11
11
|
|
|
@@ -14,6 +14,7 @@ from ..errors import QingflowApiError, raise_tool_error
|
|
|
14
14
|
from ..json_types import JSONObject, JSONScalar, JSONValue
|
|
15
15
|
from ..list_type_labels import get_record_list_type_label
|
|
16
16
|
from .base import ToolBase
|
|
17
|
+
from .directory_tools import _directory_has_more, _directory_items
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
DEFAULT_QUERY_PAGE_SIZE = 50
|
|
@@ -24,6 +25,8 @@ DEFAULT_ANALYSIS_SCAN_MAX_PAGES = 100
|
|
|
24
25
|
DEFAULT_ANALYSIS_AUTO_EXPAND_PAGE_CAP = 100
|
|
25
26
|
DEFAULT_ROW_LIMIT = 200
|
|
26
27
|
DEFAULT_OUTPUT_PROFILE = "compact"
|
|
28
|
+
DEFAULT_MEMBER_SCOPE_FETCH_PAGE_SIZE = 200
|
|
29
|
+
MAX_MEMBER_SCOPE_FETCH_PAGES = 20
|
|
27
30
|
MAX_LIST_COLUMN_LIMIT = 20
|
|
28
31
|
MAX_RECORD_COLUMN_LIMIT = 20
|
|
29
32
|
MAX_SUMMARY_PREVIEW_COLUMN_LIMIT = 6
|
|
@@ -63,6 +66,12 @@ class FormField:
|
|
|
63
66
|
system: bool
|
|
64
67
|
options: list[str]
|
|
65
68
|
aliases: list[str]
|
|
69
|
+
target_app_key: str | None
|
|
70
|
+
target_app_name_hint: str | None
|
|
71
|
+
member_select_scope_type: int | None
|
|
72
|
+
member_select_scope: JSONObject | None
|
|
73
|
+
dept_select_scope_type: int | None
|
|
74
|
+
dept_select_scope: JSONObject | None
|
|
66
75
|
raw: JSONObject
|
|
67
76
|
|
|
68
77
|
|
|
@@ -147,6 +156,7 @@ class RecordTools(ToolBase):
|
|
|
147
156
|
self._applicant_node_cache: dict[tuple[str, str], WorkflowNodeRef] = {}
|
|
148
157
|
self._view_list_cache: dict[tuple[str, str], list[JSONObject]] = {}
|
|
149
158
|
self._view_config_cache: dict[tuple[str, str], JSONObject] = {}
|
|
159
|
+
self._app_name_cache: dict[tuple[str, int | None, str], str | None] = {}
|
|
150
160
|
|
|
151
161
|
def register(self, mcp: FastMCP) -> None:
|
|
152
162
|
@mcp.tool()
|
|
@@ -165,6 +175,55 @@ class RecordTools(ToolBase):
|
|
|
165
175
|
output_profile=output_profile,
|
|
166
176
|
)
|
|
167
177
|
|
|
178
|
+
@mcp.tool(
|
|
179
|
+
description=(
|
|
180
|
+
"List current-user candidate members for a member field in the applicant-node visible schema. "
|
|
181
|
+
"Use record_schema_get first, then pass the member field_id. "
|
|
182
|
+
"This tool fails closed when the field uses dynamic or external candidate scopes."
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
def record_member_candidates(
|
|
186
|
+
profile: str = DEFAULT_PROFILE,
|
|
187
|
+
app_key: str = "",
|
|
188
|
+
field_id: int = 0,
|
|
189
|
+
keyword: str = "",
|
|
190
|
+
page_num: int = 1,
|
|
191
|
+
page_size: int = 20,
|
|
192
|
+
) -> JSONObject:
|
|
193
|
+
return self.record_member_candidates(
|
|
194
|
+
profile=profile,
|
|
195
|
+
app_key=app_key,
|
|
196
|
+
field_id=field_id,
|
|
197
|
+
keyword=keyword,
|
|
198
|
+
page_num=page_num,
|
|
199
|
+
page_size=page_size,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
@mcp.tool(
|
|
203
|
+
description=(
|
|
204
|
+
"List current-user candidate departments for a department field in the applicant-node visible schema. "
|
|
205
|
+
"Use record_schema_get first, then pass the department field_id. "
|
|
206
|
+
"This tool supports explicit department scopes and default-all department scopes, but fails closed "
|
|
207
|
+
"for dynamic or external candidate scopes."
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
def record_department_candidates(
|
|
211
|
+
profile: str = DEFAULT_PROFILE,
|
|
212
|
+
app_key: str = "",
|
|
213
|
+
field_id: int = 0,
|
|
214
|
+
keyword: str = "",
|
|
215
|
+
page_num: int = 1,
|
|
216
|
+
page_size: int = 20,
|
|
217
|
+
) -> JSONObject:
|
|
218
|
+
return self.record_department_candidates(
|
|
219
|
+
profile=profile,
|
|
220
|
+
app_key=app_key,
|
|
221
|
+
field_id=field_id,
|
|
222
|
+
keyword=keyword,
|
|
223
|
+
page_num=page_num,
|
|
224
|
+
page_size=page_size,
|
|
225
|
+
)
|
|
226
|
+
|
|
168
227
|
@mcp.tool(
|
|
169
228
|
description=(
|
|
170
229
|
"Run schema-first analytics on a Qingflow app using a restricted DSL. "
|
|
@@ -299,7 +358,16 @@ class RecordTools(ToolBase):
|
|
|
299
358
|
applicant_node = self._resolve_applicant_node(profile, context, app_key, force_refresh=False)
|
|
300
359
|
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
301
360
|
view_selection = self._resolve_view_selection(profile, context, app_key, view_key=view_key, view_name=view_name)
|
|
302
|
-
fields = [
|
|
361
|
+
fields = [
|
|
362
|
+
self._schema_field_payload(
|
|
363
|
+
profile,
|
|
364
|
+
context,
|
|
365
|
+
field,
|
|
366
|
+
workflow_node_id=applicant_node.workflow_node_id,
|
|
367
|
+
ws_id=session_profile.selected_ws_id,
|
|
368
|
+
)
|
|
369
|
+
for field in index.by_id.values()
|
|
370
|
+
]
|
|
303
371
|
suggested_dimensions = [
|
|
304
372
|
{"field_id": item["field_id"], "title": item["title"]}
|
|
305
373
|
for item in fields
|
|
@@ -342,6 +410,144 @@ class RecordTools(ToolBase):
|
|
|
342
410
|
|
|
343
411
|
return self._run_record_tool(profile, runner)
|
|
344
412
|
|
|
413
|
+
def record_member_candidates(
|
|
414
|
+
self,
|
|
415
|
+
*,
|
|
416
|
+
profile: str,
|
|
417
|
+
app_key: str,
|
|
418
|
+
field_id: int,
|
|
419
|
+
keyword: str,
|
|
420
|
+
page_num: int,
|
|
421
|
+
page_size: int,
|
|
422
|
+
) -> JSONObject:
|
|
423
|
+
if not app_key:
|
|
424
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
425
|
+
if field_id <= 0:
|
|
426
|
+
raise_tool_error(QingflowApiError.config_error("field_id must be positive"))
|
|
427
|
+
if page_num <= 0:
|
|
428
|
+
raise_tool_error(QingflowApiError.config_error("page_num must be positive"))
|
|
429
|
+
if page_size <= 0:
|
|
430
|
+
raise_tool_error(QingflowApiError.config_error("page_size must be positive"))
|
|
431
|
+
|
|
432
|
+
def runner(session_profile, context):
|
|
433
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
434
|
+
field = self._resolve_field_selector(field_id, index, location="field_id")
|
|
435
|
+
if field.que_type not in MEMBER_QUE_TYPES:
|
|
436
|
+
raise_tool_error(
|
|
437
|
+
QingflowApiError(
|
|
438
|
+
category="config",
|
|
439
|
+
message=f"field_id {field_id} is not a member field in the applicant-aware schema",
|
|
440
|
+
details={
|
|
441
|
+
"error_code": "FIELD_NOT_MEMBER",
|
|
442
|
+
"field_id": field.que_id,
|
|
443
|
+
"field_title": field.que_title,
|
|
444
|
+
"que_type": field.que_type,
|
|
445
|
+
},
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
items = self._resolve_member_candidates(context, field, keyword=keyword)
|
|
449
|
+
total = len(items)
|
|
450
|
+
start = (page_num - 1) * page_size
|
|
451
|
+
end = start + page_size
|
|
452
|
+
page_items = items[start:end]
|
|
453
|
+
page_amount = (total + page_size - 1) // page_size if total else 0
|
|
454
|
+
return {
|
|
455
|
+
"profile": profile,
|
|
456
|
+
"ws_id": session_profile.selected_ws_id,
|
|
457
|
+
"ok": True,
|
|
458
|
+
"request_route": self._request_route_payload(context),
|
|
459
|
+
"warnings": [],
|
|
460
|
+
"output_profile": "normal",
|
|
461
|
+
"data": {
|
|
462
|
+
"items": page_items,
|
|
463
|
+
"pagination": {
|
|
464
|
+
"page": page_num,
|
|
465
|
+
"page_size": page_size,
|
|
466
|
+
"returned_items": len(page_items),
|
|
467
|
+
"reported_total": total,
|
|
468
|
+
"page_amount": page_amount,
|
|
469
|
+
},
|
|
470
|
+
"selection": {
|
|
471
|
+
"app_key": app_key,
|
|
472
|
+
"field_id": field.que_id,
|
|
473
|
+
"field_title": field.que_title,
|
|
474
|
+
"keyword": keyword,
|
|
475
|
+
"permission_scope": "applicant_node",
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return self._run_record_tool(profile, runner)
|
|
481
|
+
|
|
482
|
+
def record_department_candidates(
|
|
483
|
+
self,
|
|
484
|
+
*,
|
|
485
|
+
profile: str,
|
|
486
|
+
app_key: str,
|
|
487
|
+
field_id: int,
|
|
488
|
+
keyword: str,
|
|
489
|
+
page_num: int,
|
|
490
|
+
page_size: int,
|
|
491
|
+
) -> JSONObject:
|
|
492
|
+
if not app_key:
|
|
493
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
494
|
+
if field_id <= 0:
|
|
495
|
+
raise_tool_error(QingflowApiError.config_error("field_id must be positive"))
|
|
496
|
+
if page_num <= 0:
|
|
497
|
+
raise_tool_error(QingflowApiError.config_error("page_num must be positive"))
|
|
498
|
+
if page_size <= 0:
|
|
499
|
+
raise_tool_error(QingflowApiError.config_error("page_size must be positive"))
|
|
500
|
+
|
|
501
|
+
def runner(session_profile, context):
|
|
502
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
503
|
+
field = self._resolve_field_selector(field_id, index, location="field_id")
|
|
504
|
+
if field.que_type not in DEPARTMENT_QUE_TYPES:
|
|
505
|
+
raise_tool_error(
|
|
506
|
+
QingflowApiError(
|
|
507
|
+
category="config",
|
|
508
|
+
message=f"field_id {field_id} is not a department field in the applicant-aware schema",
|
|
509
|
+
details={
|
|
510
|
+
"error_code": "FIELD_NOT_DEPARTMENT",
|
|
511
|
+
"field_id": field.que_id,
|
|
512
|
+
"field_title": field.que_title,
|
|
513
|
+
"que_type": field.que_type,
|
|
514
|
+
},
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
items = self._resolve_department_candidates(context, field, keyword=keyword)
|
|
518
|
+
total = len(items)
|
|
519
|
+
start = (page_num - 1) * page_size
|
|
520
|
+
end = start + page_size
|
|
521
|
+
page_items = items[start:end]
|
|
522
|
+
page_amount = (total + page_size - 1) // page_size if total else 0
|
|
523
|
+
return {
|
|
524
|
+
"profile": profile,
|
|
525
|
+
"ws_id": session_profile.selected_ws_id,
|
|
526
|
+
"ok": True,
|
|
527
|
+
"request_route": self._request_route_payload(context),
|
|
528
|
+
"warnings": [],
|
|
529
|
+
"output_profile": "normal",
|
|
530
|
+
"data": {
|
|
531
|
+
"items": page_items,
|
|
532
|
+
"pagination": {
|
|
533
|
+
"page": page_num,
|
|
534
|
+
"page_size": page_size,
|
|
535
|
+
"returned_items": len(page_items),
|
|
536
|
+
"reported_total": total,
|
|
537
|
+
"page_amount": page_amount,
|
|
538
|
+
},
|
|
539
|
+
"selection": {
|
|
540
|
+
"app_key": app_key,
|
|
541
|
+
"field_id": field.que_id,
|
|
542
|
+
"field_title": field.que_title,
|
|
543
|
+
"keyword": keyword,
|
|
544
|
+
"permission_scope": "applicant_node",
|
|
545
|
+
},
|
|
546
|
+
},
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return self._run_record_tool(profile, runner)
|
|
550
|
+
|
|
345
551
|
def record_analyze(
|
|
346
552
|
self,
|
|
347
553
|
*,
|
|
@@ -548,23 +754,48 @@ class RecordTools(ToolBase):
|
|
|
548
754
|
}
|
|
549
755
|
return response
|
|
550
756
|
|
|
757
|
+
# listType 降级顺序:数据管理全部 → 我发起的 → 待办 → 已办 → 抄送
|
|
758
|
+
_GET_LIST_TYPE_FALLBACKS = [DEFAULT_RECORD_LIST_TYPE, 14, 1, 2, 12]
|
|
759
|
+
|
|
551
760
|
def runner(session_profile, context):
|
|
552
761
|
index = self._get_field_index(profile, context, app_key, force_refresh=False)
|
|
553
762
|
selected_fields = list(index.by_id.values())
|
|
554
|
-
result =
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
763
|
+
result: JSONObject | None = None
|
|
764
|
+
used_list_type: int | None = None
|
|
765
|
+
last_error: QingflowApiError | None = None
|
|
766
|
+
for lt in _GET_LIST_TYPE_FALLBACKS:
|
|
767
|
+
try:
|
|
768
|
+
result = self.backend.request(
|
|
769
|
+
"GET",
|
|
770
|
+
context,
|
|
771
|
+
f"/app/{app_key}/apply/{record_id}",
|
|
772
|
+
params={"role": 1, "listType": lt},
|
|
773
|
+
)
|
|
774
|
+
used_list_type = lt
|
|
775
|
+
break
|
|
776
|
+
except QingflowApiError as exc:
|
|
777
|
+
last_error = exc
|
|
778
|
+
if exc.backend_code == 40002:
|
|
779
|
+
continue
|
|
780
|
+
raise
|
|
781
|
+
if result is None:
|
|
782
|
+
if last_error is not None:
|
|
783
|
+
raise last_error
|
|
784
|
+
raise_tool_error(QingflowApiError.config_error("record_get failed: no accessible listType"))
|
|
560
785
|
answer_list = result.get("answers") if isinstance(result, dict) and isinstance(result.get("answers"), list) else []
|
|
561
786
|
row = _build_flat_row(cast(list[JSONValue], answer_list), selected_fields, apply_id=record_id)
|
|
787
|
+
warnings: list[JSONObject] = []
|
|
788
|
+
if used_list_type is not None and used_list_type != DEFAULT_RECORD_LIST_TYPE:
|
|
789
|
+
warnings.append({
|
|
790
|
+
"code": "LIST_TYPE_FALLBACK",
|
|
791
|
+
"message": f"Record not accessible via listType={DEFAULT_RECORD_LIST_TYPE}; fell back to listType={used_list_type} ({get_record_list_type_label(used_list_type)}).",
|
|
792
|
+
})
|
|
562
793
|
response: JSONObject = {
|
|
563
794
|
"profile": profile,
|
|
564
795
|
"ws_id": session_profile.selected_ws_id,
|
|
565
796
|
"ok": True,
|
|
566
797
|
"request_route": self._request_route_payload(context),
|
|
567
|
-
"warnings":
|
|
798
|
+
"warnings": warnings,
|
|
568
799
|
"output_profile": normalized_output_profile,
|
|
569
800
|
"data": {
|
|
570
801
|
"app_key": app_key,
|
|
@@ -577,7 +808,7 @@ class RecordTools(ToolBase):
|
|
|
577
808
|
},
|
|
578
809
|
}
|
|
579
810
|
if normalized_output_profile == "verbose":
|
|
580
|
-
response["data"]["debug"] = {"raw_record": result}
|
|
811
|
+
response["data"]["debug"] = {"raw_record": result, "list_type_used": used_list_type}
|
|
581
812
|
return response
|
|
582
813
|
|
|
583
814
|
return self._run_record_tool(profile, runner)
|
|
@@ -737,9 +968,9 @@ class RecordTools(ToolBase):
|
|
|
737
968
|
preflight=None,
|
|
738
969
|
)
|
|
739
970
|
|
|
740
|
-
def _schema_field_payload(self, field: FormField, *, workflow_node_id: int) -> JSONObject:
|
|
971
|
+
def _schema_field_payload(self, profile: str, context, field: FormField, *, workflow_node_id: int, ws_id: int | None) -> JSONObject: # type: ignore[no-untyped-def]
|
|
741
972
|
write_hints = self._schema_write_hints(field)
|
|
742
|
-
|
|
973
|
+
payload = {
|
|
743
974
|
"field_id": field.que_id,
|
|
744
975
|
"title": field.que_title,
|
|
745
976
|
"que_type": field.que_type,
|
|
@@ -760,6 +991,17 @@ class RecordTools(ToolBase):
|
|
|
760
991
|
"requires_existing_row_id": write_hints["requires_existing_row_id"],
|
|
761
992
|
"unsupported_reason": write_hints["unsupported_reason"],
|
|
762
993
|
}
|
|
994
|
+
if field.que_type in RELATION_QUE_TYPES and field.target_app_key:
|
|
995
|
+
payload["target_app_key"] = field.target_app_key
|
|
996
|
+
target_app_name = field.target_app_name_hint or self._resolve_visible_app_name(
|
|
997
|
+
profile,
|
|
998
|
+
context,
|
|
999
|
+
target_app_key=field.target_app_key,
|
|
1000
|
+
ws_id=ws_id,
|
|
1001
|
+
)
|
|
1002
|
+
if target_app_name:
|
|
1003
|
+
payload["target_app_name"] = target_app_name
|
|
1004
|
+
return payload
|
|
763
1005
|
|
|
764
1006
|
def _schema_role_hints(self, field: FormField) -> JSONObject:
|
|
765
1007
|
field_family = self._schema_field_family(field)
|
|
@@ -822,6 +1064,350 @@ class RecordTools(ToolBase):
|
|
|
822
1064
|
}
|
|
823
1065
|
return mapping.get(kind, "scalar")
|
|
824
1066
|
|
|
1067
|
+
def _resolve_visible_app_name(
|
|
1068
|
+
self,
|
|
1069
|
+
profile: str,
|
|
1070
|
+
context, # type: ignore[no-untyped-def]
|
|
1071
|
+
*,
|
|
1072
|
+
target_app_key: str,
|
|
1073
|
+
ws_id: int | None,
|
|
1074
|
+
) -> str | None:
|
|
1075
|
+
cache_key = (profile, ws_id, target_app_key)
|
|
1076
|
+
if cache_key in self._app_name_cache:
|
|
1077
|
+
return self._app_name_cache[cache_key]
|
|
1078
|
+
name: str | None = None
|
|
1079
|
+
try:
|
|
1080
|
+
payload = self.backend.request("GET", context, f"/app/{target_app_key}/baseInfo")
|
|
1081
|
+
if isinstance(payload, dict):
|
|
1082
|
+
name = (
|
|
1083
|
+
_normalize_optional_text(payload.get("formTitle"))
|
|
1084
|
+
or _normalize_optional_text(payload.get("appName"))
|
|
1085
|
+
or _normalize_optional_text(payload.get("appTitle"))
|
|
1086
|
+
)
|
|
1087
|
+
except QingflowApiError:
|
|
1088
|
+
name = None
|
|
1089
|
+
self._app_name_cache[cache_key] = name
|
|
1090
|
+
return name
|
|
1091
|
+
|
|
1092
|
+
def _resolve_member_candidates(self, context, field: FormField, *, keyword: str) -> list[JSONObject]: # type: ignore[no-untyped-def]
|
|
1093
|
+
scope_type = field.member_select_scope_type
|
|
1094
|
+
scope = field.member_select_scope if isinstance(field.member_select_scope, dict) else {}
|
|
1095
|
+
if _scope_is_default_all(scope_type, scope, keys=("member", "depart", "role")):
|
|
1096
|
+
candidates = [
|
|
1097
|
+
_normalize_candidate_member(item, source_kind="workspace")
|
|
1098
|
+
for item in self._fetch_internal_members(context, department_id=None, role_id=None)
|
|
1099
|
+
]
|
|
1100
|
+
filtered = _filter_member_candidates([item for item in candidates if item is not None], keyword)
|
|
1101
|
+
filtered.sort(key=lambda item: (_normalize_optional_text(item.get("value")) or "", _coerce_count(item.get("id")) or 0))
|
|
1102
|
+
return filtered
|
|
1103
|
+
if scope_type != 1:
|
|
1104
|
+
raise_tool_error(
|
|
1105
|
+
QingflowApiError(
|
|
1106
|
+
category="not_supported",
|
|
1107
|
+
message="record_member_candidates only supports explicit member scopes on the applicant-node form",
|
|
1108
|
+
details={
|
|
1109
|
+
"error_code": "MEMBER_CANDIDATES_SCOPE_UNSUPPORTED",
|
|
1110
|
+
"field_id": field.que_id,
|
|
1111
|
+
"field_title": field.que_title,
|
|
1112
|
+
"member_select_scope_type": scope_type,
|
|
1113
|
+
},
|
|
1114
|
+
)
|
|
1115
|
+
)
|
|
1116
|
+
if _scope_has_dynamic_or_external(scope):
|
|
1117
|
+
raise_tool_error(
|
|
1118
|
+
QingflowApiError(
|
|
1119
|
+
category="not_supported",
|
|
1120
|
+
message="record_member_candidates does not support dynamic or external member scopes safely",
|
|
1121
|
+
details={
|
|
1122
|
+
"error_code": "MEMBER_CANDIDATES_SCOPE_UNSUPPORTED",
|
|
1123
|
+
"field_id": field.que_id,
|
|
1124
|
+
"field_title": field.que_title,
|
|
1125
|
+
"member_select_scope_type": scope_type,
|
|
1126
|
+
},
|
|
1127
|
+
)
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
merged: dict[str, JSONObject] = {}
|
|
1131
|
+
for item in cast(list[JSONValue], scope.get("member") or []):
|
|
1132
|
+
normalized = _normalize_candidate_member(item, source_kind="member")
|
|
1133
|
+
if normalized is None:
|
|
1134
|
+
continue
|
|
1135
|
+
self._merge_member_candidate(merged, normalized)
|
|
1136
|
+
|
|
1137
|
+
include_sub = _normalize_bool(scope.get("includeSubDeparts"))
|
|
1138
|
+
for item in cast(list[JSONValue], scope.get("depart") or []):
|
|
1139
|
+
dept_id = _coerce_count(item.get("deptId", item.get("id")) if isinstance(item, dict) else item)
|
|
1140
|
+
if dept_id is None:
|
|
1141
|
+
continue
|
|
1142
|
+
dept_name = _normalize_optional_text(item.get("deptName") if isinstance(item, dict) else None)
|
|
1143
|
+
for member in self._list_members_by_department(context, dept_id=dept_id, include_sub_departments=include_sub):
|
|
1144
|
+
normalized = _normalize_candidate_member(
|
|
1145
|
+
member,
|
|
1146
|
+
source_kind="department",
|
|
1147
|
+
source_id=dept_id,
|
|
1148
|
+
source_value=dept_name,
|
|
1149
|
+
)
|
|
1150
|
+
if normalized is None:
|
|
1151
|
+
continue
|
|
1152
|
+
self._merge_member_candidate(merged, normalized)
|
|
1153
|
+
|
|
1154
|
+
for item in cast(list[JSONValue], scope.get("role") or []):
|
|
1155
|
+
role_id = _coerce_count(item.get("roleId", item.get("id")) if isinstance(item, dict) else item)
|
|
1156
|
+
if role_id is None:
|
|
1157
|
+
continue
|
|
1158
|
+
role_name = _normalize_optional_text(item.get("roleName") if isinstance(item, dict) else None)
|
|
1159
|
+
for member in self._list_members_by_role(context, role_id=role_id):
|
|
1160
|
+
normalized = _normalize_candidate_member(
|
|
1161
|
+
member,
|
|
1162
|
+
source_kind="role",
|
|
1163
|
+
source_id=role_id,
|
|
1164
|
+
source_value=role_name,
|
|
1165
|
+
)
|
|
1166
|
+
if normalized is None:
|
|
1167
|
+
continue
|
|
1168
|
+
self._merge_member_candidate(merged, normalized)
|
|
1169
|
+
|
|
1170
|
+
candidates = list(merged.values())
|
|
1171
|
+
filtered = _filter_member_candidates(candidates, keyword)
|
|
1172
|
+
filtered.sort(key=lambda item: (_normalize_optional_text(item.get("value")) or "", _coerce_count(item.get("id")) or 0))
|
|
1173
|
+
return filtered
|
|
1174
|
+
|
|
1175
|
+
def _resolve_department_candidates(self, context, field: FormField, *, keyword: str) -> list[JSONObject]: # type: ignore[no-untyped-def]
|
|
1176
|
+
scope_type = field.dept_select_scope_type
|
|
1177
|
+
scope = field.dept_select_scope if isinstance(field.dept_select_scope, dict) else {}
|
|
1178
|
+
if _scope_has_dynamic_or_external(scope):
|
|
1179
|
+
raise_tool_error(
|
|
1180
|
+
QingflowApiError(
|
|
1181
|
+
category="not_supported",
|
|
1182
|
+
message="record_department_candidates does not support dynamic or external department scopes safely",
|
|
1183
|
+
details={
|
|
1184
|
+
"error_code": "DEPARTMENT_CANDIDATES_SCOPE_UNSUPPORTED",
|
|
1185
|
+
"field_id": field.que_id,
|
|
1186
|
+
"field_title": field.que_title,
|
|
1187
|
+
"dept_select_scope_type": scope_type,
|
|
1188
|
+
},
|
|
1189
|
+
)
|
|
1190
|
+
)
|
|
1191
|
+
merged: dict[str, JSONObject] = {}
|
|
1192
|
+
include_sub = _normalize_bool(scope.get("includeSubDeparts"))
|
|
1193
|
+
if _scope_is_default_all(scope_type, scope, keys=("depart",)):
|
|
1194
|
+
for item in self._list_all_departments(context):
|
|
1195
|
+
normalized = _normalize_candidate_department(item, source_kind="workspace")
|
|
1196
|
+
if normalized is not None:
|
|
1197
|
+
self._merge_department_candidate(merged, normalized)
|
|
1198
|
+
else:
|
|
1199
|
+
if scope_type != 1:
|
|
1200
|
+
raise_tool_error(
|
|
1201
|
+
QingflowApiError(
|
|
1202
|
+
category="not_supported",
|
|
1203
|
+
message="record_department_candidates only supports explicit or default-all department scopes on the applicant-node form",
|
|
1204
|
+
details={
|
|
1205
|
+
"error_code": "DEPARTMENT_CANDIDATES_SCOPE_UNSUPPORTED",
|
|
1206
|
+
"field_id": field.que_id,
|
|
1207
|
+
"field_title": field.que_title,
|
|
1208
|
+
"dept_select_scope_type": scope_type,
|
|
1209
|
+
},
|
|
1210
|
+
)
|
|
1211
|
+
)
|
|
1212
|
+
for item in cast(list[JSONValue], scope.get("depart") or []):
|
|
1213
|
+
dept_id = _coerce_count(item.get("deptId", item.get("id")) if isinstance(item, dict) else item)
|
|
1214
|
+
if dept_id is None:
|
|
1215
|
+
continue
|
|
1216
|
+
dept_name = _normalize_optional_text(item.get("deptName", item.get("value")) if isinstance(item, dict) else None)
|
|
1217
|
+
for dept in self._list_departments_by_scope(context, dept_id=dept_id, include_sub_departments=include_sub):
|
|
1218
|
+
normalized = _normalize_candidate_department(
|
|
1219
|
+
dept,
|
|
1220
|
+
source_kind="department",
|
|
1221
|
+
source_id=dept_id,
|
|
1222
|
+
source_value=dept_name,
|
|
1223
|
+
)
|
|
1224
|
+
if normalized is not None:
|
|
1225
|
+
self._merge_department_candidate(merged, normalized)
|
|
1226
|
+
filtered = _filter_department_candidates(list(merged.values()), keyword)
|
|
1227
|
+
filtered.sort(key=lambda item: (_normalize_optional_text(item.get("value")) or "", _coerce_count(item.get("id")) or 0))
|
|
1228
|
+
return filtered
|
|
1229
|
+
|
|
1230
|
+
def _merge_member_candidate(self, merged: dict[str, JSONObject], candidate: JSONObject) -> None:
|
|
1231
|
+
key = _member_candidate_key(candidate)
|
|
1232
|
+
if not key:
|
|
1233
|
+
return
|
|
1234
|
+
existing = merged.get(key)
|
|
1235
|
+
if existing is None:
|
|
1236
|
+
merged[key] = candidate
|
|
1237
|
+
return
|
|
1238
|
+
if not existing.get("userId") and candidate.get("userId"):
|
|
1239
|
+
existing["userId"] = candidate["userId"]
|
|
1240
|
+
if not existing.get("email") and candidate.get("email"):
|
|
1241
|
+
existing["email"] = candidate["email"]
|
|
1242
|
+
if (not _normalize_optional_text(existing.get("value"))) and _normalize_optional_text(candidate.get("value")):
|
|
1243
|
+
existing["value"] = candidate["value"]
|
|
1244
|
+
existing_sources = existing.setdefault("sources", [])
|
|
1245
|
+
candidate_sources = candidate.get("sources")
|
|
1246
|
+
if isinstance(existing_sources, list) and isinstance(candidate_sources, list):
|
|
1247
|
+
seen = {json.dumps(item, ensure_ascii=False, sort_keys=True) for item in existing_sources if isinstance(item, dict)}
|
|
1248
|
+
for source in candidate_sources:
|
|
1249
|
+
if not isinstance(source, dict):
|
|
1250
|
+
continue
|
|
1251
|
+
marker = json.dumps(source, ensure_ascii=False, sort_keys=True)
|
|
1252
|
+
if marker in seen:
|
|
1253
|
+
continue
|
|
1254
|
+
existing_sources.append(source)
|
|
1255
|
+
seen.add(marker)
|
|
1256
|
+
|
|
1257
|
+
def _list_members_by_department(
|
|
1258
|
+
self,
|
|
1259
|
+
context, # type: ignore[no-untyped-def]
|
|
1260
|
+
*,
|
|
1261
|
+
dept_id: int,
|
|
1262
|
+
include_sub_departments: bool,
|
|
1263
|
+
) -> list[dict[str, Any]]:
|
|
1264
|
+
dept_ids = {dept_id}
|
|
1265
|
+
if include_sub_departments:
|
|
1266
|
+
dept_ids.update(self._expand_department_ids(context, root_dept_id=dept_id))
|
|
1267
|
+
merged: dict[str, dict[str, Any]] = {}
|
|
1268
|
+
for current_dept_id in dept_ids:
|
|
1269
|
+
for item in self._fetch_internal_members(context, department_id=current_dept_id, role_id=None):
|
|
1270
|
+
member_key = _member_candidate_key(item)
|
|
1271
|
+
if member_key and member_key not in merged:
|
|
1272
|
+
merged[member_key] = item
|
|
1273
|
+
return list(merged.values())
|
|
1274
|
+
|
|
1275
|
+
def _list_members_by_role(self, context, *, role_id: int) -> list[dict[str, Any]]: # type: ignore[no-untyped-def]
|
|
1276
|
+
return self._fetch_internal_members(context, department_id=None, role_id=role_id)
|
|
1277
|
+
|
|
1278
|
+
def _list_departments_by_scope(
|
|
1279
|
+
self,
|
|
1280
|
+
context, # type: ignore[no-untyped-def]
|
|
1281
|
+
*,
|
|
1282
|
+
dept_id: int,
|
|
1283
|
+
include_sub_departments: bool,
|
|
1284
|
+
) -> list[dict[str, Any]]:
|
|
1285
|
+
dept_ids = {dept_id}
|
|
1286
|
+
if include_sub_departments:
|
|
1287
|
+
dept_ids.update(self._expand_department_ids(context, root_dept_id=dept_id))
|
|
1288
|
+
merged: dict[str, dict[str, Any]] = {}
|
|
1289
|
+
for item in self._list_all_departments(context):
|
|
1290
|
+
department_key = _department_candidate_key(item)
|
|
1291
|
+
current_dept_id = _coerce_count(item.get("deptId", item.get("id")))
|
|
1292
|
+
if not department_key or current_dept_id is None or current_dept_id not in dept_ids or department_key in merged:
|
|
1293
|
+
continue
|
|
1294
|
+
merged[department_key] = item
|
|
1295
|
+
return list(merged.values())
|
|
1296
|
+
|
|
1297
|
+
def _list_all_departments(self, context) -> list[dict[str, Any]]: # type: ignore[no-untyped-def]
|
|
1298
|
+
queue: list[int | None] = [None]
|
|
1299
|
+
seen_ids: set[int] = set()
|
|
1300
|
+
requested_parents: set[int | None] = set()
|
|
1301
|
+
items: list[dict[str, Any]] = []
|
|
1302
|
+
while queue:
|
|
1303
|
+
current_parent = queue.pop(0)
|
|
1304
|
+
if current_parent in requested_parents:
|
|
1305
|
+
continue
|
|
1306
|
+
requested_parents.add(current_parent)
|
|
1307
|
+
params: JSONObject = {}
|
|
1308
|
+
if current_parent is not None:
|
|
1309
|
+
params["parentDeptId"] = current_parent
|
|
1310
|
+
result = self.backend.request("GET", context, "/contact/subDeptList", params=params)
|
|
1311
|
+
page_items = _directory_items(result)
|
|
1312
|
+
if not page_items and current_parent is None:
|
|
1313
|
+
result = self.backend.request("GET", context, "/contact/subDeptList", params={"parentDeptId": 0})
|
|
1314
|
+
page_items = _directory_items(result)
|
|
1315
|
+
for item in page_items:
|
|
1316
|
+
if not isinstance(item, dict):
|
|
1317
|
+
continue
|
|
1318
|
+
dept_id = _coerce_count(item.get("deptId", item.get("id")))
|
|
1319
|
+
if dept_id is None or dept_id in seen_ids:
|
|
1320
|
+
continue
|
|
1321
|
+
seen_ids.add(dept_id)
|
|
1322
|
+
normalized = dict(item)
|
|
1323
|
+
if current_parent is not None and "parentDeptId" not in normalized:
|
|
1324
|
+
normalized["parentDeptId"] = current_parent
|
|
1325
|
+
items.append(normalized)
|
|
1326
|
+
queue.append(dept_id)
|
|
1327
|
+
return items
|
|
1328
|
+
|
|
1329
|
+
def _merge_department_candidate(self, merged: dict[str, JSONObject], candidate: JSONObject) -> None:
|
|
1330
|
+
key = _department_candidate_key(candidate)
|
|
1331
|
+
if not key:
|
|
1332
|
+
return
|
|
1333
|
+
existing = merged.get(key)
|
|
1334
|
+
if existing is None:
|
|
1335
|
+
merged[key] = candidate
|
|
1336
|
+
return
|
|
1337
|
+
if (not _normalize_optional_text(existing.get("value"))) and _normalize_optional_text(candidate.get("value")):
|
|
1338
|
+
existing["value"] = candidate["value"]
|
|
1339
|
+
existing_sources = existing.setdefault("sources", [])
|
|
1340
|
+
candidate_sources = candidate.get("sources")
|
|
1341
|
+
if isinstance(existing_sources, list) and isinstance(candidate_sources, list):
|
|
1342
|
+
seen = {json.dumps(item, ensure_ascii=False, sort_keys=True) for item in existing_sources if isinstance(item, dict)}
|
|
1343
|
+
for source in candidate_sources:
|
|
1344
|
+
if not isinstance(source, dict):
|
|
1345
|
+
continue
|
|
1346
|
+
marker = json.dumps(source, ensure_ascii=False, sort_keys=True)
|
|
1347
|
+
if marker in seen:
|
|
1348
|
+
continue
|
|
1349
|
+
existing_sources.append(source)
|
|
1350
|
+
seen.add(marker)
|
|
1351
|
+
|
|
1352
|
+
def _expand_department_ids(self, context, *, root_dept_id: int) -> set[int]: # type: ignore[no-untyped-def]
|
|
1353
|
+
seen: set[int] = set()
|
|
1354
|
+
queue: list[int] = [root_dept_id]
|
|
1355
|
+
while queue:
|
|
1356
|
+
current_dept_id = queue.pop(0)
|
|
1357
|
+
if current_dept_id in seen:
|
|
1358
|
+
continue
|
|
1359
|
+
seen.add(current_dept_id)
|
|
1360
|
+
result = self.backend.request("GET", context, "/contact/subDeptList", params={"parentDeptId": current_dept_id})
|
|
1361
|
+
for item in _directory_items(result):
|
|
1362
|
+
if not isinstance(item, dict):
|
|
1363
|
+
continue
|
|
1364
|
+
child_id = _coerce_count(item.get("deptId", item.get("id")))
|
|
1365
|
+
if child_id is None or child_id in seen:
|
|
1366
|
+
continue
|
|
1367
|
+
queue.append(child_id)
|
|
1368
|
+
return seen
|
|
1369
|
+
|
|
1370
|
+
def _fetch_internal_members(
|
|
1371
|
+
self,
|
|
1372
|
+
context, # type: ignore[no-untyped-def]
|
|
1373
|
+
*,
|
|
1374
|
+
department_id: int | None,
|
|
1375
|
+
role_id: int | None,
|
|
1376
|
+
) -> list[dict[str, Any]]:
|
|
1377
|
+
current_page = 1
|
|
1378
|
+
fetched_pages = 0
|
|
1379
|
+
seen: dict[str, dict[str, Any]] = {}
|
|
1380
|
+
while fetched_pages < MAX_MEMBER_SCOPE_FETCH_PAGES:
|
|
1381
|
+
params: JSONObject = {
|
|
1382
|
+
"pageNum": current_page,
|
|
1383
|
+
"pageSize": DEFAULT_MEMBER_SCOPE_FETCH_PAGE_SIZE,
|
|
1384
|
+
"containDisable": False,
|
|
1385
|
+
}
|
|
1386
|
+
if department_id is not None:
|
|
1387
|
+
params["deptId"] = department_id
|
|
1388
|
+
if role_id is not None:
|
|
1389
|
+
params["roleId"] = role_id
|
|
1390
|
+
result = self.backend.request("GET", context, "/contact", params=params)
|
|
1391
|
+
page_items = _directory_items(result)
|
|
1392
|
+
for item in page_items:
|
|
1393
|
+
if not isinstance(item, dict):
|
|
1394
|
+
continue
|
|
1395
|
+
normalized = dict(item)
|
|
1396
|
+
member_key = _member_candidate_key(normalized)
|
|
1397
|
+
if not member_key or member_key in seen:
|
|
1398
|
+
continue
|
|
1399
|
+
seen[member_key] = normalized
|
|
1400
|
+
fetched_pages += 1
|
|
1401
|
+
if not _directory_has_more(
|
|
1402
|
+
result,
|
|
1403
|
+
current_page=current_page,
|
|
1404
|
+
page_size=DEFAULT_MEMBER_SCOPE_FETCH_PAGE_SIZE,
|
|
1405
|
+
returned_items=len(page_items),
|
|
1406
|
+
):
|
|
1407
|
+
break
|
|
1408
|
+
current_page += 1
|
|
1409
|
+
return list(seen.values())
|
|
1410
|
+
|
|
825
1411
|
def _schema_field_family(self, field: FormField) -> str:
|
|
826
1412
|
if self._schema_is_identifier_like(field):
|
|
827
1413
|
return "text"
|
|
@@ -1968,6 +2554,9 @@ class RecordTools(ToolBase):
|
|
|
1968
2554
|
|
|
1969
2555
|
return self._run_record_tool(profile, runner)
|
|
1970
2556
|
|
|
2557
|
+
# listType 降级顺序(内部 record_get)
|
|
2558
|
+
_INTERNAL_GET_LIST_TYPE_FALLBACKS = [DEFAULT_RECORD_LIST_TYPE, 14, 1, 2, 12]
|
|
2559
|
+
|
|
1971
2560
|
def record_get(
|
|
1972
2561
|
self,
|
|
1973
2562
|
*,
|
|
@@ -1981,12 +2570,40 @@ class RecordTools(ToolBase):
|
|
|
1981
2570
|
normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
|
|
1982
2571
|
|
|
1983
2572
|
def runner(session_profile, context):
|
|
1984
|
-
|
|
1985
|
-
if list_type is not None:
|
|
1986
|
-
params["listType"] = list_type
|
|
2573
|
+
base_params: JSONObject = {"role": role}
|
|
1987
2574
|
if audit_node_id is not None:
|
|
1988
|
-
|
|
1989
|
-
|
|
2575
|
+
base_params["auditNodeId"] = audit_node_id
|
|
2576
|
+
|
|
2577
|
+
# 如果调用方指定了 list_type,直接用,不降级
|
|
2578
|
+
if list_type is not None:
|
|
2579
|
+
base_params["listType"] = list_type
|
|
2580
|
+
result = self.backend.request("GET", context, f"/app/{app_key}/apply/{normalized_apply_id}", params=base_params)
|
|
2581
|
+
return {
|
|
2582
|
+
"profile": profile,
|
|
2583
|
+
"ws_id": session_profile.selected_ws_id,
|
|
2584
|
+
"request_route": self._request_route_payload(context),
|
|
2585
|
+
"app_key": app_key,
|
|
2586
|
+
"apply_id": normalized_apply_id,
|
|
2587
|
+
"result": result,
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2590
|
+
# 未指定 list_type 时自动降级
|
|
2591
|
+
result: JSONObject | None = None
|
|
2592
|
+
last_error: QingflowApiError | None = None
|
|
2593
|
+
for lt in self._INTERNAL_GET_LIST_TYPE_FALLBACKS:
|
|
2594
|
+
try:
|
|
2595
|
+
params = {**base_params, "listType": lt}
|
|
2596
|
+
result = self.backend.request("GET", context, f"/app/{app_key}/apply/{normalized_apply_id}", params=params)
|
|
2597
|
+
break
|
|
2598
|
+
except QingflowApiError as exc:
|
|
2599
|
+
last_error = exc
|
|
2600
|
+
if exc.backend_code == 40002:
|
|
2601
|
+
continue
|
|
2602
|
+
raise
|
|
2603
|
+
if result is None:
|
|
2604
|
+
if last_error is not None:
|
|
2605
|
+
raise last_error
|
|
2606
|
+
raise_tool_error(QingflowApiError.config_error("record_get failed: no accessible listType"))
|
|
1990
2607
|
return {
|
|
1991
2608
|
"profile": profile,
|
|
1992
2609
|
"ws_id": session_profile.selected_ws_id,
|
|
@@ -4078,6 +4695,12 @@ def _build_field_index(schema: JSONObject) -> FieldIndex:
|
|
|
4078
4695
|
system=bool(question.get("system") or question.get("beingSystem") or is_base_question),
|
|
4079
4696
|
options=_extract_question_options(question),
|
|
4080
4697
|
aliases=[],
|
|
4698
|
+
target_app_key=_extract_relation_target_app_key(question),
|
|
4699
|
+
target_app_name_hint=_extract_relation_target_app_name_hint(question),
|
|
4700
|
+
member_select_scope_type=_coerce_count(question.get("memberSelectScopeType")),
|
|
4701
|
+
member_select_scope=_normalize_scope_payload(question.get("memberSelectScope")),
|
|
4702
|
+
dept_select_scope_type=_coerce_count(question.get("deptSelectScopeType")),
|
|
4703
|
+
dept_select_scope=_normalize_scope_payload(question.get("deptSelectScope")),
|
|
4081
4704
|
raw=question,
|
|
4082
4705
|
)
|
|
4083
4706
|
if str(que_id) in by_id:
|
|
@@ -4090,6 +4713,32 @@ def _build_field_index(schema: JSONObject) -> FieldIndex:
|
|
|
4090
4713
|
return FieldIndex(by_id=by_id, by_title=by_title, by_alias=by_alias)
|
|
4091
4714
|
|
|
4092
4715
|
|
|
4716
|
+
def _extract_relation_target_app_key(question: JSONObject) -> str | None:
|
|
4717
|
+
if _coerce_count(question.get("queType")) not in RELATION_QUE_TYPES:
|
|
4718
|
+
return None
|
|
4719
|
+
reference = question.get("referenceConfig")
|
|
4720
|
+
if not isinstance(reference, dict):
|
|
4721
|
+
return None
|
|
4722
|
+
return _normalize_optional_text(reference.get("referAppKey"))
|
|
4723
|
+
|
|
4724
|
+
|
|
4725
|
+
def _extract_relation_target_app_name_hint(question: JSONObject) -> str | None:
|
|
4726
|
+
if _coerce_count(question.get("queType")) not in RELATION_QUE_TYPES:
|
|
4727
|
+
return None
|
|
4728
|
+
reference = question.get("referenceConfig")
|
|
4729
|
+
if not isinstance(reference, dict):
|
|
4730
|
+
return None
|
|
4731
|
+
for key in ("referAppName", "referAppTitle", "referFormTitle", "appName", "appTitle"):
|
|
4732
|
+
text = _normalize_optional_text(reference.get(key))
|
|
4733
|
+
if text:
|
|
4734
|
+
return text
|
|
4735
|
+
return None
|
|
4736
|
+
|
|
4737
|
+
|
|
4738
|
+
def _normalize_scope_payload(value: JSONValue) -> JSONObject | None:
|
|
4739
|
+
return value if isinstance(value, dict) else None
|
|
4740
|
+
|
|
4741
|
+
|
|
4093
4742
|
def _flatten_questions(payload: JSONValue) -> list[JSONObject]:
|
|
4094
4743
|
flattened: list[JSONObject] = []
|
|
4095
4744
|
if isinstance(payload, dict):
|
|
@@ -5260,6 +5909,164 @@ def _relation_value(value: JSONValue) -> JSONObject:
|
|
|
5260
5909
|
return {"value": _stringify_json(value)}
|
|
5261
5910
|
|
|
5262
5911
|
|
|
5912
|
+
def _normalize_candidate_member(
|
|
5913
|
+
value: JSONValue,
|
|
5914
|
+
*,
|
|
5915
|
+
source_kind: str,
|
|
5916
|
+
source_id: int | None = None,
|
|
5917
|
+
source_value: str | None = None,
|
|
5918
|
+
) -> JSONObject | None:
|
|
5919
|
+
if not isinstance(value, dict):
|
|
5920
|
+
return None
|
|
5921
|
+
member_id = _coerce_count(value.get("uid", value.get("id")))
|
|
5922
|
+
user_id = _normalize_optional_text(value.get("userId"))
|
|
5923
|
+
if member_id is None and not user_id:
|
|
5924
|
+
return None
|
|
5925
|
+
display_value = (
|
|
5926
|
+
_normalize_optional_text(value.get("nickName"))
|
|
5927
|
+
or _normalize_optional_text(value.get("name"))
|
|
5928
|
+
or _normalize_optional_text(value.get("userName"))
|
|
5929
|
+
or _normalize_optional_text(value.get("value"))
|
|
5930
|
+
or (str(member_id) if member_id is not None else user_id)
|
|
5931
|
+
)
|
|
5932
|
+
if not display_value:
|
|
5933
|
+
return None
|
|
5934
|
+
candidate: JSONObject = {"value": display_value, "sources": [{"kind": source_kind}]}
|
|
5935
|
+
if member_id is not None:
|
|
5936
|
+
candidate["id"] = member_id
|
|
5937
|
+
if user_id:
|
|
5938
|
+
candidate["userId"] = user_id
|
|
5939
|
+
email = _normalize_optional_text(value.get("email"))
|
|
5940
|
+
if email:
|
|
5941
|
+
candidate["email"] = email
|
|
5942
|
+
if source_id is not None:
|
|
5943
|
+
cast(list[JSONObject], candidate["sources"])[0]["id"] = source_id
|
|
5944
|
+
if source_value:
|
|
5945
|
+
cast(list[JSONObject], candidate["sources"])[0]["value"] = source_value
|
|
5946
|
+
return candidate
|
|
5947
|
+
|
|
5948
|
+
|
|
5949
|
+
def _normalize_candidate_department(
|
|
5950
|
+
value: JSONValue,
|
|
5951
|
+
*,
|
|
5952
|
+
source_kind: str,
|
|
5953
|
+
source_id: int | None = None,
|
|
5954
|
+
source_value: str | None = None,
|
|
5955
|
+
) -> JSONObject | None:
|
|
5956
|
+
if not isinstance(value, dict):
|
|
5957
|
+
return None
|
|
5958
|
+
dept_id = _coerce_count(value.get("deptId", value.get("id")))
|
|
5959
|
+
if dept_id is None:
|
|
5960
|
+
return None
|
|
5961
|
+
display_value = (
|
|
5962
|
+
_normalize_optional_text(value.get("deptName"))
|
|
5963
|
+
or _normalize_optional_text(value.get("value"))
|
|
5964
|
+
or _normalize_optional_text(value.get("name"))
|
|
5965
|
+
or str(dept_id)
|
|
5966
|
+
)
|
|
5967
|
+
if not display_value:
|
|
5968
|
+
return None
|
|
5969
|
+
candidate: JSONObject = {"id": dept_id, "value": display_value, "sources": [{"kind": source_kind}]}
|
|
5970
|
+
parent_dept_id = _coerce_count(value.get("parentDeptId"))
|
|
5971
|
+
if parent_dept_id is not None:
|
|
5972
|
+
candidate["parentDeptId"] = parent_dept_id
|
|
5973
|
+
if source_id is not None:
|
|
5974
|
+
cast(list[JSONObject], candidate["sources"])[0]["id"] = source_id
|
|
5975
|
+
if source_value:
|
|
5976
|
+
cast(list[JSONObject], candidate["sources"])[0]["value"] = source_value
|
|
5977
|
+
return candidate
|
|
5978
|
+
|
|
5979
|
+
|
|
5980
|
+
def _member_candidate_key(value: JSONValue) -> str | None:
|
|
5981
|
+
if not isinstance(value, dict):
|
|
5982
|
+
return None
|
|
5983
|
+
member_id = _coerce_count(value.get("id", value.get("uid")))
|
|
5984
|
+
if member_id is not None:
|
|
5985
|
+
return f"id:{member_id}"
|
|
5986
|
+
user_id = _normalize_optional_text(value.get("userId"))
|
|
5987
|
+
if user_id:
|
|
5988
|
+
return f"userId:{user_id}"
|
|
5989
|
+
return None
|
|
5990
|
+
|
|
5991
|
+
|
|
5992
|
+
def _department_candidate_key(value: JSONValue) -> str | None:
|
|
5993
|
+
if not isinstance(value, dict):
|
|
5994
|
+
return None
|
|
5995
|
+
dept_id = _coerce_count(value.get("id", value.get("deptId")))
|
|
5996
|
+
if dept_id is None:
|
|
5997
|
+
return None
|
|
5998
|
+
return f"id:{dept_id}"
|
|
5999
|
+
|
|
6000
|
+
|
|
6001
|
+
def _filter_member_candidates(items: list[JSONObject], keyword: str) -> list[JSONObject]:
|
|
6002
|
+
normalized_keyword = keyword.strip().lower()
|
|
6003
|
+
if not normalized_keyword:
|
|
6004
|
+
return items
|
|
6005
|
+
filtered: list[JSONObject] = []
|
|
6006
|
+
for item in items:
|
|
6007
|
+
haystacks = [
|
|
6008
|
+
_normalize_optional_text(item.get("value")) or "",
|
|
6009
|
+
_normalize_optional_text(item.get("userId")) or "",
|
|
6010
|
+
_normalize_optional_text(item.get("email")) or "",
|
|
6011
|
+
]
|
|
6012
|
+
if any(normalized_keyword in haystack.lower() for haystack in haystacks if haystack):
|
|
6013
|
+
filtered.append(item)
|
|
6014
|
+
return filtered
|
|
6015
|
+
|
|
6016
|
+
|
|
6017
|
+
def _filter_department_candidates(items: list[JSONObject], keyword: str) -> list[JSONObject]:
|
|
6018
|
+
normalized_keyword = keyword.strip().lower()
|
|
6019
|
+
if not normalized_keyword:
|
|
6020
|
+
return items
|
|
6021
|
+
filtered: list[JSONObject] = []
|
|
6022
|
+
for item in items:
|
|
6023
|
+
haystacks = [
|
|
6024
|
+
_normalize_optional_text(item.get("value")) or "",
|
|
6025
|
+
_stringify_json(item.get("id")),
|
|
6026
|
+
]
|
|
6027
|
+
if any(normalized_keyword in haystack.lower() for haystack in haystacks if haystack):
|
|
6028
|
+
filtered.append(item)
|
|
6029
|
+
return filtered
|
|
6030
|
+
|
|
6031
|
+
|
|
6032
|
+
def _scope_has_dynamic_or_external(scope: JSONObject) -> bool:
|
|
6033
|
+
dynamic_items = scope.get("dynamic")
|
|
6034
|
+
external_members = scope.get("externalMemberList")
|
|
6035
|
+
external_departs = scope.get("externalDepartList")
|
|
6036
|
+
return bool(
|
|
6037
|
+
isinstance(dynamic_items, list)
|
|
6038
|
+
and dynamic_items
|
|
6039
|
+
or isinstance(external_members, list)
|
|
6040
|
+
and external_members
|
|
6041
|
+
or isinstance(external_departs, list)
|
|
6042
|
+
and external_departs
|
|
6043
|
+
)
|
|
6044
|
+
|
|
6045
|
+
|
|
6046
|
+
def _scope_is_default_all(scope_type: int | None, scope: JSONObject, *, keys: tuple[str, ...]) -> bool:
|
|
6047
|
+
if scope_type == 2:
|
|
6048
|
+
return True
|
|
6049
|
+
if scope_type != 1:
|
|
6050
|
+
return False
|
|
6051
|
+
if _scope_has_dynamic_or_external(scope):
|
|
6052
|
+
return False
|
|
6053
|
+
for key in keys:
|
|
6054
|
+
items = scope.get(key)
|
|
6055
|
+
if isinstance(items, list) and items:
|
|
6056
|
+
return False
|
|
6057
|
+
return True
|
|
6058
|
+
|
|
6059
|
+
|
|
6060
|
+
def _normalize_bool(value: JSONValue) -> bool:
|
|
6061
|
+
if isinstance(value, bool):
|
|
6062
|
+
return value
|
|
6063
|
+
if isinstance(value, (int, float)):
|
|
6064
|
+
return bool(value)
|
|
6065
|
+
if isinstance(value, str):
|
|
6066
|
+
return value.strip().lower() in {"1", "true", "yes", "y", "是"}
|
|
6067
|
+
return False
|
|
6068
|
+
|
|
6069
|
+
|
|
5263
6070
|
def _field_ref_payload(field: FormField) -> JSONObject:
|
|
5264
6071
|
payload = {"que_id": field.que_id, "que_title": field.que_title, "que_type": field.que_type}
|
|
5265
6072
|
if field.aliases:
|