@josephyan/qingflow-app-user-mcp 0.2.0-beta.27 → 0.2.0-beta.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-record-analysis/SKILL.md +1 -1
- package/skills/qingflow-record-crud/SKILL.md +12 -1
- package/skills/qingflow-record-crud/references/record-patterns.md +19 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/server.py +4 -0
- package/src/qingflow_mcp/server_app_user.py +4 -0
- package/src/qingflow_mcp/tools/record_tools.py +191 -37
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.
|
|
6
|
+
npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.29
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.
|
|
12
|
+
npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.29 qingflow-app-user-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -9,7 +9,7 @@ metadata:
|
|
|
9
9
|
|
|
10
10
|
This skill is for final statistical conclusions only.
|
|
11
11
|
Assumes MCP is connected, authenticated, and on the correct workspace.
|
|
12
|
-
Analysis tasks must start with `record_schema_get`.
|
|
12
|
+
Analysis tasks must start with `record_schema_get`. Read top-level `fields` and `suggested_*`, then build field_id-based DSLs only.
|
|
13
13
|
|
|
14
14
|
## Step 1: `record_schema_get` → Step 2: build DSL → Step 3: `record_analyze`
|
|
15
15
|
|
|
@@ -27,7 +27,7 @@ Use exactly one of these default paths:
|
|
|
27
27
|
- `record_get`
|
|
28
28
|
- `record_write`
|
|
29
29
|
|
|
30
|
-
`record_schema_get` only exposes the current user's applicant-node visible fields
|
|
30
|
+
`record_schema_get` only exposes the current user's applicant-node visible fields. Read top-level `fields` and `suggested_*`; if a field is missing, treat it as unavailable in the current permission scope.
|
|
31
31
|
|
|
32
32
|
## Supporting Tools
|
|
33
33
|
|
|
@@ -42,6 +42,8 @@ Use exactly one of these default paths:
|
|
|
42
42
|
- `file_get_upload_info`
|
|
43
43
|
- `file_upload_local`
|
|
44
44
|
|
|
45
|
+
Use `record_member_candidates` / `record_department_candidates` as the default lookup path for member or department fields. Treat `directory_*` as org-browse or fallback tooling, not the primary field-candidate path.
|
|
46
|
+
|
|
45
47
|
## Standard Operating Order
|
|
46
48
|
|
|
47
49
|
1. Ensure auth exists
|
|
@@ -58,6 +60,7 @@ Use exactly one of these default paths:
|
|
|
58
60
|
## Record Read Rules
|
|
59
61
|
|
|
60
62
|
- Use `record_list` for browse/export/sample inspection only
|
|
63
|
+
- For `columns`, prefer `[{ "field_id": 12 }]`; bare integer field ids are accepted for compatibility
|
|
61
64
|
- Use `record_get` when `record_id` is known
|
|
62
65
|
- `record_get` without explicit `columns` still returns only applicant-node visible fields; do not assume it exposes the full builder-side record
|
|
63
66
|
- `record_list` and `record_get` may reject hidden-field `field_id`s because record tools now validate against the applicant-node visible schema only
|
|
@@ -101,8 +104,16 @@ The DSL is clause-shaped like SQL, but it is **not raw SQL text**.
|
|
|
101
104
|
- Do not auto-resolve relation targets without first querying them
|
|
102
105
|
- Do not assume member display names resolve automatically; `record_member_candidates` returns ids, but direct name-to-id write is not automatic
|
|
103
106
|
- Do not assume department names resolve automatically; `record_department_candidates` returns ids, but direct name-to-id write is not automatic
|
|
107
|
+
- For default-all member or department fields, prefer the field candidate tools; do not start with `directory_*`
|
|
104
108
|
- Do not assume `record_schema_get` is a builder/full-field schema.
|
|
105
109
|
|
|
110
|
+
### Complex field quick examples
|
|
111
|
+
|
|
112
|
+
- `member`: `✅ {"field_id":5,"value":{"id":7}}` / `❌ {"field_id":5,"value":"张三"}`
|
|
113
|
+
- `department`: `✅ {"field_id":22,"value":{"id":336193,"value":"北斗组"}}` / `❌ {"field_id":22,"value":"北斗组"}`
|
|
114
|
+
- `relation`: `✅ {"field_id":25,"value":{"apply_id":5001}}` / `❌ {"field_id":25,"value":"客户A"}`
|
|
115
|
+
- `attachment`: upload first, then `✅ {"field_id":13,"value":{"value":"https://.../a.pdf","name":"a.pdf"}}` / `❌ {"field_id":13,"value":"/tmp/a.pdf"}`
|
|
116
|
+
|
|
106
117
|
## Response Interpretation
|
|
107
118
|
|
|
108
119
|
- `record_list` returns browse/sample data, not final analysis conclusions
|
|
@@ -15,7 +15,7 @@ Remember that `record_schema_get` only exposes the current user's applicant-node
|
|
|
15
15
|
|
|
16
16
|
Keep the browse DSL simple:
|
|
17
17
|
|
|
18
|
-
- `columns`:
|
|
18
|
+
- `columns`: prefer `[{ "field_id": 12 }]`; bare integers are accepted for compatibility
|
|
19
19
|
- `where`: flat AND filters only
|
|
20
20
|
- `order_by`: field sorting only
|
|
21
21
|
- `limit` and `page`: browsing intent only
|
|
@@ -110,3 +110,21 @@ If the payload includes them, stop after the blocked `record_write` response and
|
|
|
110
110
|
- Relation fields are record-id based. Resolve the referenced target first, then write the relation field with the real `record_id`.
|
|
111
111
|
- Attachment fields are two-step: upload first with `file_upload_local`, then reuse the returned attachment payload in `record_write`.
|
|
112
112
|
- Subtable writes require the current schema shape; when updating existing subtable rows, preserve row ids if the current record exposes them.
|
|
113
|
+
|
|
114
|
+
### Quick field examples
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{ "field_id": 5, "value": { "id": 7 } }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
{ "field_id": 22, "value": { "id": 336193, "value": "北斗组" } }
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{ "field_id": 25, "value": { "apply_id": 5001 } }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{ "field_id": 13, "value": { "value": "https://.../a.pdf", "name": "a.pdf" } }
|
|
130
|
+
```
|
|
@@ -53,6 +53,7 @@ Always call `record_schema_get` before `record_list`, `record_get`, `record_writ
|
|
|
53
53
|
|
|
54
54
|
- Hidden fields are omitted.
|
|
55
55
|
- Missing fields mean the field is not visible in the current permission scope.
|
|
56
|
+
- Read `fields` and `suggested_*` from the top level of the schema response.
|
|
56
57
|
|
|
57
58
|
## Analytics Path
|
|
58
59
|
|
|
@@ -79,6 +80,8 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
79
80
|
|
|
80
81
|
`record_schema_get -> record_list / record_get / record_write`
|
|
81
82
|
|
|
83
|
+
- For `columns`, prefer `[{{field_id}}]`; bare integer field ids remain supported for compatibility.
|
|
84
|
+
|
|
82
85
|
`record_write` uses SQL-like JSON clauses:
|
|
83
86
|
|
|
84
87
|
- `insert` -> `values`
|
|
@@ -87,6 +90,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
87
90
|
|
|
88
91
|
- Read relation targets from `record_schema_get.target_app_key` / `target_app_name` before preparing relation writes.
|
|
89
92
|
- 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`.
|
|
93
|
+
- For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
|
|
90
94
|
|
|
91
95
|
## Task Center Path
|
|
92
96
|
|
|
@@ -41,6 +41,7 @@ Always call `record_schema_get` before `record_list`, `record_get`, `record_writ
|
|
|
41
41
|
|
|
42
42
|
- Hidden fields are omitted.
|
|
43
43
|
- Missing fields mean the field is not visible in the current permission scope.
|
|
44
|
+
- Read `fields` and `suggested_*` from the top level of the schema response.
|
|
44
45
|
|
|
45
46
|
## Analytics Path
|
|
46
47
|
|
|
@@ -67,6 +68,8 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
67
68
|
|
|
68
69
|
`record_schema_get -> record_list / record_get / record_write`
|
|
69
70
|
|
|
71
|
+
- For `columns`, prefer `[{{field_id}}]`; bare integer field ids remain supported for compatibility.
|
|
72
|
+
|
|
70
73
|
`record_write` uses SQL-like JSON clauses:
|
|
71
74
|
|
|
72
75
|
- `insert` -> `values`
|
|
@@ -75,6 +78,7 @@ Analysis answers must include concrete numbers. When applicable, include percent
|
|
|
75
78
|
|
|
76
79
|
- Read relation targets from `record_schema_get.target_app_key` / `target_app_name` before preparing relation writes.
|
|
77
80
|
- 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`.
|
|
81
|
+
- For default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
|
|
78
82
|
|
|
79
83
|
## Task Center Path
|
|
80
84
|
|
|
@@ -269,7 +269,7 @@ class RecordTools(ToolBase):
|
|
|
269
269
|
def record_list(
|
|
270
270
|
profile: str = DEFAULT_PROFILE,
|
|
271
271
|
app_key: str = "",
|
|
272
|
-
columns: list[int] | None = None,
|
|
272
|
+
columns: list[JSONObject | int] | None = None,
|
|
273
273
|
where: list[JSONObject] | None = None,
|
|
274
274
|
order_by: list[JSONObject] | None = None,
|
|
275
275
|
limit: int = 50,
|
|
@@ -296,7 +296,7 @@ class RecordTools(ToolBase):
|
|
|
296
296
|
profile: str = DEFAULT_PROFILE,
|
|
297
297
|
app_key: str = "",
|
|
298
298
|
record_id: int = 0,
|
|
299
|
-
columns: list[int] | None = None,
|
|
299
|
+
columns: list[JSONObject | int] | None = None,
|
|
300
300
|
workflow_node_id: int | None = None,
|
|
301
301
|
output_profile: str = "normal",
|
|
302
302
|
) -> JSONObject:
|
|
@@ -389,23 +389,21 @@ class RecordTools(ToolBase):
|
|
|
389
389
|
"ok": True,
|
|
390
390
|
"status": "success",
|
|
391
391
|
"request_route": self._request_route_payload(context),
|
|
392
|
-
"
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
"
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
"type": applicant_node.type,
|
|
399
|
-
},
|
|
400
|
-
"view_resolution": _view_selection_payload(view_selection),
|
|
401
|
-
"fields": fields,
|
|
402
|
-
"suggested_dimensions": suggested_dimensions,
|
|
403
|
-
"suggested_metrics": suggested_metrics,
|
|
404
|
-
"suggested_time_fields": suggested_time_fields,
|
|
392
|
+
"app_key": app_key,
|
|
393
|
+
"schema_scope": "applicant_node",
|
|
394
|
+
"workflow_node": {
|
|
395
|
+
"workflow_node_id": applicant_node.workflow_node_id,
|
|
396
|
+
"name": applicant_node.name,
|
|
397
|
+
"type": applicant_node.type,
|
|
405
398
|
},
|
|
399
|
+
"view_resolution": _view_selection_payload(view_selection),
|
|
400
|
+
"fields": fields,
|
|
401
|
+
"suggested_dimensions": suggested_dimensions,
|
|
402
|
+
"suggested_metrics": suggested_metrics,
|
|
403
|
+
"suggested_time_fields": suggested_time_fields,
|
|
406
404
|
}
|
|
407
405
|
if output_profile == "verbose":
|
|
408
|
-
response["
|
|
406
|
+
response["field_count"] = len(fields)
|
|
409
407
|
return response
|
|
410
408
|
|
|
411
409
|
return self._run_record_tool(profile, runner)
|
|
@@ -605,7 +603,7 @@ class RecordTools(ToolBase):
|
|
|
605
603
|
*,
|
|
606
604
|
profile: str,
|
|
607
605
|
app_key: str,
|
|
608
|
-
columns: list[int],
|
|
606
|
+
columns: list[JSONObject | int],
|
|
609
607
|
where: list[JSONObject],
|
|
610
608
|
order_by: list[JSONObject],
|
|
611
609
|
limit: int,
|
|
@@ -617,10 +615,9 @@ class RecordTools(ToolBase):
|
|
|
617
615
|
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
618
616
|
if not app_key:
|
|
619
617
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
620
|
-
|
|
618
|
+
normalized_columns = _normalize_public_column_selectors(columns)
|
|
619
|
+
if not normalized_columns:
|
|
621
620
|
raise_tool_error(QingflowApiError.config_error("columns is required"))
|
|
622
|
-
if any(not isinstance(item, int) or item < 0 for item in columns):
|
|
623
|
-
raise_tool_error(QingflowApiError.config_error("columns must be a list of field_id integers"))
|
|
624
621
|
if limit <= 0:
|
|
625
622
|
raise_tool_error(QingflowApiError.config_error("limit must be positive"))
|
|
626
623
|
if page <= 0:
|
|
@@ -640,8 +637,8 @@ class RecordTools(ToolBase):
|
|
|
640
637
|
filters=self._normalize_record_list_where(where),
|
|
641
638
|
sorts=self._normalize_record_list_order_by(order_by),
|
|
642
639
|
max_rows=limit,
|
|
643
|
-
max_columns=len(
|
|
644
|
-
select_columns=
|
|
640
|
+
max_columns=len(normalized_columns),
|
|
641
|
+
select_columns=normalized_columns,
|
|
645
642
|
amount_column=None,
|
|
646
643
|
time_range={},
|
|
647
644
|
stat_policy={},
|
|
@@ -674,7 +671,7 @@ class RecordTools(ToolBase):
|
|
|
674
671
|
"result_amount": pagination.get("result_amount"),
|
|
675
672
|
},
|
|
676
673
|
"selection": {
|
|
677
|
-
"columns":
|
|
674
|
+
"columns": [_column_selector_payload(field_id) for field_id in normalized_columns],
|
|
678
675
|
"view": cast(JSONObject, raw["data"]).get("view"),
|
|
679
676
|
},
|
|
680
677
|
},
|
|
@@ -695,17 +692,16 @@ class RecordTools(ToolBase):
|
|
|
695
692
|
profile: str,
|
|
696
693
|
app_key: str,
|
|
697
694
|
record_id: int,
|
|
698
|
-
columns: list[int],
|
|
695
|
+
columns: list[JSONObject | int],
|
|
699
696
|
workflow_node_id: int | None,
|
|
700
697
|
output_profile: str,
|
|
701
698
|
) -> JSONObject:
|
|
702
699
|
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
703
700
|
if record_id <= 0:
|
|
704
701
|
raise_tool_error(QingflowApiError.config_error("record_id must be positive"))
|
|
705
|
-
|
|
706
|
-
raise_tool_error(QingflowApiError.config_error("columns must be a list of field_id integers"))
|
|
702
|
+
normalized_columns = _normalize_public_column_selectors(columns)
|
|
707
703
|
|
|
708
|
-
if
|
|
704
|
+
if normalized_columns:
|
|
709
705
|
raw = self.record_query(
|
|
710
706
|
profile=profile,
|
|
711
707
|
query_mode="record",
|
|
@@ -720,8 +716,8 @@ class RecordTools(ToolBase):
|
|
|
720
716
|
filters=[],
|
|
721
717
|
sorts=[],
|
|
722
718
|
max_rows=1,
|
|
723
|
-
max_columns=len(
|
|
724
|
-
select_columns=
|
|
719
|
+
max_columns=len(normalized_columns),
|
|
720
|
+
select_columns=normalized_columns,
|
|
725
721
|
amount_column=None,
|
|
726
722
|
time_range={},
|
|
727
723
|
stat_policy={},
|
|
@@ -742,7 +738,7 @@ class RecordTools(ToolBase):
|
|
|
742
738
|
"record_id": record_id,
|
|
743
739
|
"record": record_data.get("row"),
|
|
744
740
|
"selection": {
|
|
745
|
-
"columns":
|
|
741
|
+
"columns": [_column_selector_payload(field_id) for field_id in normalized_columns],
|
|
746
742
|
"workflow_node_id": workflow_node_id,
|
|
747
743
|
},
|
|
748
744
|
},
|
|
@@ -1095,9 +1091,9 @@ class RecordTools(ToolBase):
|
|
|
1095
1091
|
if _scope_is_default_all(scope_type, scope, keys=("member", "depart", "role")):
|
|
1096
1092
|
candidates = [
|
|
1097
1093
|
_normalize_candidate_member(item, source_kind="workspace")
|
|
1098
|
-
for item in self.
|
|
1094
|
+
for item in self._search_workspace_members(context, keyword=keyword)
|
|
1099
1095
|
]
|
|
1100
|
-
filtered =
|
|
1096
|
+
filtered = [item for item in candidates if item is not None]
|
|
1101
1097
|
filtered.sort(key=lambda item: (_normalize_optional_text(item.get("value")) or "", _coerce_count(item.get("id")) or 0))
|
|
1102
1098
|
return filtered
|
|
1103
1099
|
if scope_type != 1:
|
|
@@ -1191,7 +1187,7 @@ class RecordTools(ToolBase):
|
|
|
1191
1187
|
merged: dict[str, JSONObject] = {}
|
|
1192
1188
|
include_sub = _normalize_bool(scope.get("includeSubDeparts"))
|
|
1193
1189
|
if _scope_is_default_all(scope_type, scope, keys=("depart",)):
|
|
1194
|
-
for item in self.
|
|
1190
|
+
for item in self._search_workspace_departments(context, keyword=keyword):
|
|
1195
1191
|
normalized = _normalize_candidate_department(item, source_kind="workspace")
|
|
1196
1192
|
if normalized is not None:
|
|
1197
1193
|
self._merge_department_candidate(merged, normalized)
|
|
@@ -1408,6 +1404,56 @@ class RecordTools(ToolBase):
|
|
|
1408
1404
|
current_page += 1
|
|
1409
1405
|
return list(seen.values())
|
|
1410
1406
|
|
|
1407
|
+
def _search_workspace_members(self, context, *, keyword: str) -> list[dict[str, Any]]: # type: ignore[no-untyped-def]
|
|
1408
|
+
return self._search_workspace_directory_dimension(context, dimension="MEMBER", bucket_key="member", keyword=keyword)
|
|
1409
|
+
|
|
1410
|
+
def _search_workspace_departments(self, context, *, keyword: str) -> list[dict[str, Any]]: # type: ignore[no-untyped-def]
|
|
1411
|
+
return self._search_workspace_directory_dimension(context, dimension="DEPT", bucket_key="dept", keyword=keyword)
|
|
1412
|
+
|
|
1413
|
+
def _search_workspace_directory_dimension(
|
|
1414
|
+
self,
|
|
1415
|
+
context, # type: ignore[no-untyped-def]
|
|
1416
|
+
*,
|
|
1417
|
+
dimension: str,
|
|
1418
|
+
bucket_key: str,
|
|
1419
|
+
keyword: str,
|
|
1420
|
+
) -> list[dict[str, Any]]:
|
|
1421
|
+
current_page = 1
|
|
1422
|
+
fetched_pages = 0
|
|
1423
|
+
seen: dict[str, dict[str, Any]] = {}
|
|
1424
|
+
while fetched_pages < MAX_MEMBER_SCOPE_FETCH_PAGES:
|
|
1425
|
+
result = self.backend.request(
|
|
1426
|
+
"POST",
|
|
1427
|
+
context,
|
|
1428
|
+
"/member/search",
|
|
1429
|
+
json_body={
|
|
1430
|
+
"dimensions": [dimension],
|
|
1431
|
+
"searchKey": keyword,
|
|
1432
|
+
"pageNum": current_page,
|
|
1433
|
+
"pageSize": DEFAULT_MEMBER_SCOPE_FETCH_PAGE_SIZE,
|
|
1434
|
+
},
|
|
1435
|
+
)
|
|
1436
|
+
bucket_payload = _member_search_bucket_payload(result, bucket_key=bucket_key)
|
|
1437
|
+
page_items = _directory_items(bucket_payload)
|
|
1438
|
+
for item in page_items:
|
|
1439
|
+
if not isinstance(item, dict):
|
|
1440
|
+
continue
|
|
1441
|
+
normalized = dict(item)
|
|
1442
|
+
item_key = _member_candidate_key(normalized) if bucket_key == "member" else _department_candidate_key(normalized)
|
|
1443
|
+
if not item_key or item_key in seen:
|
|
1444
|
+
continue
|
|
1445
|
+
seen[item_key] = normalized
|
|
1446
|
+
fetched_pages += 1
|
|
1447
|
+
if not _directory_has_more(
|
|
1448
|
+
bucket_payload,
|
|
1449
|
+
current_page=current_page,
|
|
1450
|
+
page_size=DEFAULT_MEMBER_SCOPE_FETCH_PAGE_SIZE,
|
|
1451
|
+
returned_items=len(page_items),
|
|
1452
|
+
):
|
|
1453
|
+
break
|
|
1454
|
+
current_page += 1
|
|
1455
|
+
return list(seen.values())
|
|
1456
|
+
|
|
1411
1457
|
def _schema_field_family(self, field: FormField) -> str:
|
|
1412
1458
|
if self._schema_is_identifier_like(field):
|
|
1413
1459
|
return "text"
|
|
@@ -3360,6 +3406,8 @@ class RecordTools(ToolBase):
|
|
|
3360
3406
|
self._raise_if_verify_unsupported_write_field(field, item, location=field.que_title)
|
|
3361
3407
|
if "values" in item and isinstance(item["values"], list):
|
|
3362
3408
|
values = item["values"]
|
|
3409
|
+
elif field.que_type in RELATION_QUE_TYPES and isinstance(item.get("referValues"), list):
|
|
3410
|
+
values = item["referValues"]
|
|
3363
3411
|
elif "value" in item:
|
|
3364
3412
|
values = [item["value"]]
|
|
3365
3413
|
else:
|
|
@@ -3369,6 +3417,8 @@ class RecordTools(ToolBase):
|
|
|
3369
3417
|
fix_hint="Pass value for scalar fields, or values for multi-value fields.",
|
|
3370
3418
|
details={"location": field.que_title, "field": _field_ref_payload(field), "expected_format": _write_format_for_field(field)},
|
|
3371
3419
|
)
|
|
3420
|
+
if field.que_type in RELATION_QUE_TYPES:
|
|
3421
|
+
return self._build_relation_answer(field, values)
|
|
3372
3422
|
return {
|
|
3373
3423
|
"queId": field.que_id,
|
|
3374
3424
|
"queType": field.que_type or 2,
|
|
@@ -3387,6 +3437,8 @@ class RecordTools(ToolBase):
|
|
|
3387
3437
|
}
|
|
3388
3438
|
self._raise_if_verify_unsupported_write_field(field, raw_value, location=str(field_selector))
|
|
3389
3439
|
values = raw_value if isinstance(raw_value, list) and field.que_type in MULTI_SELECT_QUE_TYPES else [raw_value]
|
|
3440
|
+
if field.que_type in RELATION_QUE_TYPES:
|
|
3441
|
+
return self._build_relation_answer(field, values)
|
|
3390
3442
|
return {
|
|
3391
3443
|
"queId": field.que_id,
|
|
3392
3444
|
"queType": field.que_type or 2,
|
|
@@ -3394,6 +3446,21 @@ class RecordTools(ToolBase):
|
|
|
3394
3446
|
"tableValues": [],
|
|
3395
3447
|
}
|
|
3396
3448
|
|
|
3449
|
+
def _build_relation_answer(self, field: FormField, raw_values: list[JSONValue]) -> JSONObject:
|
|
3450
|
+
values: list[JSONObject] = []
|
|
3451
|
+
refer_values: list[JSONObject] = []
|
|
3452
|
+
for raw_value in _expand_values(raw_values):
|
|
3453
|
+
value_payload, refer_payload = _relation_value_payload(field, raw_value)
|
|
3454
|
+
values.append(value_payload)
|
|
3455
|
+
refer_values.append(refer_payload)
|
|
3456
|
+
return {
|
|
3457
|
+
"queId": field.que_id,
|
|
3458
|
+
"queType": field.que_type or 25,
|
|
3459
|
+
"values": values,
|
|
3460
|
+
"referValues": refer_values,
|
|
3461
|
+
"tableValues": [],
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3397
3464
|
def _raise_if_verify_unsupported_write_field(self, field: FormField, raw_value: JSONValue, *, location: str) -> None:
|
|
3398
3465
|
if field.que_type not in VERIFY_UNSUPPORTED_WRITE_QUE_TYPES:
|
|
3399
3466
|
return
|
|
@@ -4435,6 +4502,25 @@ class RecordTools(ToolBase):
|
|
|
4435
4502
|
count_mismatches=count_mismatches,
|
|
4436
4503
|
)
|
|
4437
4504
|
continue
|
|
4505
|
+
if field is not None and field.que_type in RELATION_QUE_TYPES:
|
|
4506
|
+
expected_relation_ids = _relation_ids_from_answer(answer)
|
|
4507
|
+
actual_relation_ids = _relation_ids_from_answer(actual)
|
|
4508
|
+
if expected_relation_ids and not actual_relation_ids:
|
|
4509
|
+
empty_fields.append(field_payload)
|
|
4510
|
+
continue
|
|
4511
|
+
if expected_relation_ids:
|
|
4512
|
+
actual_id_set = set(actual_relation_ids)
|
|
4513
|
+
missing_ids = [value for value in expected_relation_ids if value not in actual_id_set]
|
|
4514
|
+
if missing_ids:
|
|
4515
|
+
count_mismatches.append(
|
|
4516
|
+
{
|
|
4517
|
+
**field_payload,
|
|
4518
|
+
"expected_ids": expected_relation_ids,
|
|
4519
|
+
"actual_ids": actual_relation_ids,
|
|
4520
|
+
"missing_ids": missing_ids,
|
|
4521
|
+
}
|
|
4522
|
+
)
|
|
4523
|
+
continue
|
|
4438
4524
|
actual_values = actual.get("values") if isinstance(actual.get("values"), list) else []
|
|
4439
4525
|
if not actual_values:
|
|
4440
4526
|
empty_fields.append(field_payload)
|
|
@@ -5262,6 +5348,28 @@ def _extract_sort_selector(item: JSONObject) -> JSONValue:
|
|
|
5262
5348
|
return None
|
|
5263
5349
|
|
|
5264
5350
|
|
|
5351
|
+
def _normalize_public_column_selectors(columns: list[JSONObject | int]) -> list[int]:
|
|
5352
|
+
normalized: list[int] = []
|
|
5353
|
+
for item in columns:
|
|
5354
|
+
field_id: int | None = None
|
|
5355
|
+
if isinstance(item, int):
|
|
5356
|
+
field_id = item
|
|
5357
|
+
elif isinstance(item, dict):
|
|
5358
|
+
field_id = _coerce_count(item.get("field_id", item.get("fieldId")))
|
|
5359
|
+
if field_id is None or field_id < 0:
|
|
5360
|
+
raise_tool_error(
|
|
5361
|
+
QingflowApiError.config_error(
|
|
5362
|
+
"columns must be a list of field_id integers or {field_id} objects"
|
|
5363
|
+
)
|
|
5364
|
+
)
|
|
5365
|
+
normalized.append(field_id)
|
|
5366
|
+
return normalized
|
|
5367
|
+
|
|
5368
|
+
|
|
5369
|
+
def _column_selector_payload(field_id: int) -> JSONObject:
|
|
5370
|
+
return {"field_id": field_id}
|
|
5371
|
+
|
|
5372
|
+
|
|
5265
5373
|
def _resolve_sort_ascend(item: JSONObject) -> bool:
|
|
5266
5374
|
if "isAscend" in item:
|
|
5267
5375
|
return bool(item["isAscend"])
|
|
@@ -5896,7 +6004,24 @@ def _attachment_value(value: JSONValue) -> JSONObject:
|
|
|
5896
6004
|
|
|
5897
6005
|
|
|
5898
6006
|
def _relation_value(value: JSONValue) -> JSONObject:
|
|
6007
|
+
return _relation_value_payload(None, value)[0]
|
|
6008
|
+
|
|
6009
|
+
|
|
6010
|
+
def _relation_value_payload(field: FormField | None, value: JSONValue) -> tuple[JSONObject, JSONObject]:
|
|
5899
6011
|
if isinstance(value, dict):
|
|
6012
|
+
target_app_key = _normalize_optional_text(value.get("target_app_key", value.get("targetAppKey", value.get("app_key", value.get("appKey")))))
|
|
6013
|
+
if field is not None and field.target_app_key and target_app_key and target_app_key != field.target_app_key:
|
|
6014
|
+
raise RecordInputError(
|
|
6015
|
+
message=f"relation field '{field.que_title}' points to a different target app",
|
|
6016
|
+
error_code="RELATION_TARGET_APP_MISMATCH",
|
|
6017
|
+
fix_hint=f"Use a record from target app '{field.target_app_key}'.",
|
|
6018
|
+
details={
|
|
6019
|
+
"field": _field_ref_payload(field),
|
|
6020
|
+
"expected_target_app_key": field.target_app_key,
|
|
6021
|
+
"received_target_app_key": target_app_key,
|
|
6022
|
+
"received_value": value,
|
|
6023
|
+
},
|
|
6024
|
+
)
|
|
5900
6025
|
apply_id = value.get("apply_id", value.get("applyId", value.get("value", value.get("id"))))
|
|
5901
6026
|
if apply_id is None:
|
|
5902
6027
|
raise RecordInputError(
|
|
@@ -5905,8 +6030,37 @@ def _relation_value(value: JSONValue) -> JSONObject:
|
|
|
5905
6030
|
fix_hint="Pass relation values like {'apply_id': 5001} or numeric apply ids.",
|
|
5906
6031
|
details={"received_value": value},
|
|
5907
6032
|
)
|
|
5908
|
-
|
|
5909
|
-
|
|
6033
|
+
normalized_apply_id = _stringify_json(apply_id)
|
|
6034
|
+
return ({"value": normalized_apply_id}, {"applyId": normalized_apply_id})
|
|
6035
|
+
normalized_apply_id = _stringify_json(value)
|
|
6036
|
+
return ({"value": normalized_apply_id}, {"applyId": normalized_apply_id})
|
|
6037
|
+
|
|
6038
|
+
|
|
6039
|
+
def _relation_ids_from_answer(answer: JSONObject) -> list[str]:
|
|
6040
|
+
ids: list[str] = []
|
|
6041
|
+
seen: set[str] = set()
|
|
6042
|
+
for key in ("referValues", "values"):
|
|
6043
|
+
values = answer.get(key)
|
|
6044
|
+
if not isinstance(values, list):
|
|
6045
|
+
continue
|
|
6046
|
+
for item in values:
|
|
6047
|
+
if not isinstance(item, dict):
|
|
6048
|
+
continue
|
|
6049
|
+
relation_id = item.get("applyId", item.get("apply_id", item.get("value", item.get("id"))))
|
|
6050
|
+
normalized = _normalize_optional_text(relation_id) or (_stringify_json(relation_id) if relation_id is not None else None)
|
|
6051
|
+
if not normalized or normalized in seen:
|
|
6052
|
+
continue
|
|
6053
|
+
ids.append(normalized)
|
|
6054
|
+
seen.add(normalized)
|
|
6055
|
+
return ids
|
|
6056
|
+
|
|
6057
|
+
|
|
6058
|
+
def _member_search_bucket_payload(payload: JSONValue, *, bucket_key: str) -> JSONValue:
|
|
6059
|
+
if isinstance(payload, dict):
|
|
6060
|
+
bucket = payload.get(bucket_key)
|
|
6061
|
+
if isinstance(bucket, (dict, list)):
|
|
6062
|
+
return bucket
|
|
6063
|
+
return payload
|
|
5910
6064
|
|
|
5911
6065
|
|
|
5912
6066
|
def _normalize_candidate_member(
|
|
@@ -6247,8 +6401,8 @@ def _write_format_for_field(field: FormField) -> JSONObject:
|
|
|
6247
6401
|
return _write_support_payload(
|
|
6248
6402
|
support_level="restricted",
|
|
6249
6403
|
kind="relation_record",
|
|
6250
|
-
examples=[{"
|
|
6251
|
-
required_presteps=["Query the target app first and use the referenced apply_id."],
|
|
6404
|
+
examples=[{"apply_id": 5001}],
|
|
6405
|
+
required_presteps=["Query the target app first and use the referenced apply_id from the target app."],
|
|
6252
6406
|
)
|
|
6253
6407
|
if field.que_type in SINGLE_SELECT_QUE_TYPES:
|
|
6254
6408
|
return _write_support_payload(support_level="full", kind="single_select", options=field.options)
|