@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.26
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.26 qingflow-app-builder-mcp
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-builder-mcp",
3
- "version": "0.2.0-beta.26",
3
+ "version": "0.2.0-beta.27",
4
4
  "description": "Builder MCP for Qingflow app/package/system design and staged solution workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b26"
7
+ version = "0.2.0b27"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b26"
5
+ __version__ = "0.2.0b27"
@@ -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
- "Use auth_login first, then workspace_list and workspace_select. "
30
- "All resource tools operate with the logged-in user's Qingflow permissions.\n\n"
31
- "If app_key is unknown, use app_list or app_search first to discover current-user visible apps in the selected workspace. "
32
- "For analytics, use record_schema_get first, let the model build field_id-based DSL, "
33
- "then call record_analyze. record_analyze returns compact business-first output as query/result/ranking/ratios/completeness/presentation; use verbose only for route/debug details. "
34
- "record_schema_get returns the current user's applicant-node visible schema only; hidden fields are omitted and missing fields should be treated as not visible in the current permission scope. "
35
- "For operational record reads, use record_schema_get first, then record_list or record_get. "
36
- "For writes, use record_schema_get and then call record_write once; it performs internal preflight before any apply and refuses fields outside the applicant-node writable schema.\n\n"
37
- "Task Center (待办/已办) handling:\n"
38
- "- Use task_summary to get headline counts.\n"
39
- "- Use task_list for flat task browsing with task_box and flow_status.\n"
40
- "- Use task_facets when worksheet or workflow-node buckets matter.\n"
41
- "- Use task_mark_read to mark a specific task as read.\n"
42
- "- Use task_urge to send an urgent reminder for a pending task.\n"
43
- "- After identifying the exact task node and record, use task_approve, task_reject, task_rollback, or task_transfer as needed."
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
- "Use this server for Qingflow operational workflows with a schema-first path. "
23
- "If app_key is unknown, use app_list or app_search first to discover current-user visible apps in the selected workspace. "
24
- "For records, start with record_schema_get, then choose record_list, record_get, or record_write. "
25
- "record_schema_get returns the current user's applicant-node visible schema only; hidden fields are omitted and missing fields should be treated as not visible in the current permission scope. "
26
- "For analytics, switch to record_schema_get and record_analyze; its default output is compact query/result/ranking/ratios/completeness/presentation, with route/debug only in verbose mode. "
27
- "For task center, use task_summary, task_list, and task_facets before any explicit task action. "
28
- "Avoid builder-side app or schema changes here."
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 = [self._schema_field_payload(field, workflow_node_id=applicant_node.workflow_node_id) for field in index.by_id.values()]
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 = self.backend.request(
555
- "GET",
556
- context,
557
- f"/app/{app_key}/apply/{record_id}",
558
- params={"role": 1},
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
- return {
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
- params: JSONObject = {"role": role}
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
- params["auditNodeId"] = audit_node_id
1989
- result = self.backend.request("GET", context, f"/app/{app_key}/apply/{normalized_apply_id}", params=params)
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: