@qingflow-tech/qingflow-app-user-mcp 1.0.9 → 1.0.11
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-update/SKILL.md +2 -0
- package/src/qingflow_mcp/builder_facade/models.py +43 -2
- package/src/qingflow_mcp/builder_facade/service.py +1476 -172
- package/src/qingflow_mcp/cli/commands/app.py +3 -16
- package/src/qingflow_mcp/cli/commands/builder.py +68 -13
- package/src/qingflow_mcp/cli/commands/record.py +16 -1
- package/src/qingflow_mcp/cli/formatters.py +32 -1
- package/src/qingflow_mcp/cli/main.py +204 -3
- package/src/qingflow_mcp/public_surface.py +3 -1
- package/src/qingflow_mcp/response_trim.py +70 -13
- package/src/qingflow_mcp/server.py +10 -9
- package/src/qingflow_mcp/server_app_builder.py +53 -7
- package/src/qingflow_mcp/server_app_user.py +12 -15
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +294 -0
- package/src/qingflow_mcp/tools/ai_builder_tools.py +1642 -75
- package/src/qingflow_mcp/tools/app_tools.py +53 -8
- package/src/qingflow_mcp/tools/package_tools.py +16 -2
- package/src/qingflow_mcp/tools/record_tools.py +1423 -70
- package/src/qingflow_mcp/tools/resource_read_tools.py +3 -0
|
@@ -45,6 +45,11 @@ RECORD_ACCESS_UNBOUNDED_ROW_THRESHOLD = 50_000
|
|
|
45
45
|
RECORD_ACCESS_TIME_BUDGET_SECONDS = 55.0
|
|
46
46
|
RECORD_ACCESS_MIN_REMAINING_SECONDS = 8.0
|
|
47
47
|
RECORD_GET_DETAIL_LOG_PAGE_SIZE = 10
|
|
48
|
+
RECORD_LOGS_PAGE_SIZE = 200
|
|
49
|
+
RECORD_LOGS_PREVIEW_LIMIT = 10
|
|
50
|
+
RECORD_LOGS_MAX_ITEMS = 20_000
|
|
51
|
+
RECORD_LOGS_TIME_BUDGET_SECONDS = 55.0
|
|
52
|
+
RECORD_LOGS_MIN_REMAINING_SECONDS = 8.0
|
|
48
53
|
RECORD_GET_MEDIA_MAX_IMAGES = 30
|
|
49
54
|
RECORD_GET_MEDIA_MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
50
55
|
RECORD_GET_MEDIA_MAX_TOTAL_BYTES = 100 * 1024 * 1024
|
|
@@ -269,6 +274,30 @@ GENERIC_FIELD_ALIAS_OVERRIDES: dict[str, list[str]] = {
|
|
|
269
274
|
FIELD_LOOKUP_STRIP_RE = re.compile(r"[\s_()()\[\]【】{}<>·/\\::-]+")
|
|
270
275
|
|
|
271
276
|
|
|
277
|
+
def _pick_route_payload(payload: JSONObject) -> JSONObject:
|
|
278
|
+
return {
|
|
279
|
+
key: payload[key]
|
|
280
|
+
for key in (
|
|
281
|
+
"route_type",
|
|
282
|
+
"endpoint_kind",
|
|
283
|
+
"status",
|
|
284
|
+
"role",
|
|
285
|
+
"task_id",
|
|
286
|
+
"workflow_node_id",
|
|
287
|
+
"view_id",
|
|
288
|
+
"view_key",
|
|
289
|
+
"view_name",
|
|
290
|
+
"error_code",
|
|
291
|
+
"backend_code",
|
|
292
|
+
"http_status",
|
|
293
|
+
"request_id",
|
|
294
|
+
"message",
|
|
295
|
+
"reason",
|
|
296
|
+
)
|
|
297
|
+
if key in payload and payload[key] not in (None, "", [], {})
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
272
301
|
class RecordTools(ToolBase):
|
|
273
302
|
"""记录工具(中文名:记录读写与分析)。
|
|
274
303
|
|
|
@@ -442,6 +471,20 @@ class RecordTools(ToolBase):
|
|
|
442
471
|
output_profile=output_profile,
|
|
443
472
|
)
|
|
444
473
|
|
|
474
|
+
@mcp.tool(description="Read all visible data logs and workflow logs for one Qingflow record into local JSONL files. This tool hides pagination and returns file paths plus completeness metadata.")
|
|
475
|
+
def record_logs_get(
|
|
476
|
+
profile: str = DEFAULT_PROFILE,
|
|
477
|
+
app_key: str = "",
|
|
478
|
+
record_id: str = "",
|
|
479
|
+
view_id: str | None = None,
|
|
480
|
+
) -> JSONObject:
|
|
481
|
+
return self.record_logs_get(
|
|
482
|
+
profile=profile,
|
|
483
|
+
app_key=app_key,
|
|
484
|
+
record_id=record_id,
|
|
485
|
+
view_id=view_id,
|
|
486
|
+
)
|
|
487
|
+
|
|
445
488
|
@mcp.tool()
|
|
446
489
|
def record_browse_schema_get(
|
|
447
490
|
app_key: str = "",
|
|
@@ -493,8 +536,9 @@ class RecordTools(ToolBase):
|
|
|
493
536
|
@mcp.tool(
|
|
494
537
|
description=(
|
|
495
538
|
"Update one Qingflow record using a field map. "
|
|
496
|
-
"
|
|
497
|
-
"
|
|
539
|
+
"For simple field changes, call this tool directly after the target record is clear. "
|
|
540
|
+
"It first tries the data-manager direct route, then falls back to the frontend custom-view edit route when available. "
|
|
541
|
+
"Use record_update_schema_get for diagnostics or complex field-scope inspection."
|
|
498
542
|
)
|
|
499
543
|
)
|
|
500
544
|
def record_update(
|
|
@@ -995,15 +1039,59 @@ class RecordTools(ToolBase):
|
|
|
995
1039
|
item["title"]: self._ready_schema_template_value(item)
|
|
996
1040
|
for item in writable_fields
|
|
997
1041
|
},
|
|
1042
|
+
"available_update_routes": self._record_update_schema_available_routes(matched_probes),
|
|
1043
|
+
"recommended_update_route": {
|
|
1044
|
+
"route_type": "auto",
|
|
1045
|
+
"order": ["admin_direct", "view_edit", "task_save_only"],
|
|
1046
|
+
"message": "record_update will try data-manager direct edit first, then a matching custom-view edit route, then a unique current-user todo save-only route when the target fields are editable on that workflow node.",
|
|
1047
|
+
},
|
|
998
1048
|
}
|
|
999
1049
|
if normalized_output_profile == "verbose":
|
|
1000
1050
|
response["view_probe_summary"] = probe_summary
|
|
1001
1051
|
response["record_context_probe"] = probe_summary
|
|
1002
1052
|
response["ambiguous_fields"] = ambiguous_fields
|
|
1053
|
+
response["route_probe_summary"] = probe_summary
|
|
1003
1054
|
return response
|
|
1004
1055
|
|
|
1005
1056
|
return self._run_record_tool(profile, runner)
|
|
1006
1057
|
|
|
1058
|
+
def _record_update_schema_available_routes(self, matched_probes: list[RecordContextRouteProbe]) -> list[JSONObject]:
|
|
1059
|
+
routes: list[JSONObject] = [
|
|
1060
|
+
{
|
|
1061
|
+
"route_type": "admin_direct",
|
|
1062
|
+
"endpoint_kind": "app_apply_update",
|
|
1063
|
+
"role": 1,
|
|
1064
|
+
"availability": "attempted_on_update",
|
|
1065
|
+
"message": "Requires data-manager edit permission; record_update safely falls back if backend returns permission denied.",
|
|
1066
|
+
}
|
|
1067
|
+
]
|
|
1068
|
+
for probe in matched_probes:
|
|
1069
|
+
if probe.route.kind != "custom":
|
|
1070
|
+
continue
|
|
1071
|
+
view_key = self._route_view_key(probe.route)
|
|
1072
|
+
if not view_key:
|
|
1073
|
+
continue
|
|
1074
|
+
routes.append(
|
|
1075
|
+
{
|
|
1076
|
+
"route_type": "view_edit",
|
|
1077
|
+
"endpoint_kind": "view_apply_update",
|
|
1078
|
+
"view_id": probe.route.view_id,
|
|
1079
|
+
"view_key": view_key,
|
|
1080
|
+
"view_name": probe.route.name,
|
|
1081
|
+
"availability": "candidate",
|
|
1082
|
+
"message": "Uses the same custom-view detail edit route as the frontend.",
|
|
1083
|
+
}
|
|
1084
|
+
)
|
|
1085
|
+
routes.append(
|
|
1086
|
+
{
|
|
1087
|
+
"route_type": "task_save_only",
|
|
1088
|
+
"endpoint_kind": "workflow_node_save_only",
|
|
1089
|
+
"availability": "auto_probe_on_update",
|
|
1090
|
+
"message": "record_update probes the current user's todo list and uses save-only only when exactly one matching task exists and the requested fields are editable on that workflow node.",
|
|
1091
|
+
}
|
|
1092
|
+
)
|
|
1093
|
+
return routes
|
|
1094
|
+
|
|
1007
1095
|
def _record_update_schema_blocked_response(
|
|
1008
1096
|
self,
|
|
1009
1097
|
*,
|
|
@@ -2175,6 +2263,130 @@ class RecordTools(ToolBase):
|
|
|
2175
2263
|
|
|
2176
2264
|
return self._run_record_tool(profile, runner)
|
|
2177
2265
|
|
|
2266
|
+
@tool_cn_name("记录全量日志")
|
|
2267
|
+
def record_logs_get(
|
|
2268
|
+
self,
|
|
2269
|
+
*,
|
|
2270
|
+
profile: str,
|
|
2271
|
+
app_key: str,
|
|
2272
|
+
record_id: Any,
|
|
2273
|
+
view_id: str | None = None,
|
|
2274
|
+
) -> JSONObject:
|
|
2275
|
+
"""读取单条记录可见的全量数据日志和流程日志,写入本地 JSONL。"""
|
|
2276
|
+
record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
|
|
2277
|
+
|
|
2278
|
+
def runner(session_profile, context):
|
|
2279
|
+
resolved_view, compatibility_warnings = self._resolve_accessible_view_route(
|
|
2280
|
+
profile,
|
|
2281
|
+
context,
|
|
2282
|
+
app_key,
|
|
2283
|
+
view_id=view_id,
|
|
2284
|
+
list_type=None,
|
|
2285
|
+
view_key=None,
|
|
2286
|
+
view_name=None,
|
|
2287
|
+
allow_default=True,
|
|
2288
|
+
)
|
|
2289
|
+
warnings: list[JSONObject] = []
|
|
2290
|
+
warnings.extend(compatibility_warnings)
|
|
2291
|
+
warnings.extend(_view_filter_trust_warnings(resolved_view))
|
|
2292
|
+
unavailable_context: list[JSONObject] = []
|
|
2293
|
+
|
|
2294
|
+
schema = self._record_get_detail_schema(profile, context, app_key, resolved_view, force_refresh=False)
|
|
2295
|
+
index = _build_top_level_field_index(schema)
|
|
2296
|
+
audit_info = self._record_get_audit_info(
|
|
2297
|
+
context,
|
|
2298
|
+
app_key=app_key,
|
|
2299
|
+
record_id=record_id_int,
|
|
2300
|
+
resolved_view=resolved_view,
|
|
2301
|
+
)
|
|
2302
|
+
audit_context = _record_detail_audit_context(audit_info, workflow_node_id=None)
|
|
2303
|
+
detail_result, used_list_type, used_role = self._record_get_apply_detail(
|
|
2304
|
+
context,
|
|
2305
|
+
app_key=app_key,
|
|
2306
|
+
record_id=record_id_int,
|
|
2307
|
+
resolved_view=resolved_view,
|
|
2308
|
+
audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
|
|
2309
|
+
)
|
|
2310
|
+
answer_list = _record_detail_answers(detail_result)
|
|
2311
|
+
selected_fields = list(index.by_id.values())
|
|
2312
|
+
fields = [
|
|
2313
|
+
_record_detail_field_payload(field, _find_answer_for_field(cast(list[JSONValue], answer_list), field), focus_id_set=set())
|
|
2314
|
+
for field in selected_fields
|
|
2315
|
+
]
|
|
2316
|
+
app_name = self._record_get_detail_app_name(
|
|
2317
|
+
profile,
|
|
2318
|
+
context,
|
|
2319
|
+
app_key=app_key,
|
|
2320
|
+
schema=schema,
|
|
2321
|
+
used_list_type=used_list_type,
|
|
2322
|
+
)
|
|
2323
|
+
view_payload = _accessible_view_payload(resolved_view)
|
|
2324
|
+
record_payload = _record_detail_record_payload(
|
|
2325
|
+
app_key=app_key,
|
|
2326
|
+
record_id=record_id_int,
|
|
2327
|
+
detail=detail_result,
|
|
2328
|
+
answer_list=cast(list[JSONValue], answer_list),
|
|
2329
|
+
fields=fields,
|
|
2330
|
+
)
|
|
2331
|
+
log_visibility = self._record_get_log_visibility_context(
|
|
2332
|
+
context,
|
|
2333
|
+
app_key=app_key,
|
|
2334
|
+
record_id=record_id_int,
|
|
2335
|
+
resolved_view=resolved_view,
|
|
2336
|
+
role=used_role,
|
|
2337
|
+
audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
|
|
2338
|
+
unavailable_context=unavailable_context,
|
|
2339
|
+
)
|
|
2340
|
+
run_dir = _record_logs_run_dir()
|
|
2341
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
2342
|
+
deadline = time.monotonic() + RECORD_LOGS_TIME_BUDGET_SECONDS
|
|
2343
|
+
data_logs = self._record_get_full_data_logs_context(
|
|
2344
|
+
context,
|
|
2345
|
+
app_key=app_key,
|
|
2346
|
+
record_id=record_id_int,
|
|
2347
|
+
role=used_role,
|
|
2348
|
+
log_visibility=log_visibility,
|
|
2349
|
+
unavailable_context=unavailable_context,
|
|
2350
|
+
run_dir=run_dir,
|
|
2351
|
+
deadline=deadline,
|
|
2352
|
+
)
|
|
2353
|
+
workflow_logs = self._record_get_full_workflow_logs_context(
|
|
2354
|
+
context,
|
|
2355
|
+
app_key=app_key,
|
|
2356
|
+
record_id=record_id_int,
|
|
2357
|
+
resolved_view=resolved_view,
|
|
2358
|
+
role=used_role,
|
|
2359
|
+
audit_node_id=cast(int | None, audit_context.get("audit_node_id")),
|
|
2360
|
+
log_visibility=log_visibility,
|
|
2361
|
+
unavailable_context=unavailable_context,
|
|
2362
|
+
run_dir=run_dir,
|
|
2363
|
+
deadline=deadline,
|
|
2364
|
+
)
|
|
2365
|
+
status = _record_logs_overall_status(data_logs=data_logs, workflow_logs=workflow_logs)
|
|
2366
|
+
context_integrity = _record_logs_context_integrity(data_logs=data_logs, workflow_logs=workflow_logs)
|
|
2367
|
+
payload: JSONObject = {
|
|
2368
|
+
"ok": True,
|
|
2369
|
+
"status": status,
|
|
2370
|
+
"output_profile": "record_logs",
|
|
2371
|
+
"app": {"app_key": app_key, "app_name": app_name},
|
|
2372
|
+
"view": view_payload,
|
|
2373
|
+
"record": record_payload,
|
|
2374
|
+
"local_dir": str(run_dir),
|
|
2375
|
+
"data_logs": data_logs,
|
|
2376
|
+
"workflow_logs": workflow_logs,
|
|
2377
|
+
"warnings": warnings,
|
|
2378
|
+
"unavailable_context": unavailable_context,
|
|
2379
|
+
"context_integrity": context_integrity,
|
|
2380
|
+
}
|
|
2381
|
+
summary_path = run_dir / "summary.json"
|
|
2382
|
+
summary_payload = deepcopy(payload)
|
|
2383
|
+
summary_payload.pop("request_route", None)
|
|
2384
|
+
summary_path.write_text(json.dumps(summary_payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
2385
|
+
payload["summary_path"] = str(summary_path)
|
|
2386
|
+
return payload
|
|
2387
|
+
|
|
2388
|
+
return self._run_record_tool(profile, runner)
|
|
2389
|
+
|
|
2178
2390
|
def _record_get_detail_context(
|
|
2179
2391
|
self,
|
|
2180
2392
|
*,
|
|
@@ -2824,6 +3036,108 @@ class RecordTools(ToolBase):
|
|
|
2824
3036
|
unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "流程日志本次获取失败。", exc))
|
|
2825
3037
|
return _record_detail_log_unavailable_payload("workflow_logs", "fetch_unavailable")
|
|
2826
3038
|
|
|
3039
|
+
def _record_get_full_data_logs_context(
|
|
3040
|
+
self,
|
|
3041
|
+
context, # type: ignore[no-untyped-def]
|
|
3042
|
+
*,
|
|
3043
|
+
app_key: str,
|
|
3044
|
+
record_id: int,
|
|
3045
|
+
role: int,
|
|
3046
|
+
log_visibility: JSONObject,
|
|
3047
|
+
unavailable_context: list[JSONObject],
|
|
3048
|
+
run_dir: Path,
|
|
3049
|
+
deadline: float,
|
|
3050
|
+
) -> JSONObject:
|
|
3051
|
+
"""读取全量数据日志并写入 JSONL。"""
|
|
3052
|
+
if log_visibility.get("status") == "unavailable":
|
|
3053
|
+
return _record_logs_unavailable_payload("data_logs", "visibility_unavailable")
|
|
3054
|
+
if log_visibility.get("data_log_visible") is False:
|
|
3055
|
+
return _record_logs_hidden_payload("data_logs")
|
|
3056
|
+
|
|
3057
|
+
def fetch_page(page_num: int) -> JSONValue:
|
|
3058
|
+
return self.backend.request(
|
|
3059
|
+
"POST",
|
|
3060
|
+
context,
|
|
3061
|
+
f"/worksheet/data/log/{app_key}/{record_id}/page",
|
|
3062
|
+
json_body={
|
|
3063
|
+
"viewChannel": log_visibility.get("channel"),
|
|
3064
|
+
"role": role,
|
|
3065
|
+
"pageNum": page_num,
|
|
3066
|
+
"pageSize": RECORD_LOGS_PAGE_SIZE,
|
|
3067
|
+
},
|
|
3068
|
+
)
|
|
3069
|
+
|
|
3070
|
+
try:
|
|
3071
|
+
return _record_logs_fetch_all_to_jsonl(
|
|
3072
|
+
fetch_page=fetch_page,
|
|
3073
|
+
normalizer=_record_detail_data_log_item,
|
|
3074
|
+
source="data_logs",
|
|
3075
|
+
file_path=run_dir / "data-logs.jsonl",
|
|
3076
|
+
deadline=deadline,
|
|
3077
|
+
)
|
|
3078
|
+
except QingflowApiError as exc:
|
|
3079
|
+
unavailable_context.append(_record_detail_unavailable_context("data_logs", "全量数据日志获取失败。", exc))
|
|
3080
|
+
return _record_logs_unavailable_payload("data_logs", "fetch_unavailable")
|
|
3081
|
+
|
|
3082
|
+
def _record_get_full_workflow_logs_context(
|
|
3083
|
+
self,
|
|
3084
|
+
context, # type: ignore[no-untyped-def]
|
|
3085
|
+
*,
|
|
3086
|
+
app_key: str,
|
|
3087
|
+
record_id: int,
|
|
3088
|
+
resolved_view: AccessibleViewRoute,
|
|
3089
|
+
role: int,
|
|
3090
|
+
audit_node_id: int | None,
|
|
3091
|
+
log_visibility: JSONObject,
|
|
3092
|
+
unavailable_context: list[JSONObject],
|
|
3093
|
+
run_dir: Path,
|
|
3094
|
+
deadline: float,
|
|
3095
|
+
) -> JSONObject:
|
|
3096
|
+
"""读取全量流程日志并写入 JSONL。"""
|
|
3097
|
+
if log_visibility.get("status") == "unavailable":
|
|
3098
|
+
return _record_logs_unavailable_payload("workflow_logs", "visibility_unavailable")
|
|
3099
|
+
if log_visibility.get("workflow_log_visible") is False:
|
|
3100
|
+
return _record_logs_hidden_payload("workflow_logs")
|
|
3101
|
+
|
|
3102
|
+
def fetch_page(page_num: int) -> JSONValue:
|
|
3103
|
+
if resolved_view.kind == "custom" and resolved_view.view_selection is not None:
|
|
3104
|
+
return self.backend.request(
|
|
3105
|
+
"POST",
|
|
3106
|
+
context,
|
|
3107
|
+
f"/viewGraph/{resolved_view.view_selection.view_key}/workflow/node/record",
|
|
3108
|
+
json_body={
|
|
3109
|
+
"key": resolved_view.view_selection.view_key,
|
|
3110
|
+
"rowRecordId": str(record_id),
|
|
3111
|
+
"pageNum": page_num,
|
|
3112
|
+
"pageSize": RECORD_LOGS_PAGE_SIZE,
|
|
3113
|
+
},
|
|
3114
|
+
)
|
|
3115
|
+
return self.backend.request(
|
|
3116
|
+
"POST",
|
|
3117
|
+
context,
|
|
3118
|
+
"/application/workflow/node/record",
|
|
3119
|
+
json_body={
|
|
3120
|
+
"key": app_key,
|
|
3121
|
+
"rowRecordId": str(record_id),
|
|
3122
|
+
"nodeId": audit_node_id,
|
|
3123
|
+
"role": role,
|
|
3124
|
+
"pageNum": page_num,
|
|
3125
|
+
"pageSize": RECORD_LOGS_PAGE_SIZE,
|
|
3126
|
+
},
|
|
3127
|
+
)
|
|
3128
|
+
|
|
3129
|
+
try:
|
|
3130
|
+
return _record_logs_fetch_all_to_jsonl(
|
|
3131
|
+
fetch_page=fetch_page,
|
|
3132
|
+
normalizer=_record_detail_workflow_log_item,
|
|
3133
|
+
source="workflow_logs",
|
|
3134
|
+
file_path=run_dir / "workflow-logs.jsonl",
|
|
3135
|
+
deadline=deadline,
|
|
3136
|
+
)
|
|
3137
|
+
except QingflowApiError as exc:
|
|
3138
|
+
unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "全量流程日志获取失败。", exc))
|
|
3139
|
+
return _record_logs_unavailable_payload("workflow_logs", "fetch_unavailable")
|
|
3140
|
+
|
|
2827
3141
|
def _record_get_associated_resources(
|
|
2828
3142
|
self,
|
|
2829
3143
|
context, # type: ignore[no-untyped-def]
|
|
@@ -3530,71 +3844,720 @@ class RecordTools(ToolBase):
|
|
|
3530
3844
|
output_profile=normalized_output_profile,
|
|
3531
3845
|
)
|
|
3532
3846
|
|
|
3533
|
-
def _record_update_public_single(
|
|
3847
|
+
def _record_update_public_single(
|
|
3848
|
+
self,
|
|
3849
|
+
*,
|
|
3850
|
+
profile: str,
|
|
3851
|
+
app_key: str,
|
|
3852
|
+
record_id: int,
|
|
3853
|
+
fields: JSONObject,
|
|
3854
|
+
verify_write: bool,
|
|
3855
|
+
output_profile: str,
|
|
3856
|
+
) -> JSONObject:
|
|
3857
|
+
"""执行内部辅助逻辑。"""
|
|
3858
|
+
raw_preflight = self._preflight_record_update_with_auto_view(
|
|
3859
|
+
profile=profile,
|
|
3860
|
+
app_key=app_key,
|
|
3861
|
+
record_id=record_id,
|
|
3862
|
+
fields=fields,
|
|
3863
|
+
force_refresh_form=False,
|
|
3864
|
+
)
|
|
3865
|
+
preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
|
|
3866
|
+
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
3867
|
+
normalized_payload = self._record_write_normalized_payload(
|
|
3868
|
+
operation="update",
|
|
3869
|
+
record_id=record_id,
|
|
3870
|
+
record_ids=[],
|
|
3871
|
+
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3872
|
+
submit_type=1,
|
|
3873
|
+
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
3874
|
+
)
|
|
3875
|
+
if preflight_data.get("blockers"):
|
|
3876
|
+
return self._record_write_blocked_response(
|
|
3877
|
+
raw_preflight,
|
|
3878
|
+
operation="update",
|
|
3879
|
+
normalized_payload=normalized_payload,
|
|
3880
|
+
output_profile=output_profile,
|
|
3881
|
+
human_review=True,
|
|
3882
|
+
target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
|
|
3883
|
+
)
|
|
3884
|
+
route_apply, tried_routes, route_blocker = self._record_update_apply_with_auto_route(
|
|
3885
|
+
profile=profile,
|
|
3886
|
+
app_key=app_key,
|
|
3887
|
+
record_id=record_id,
|
|
3888
|
+
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3889
|
+
preflight_data=preflight_data,
|
|
3890
|
+
verify_write=verify_write,
|
|
3891
|
+
force_refresh_form=preflight_used_force_refresh,
|
|
3892
|
+
)
|
|
3893
|
+
if route_blocker is not None:
|
|
3894
|
+
return self._record_update_route_blocked_response(
|
|
3895
|
+
raw_preflight=raw_preflight,
|
|
3896
|
+
operation="update",
|
|
3897
|
+
normalized_payload=normalized_payload,
|
|
3898
|
+
output_profile=output_profile,
|
|
3899
|
+
human_review=True,
|
|
3900
|
+
app_key=app_key,
|
|
3901
|
+
record_id=record_id,
|
|
3902
|
+
tried_routes=tried_routes,
|
|
3903
|
+
route_blocker=route_blocker,
|
|
3904
|
+
)
|
|
3905
|
+
raw_apply = cast(JSONObject, route_apply)
|
|
3906
|
+
return self._record_write_apply_response(
|
|
3907
|
+
raw_apply,
|
|
3908
|
+
operation="update",
|
|
3909
|
+
normalized_payload=normalized_payload,
|
|
3910
|
+
output_profile=output_profile,
|
|
3911
|
+
human_review=True,
|
|
3912
|
+
preflight=raw_preflight,
|
|
3913
|
+
)
|
|
3914
|
+
|
|
3915
|
+
def _record_update_apply_with_auto_route(
|
|
3916
|
+
self,
|
|
3917
|
+
*,
|
|
3918
|
+
profile: str,
|
|
3919
|
+
app_key: str,
|
|
3920
|
+
record_id: int,
|
|
3921
|
+
normalized_answers: list[JSONObject],
|
|
3922
|
+
preflight_data: JSONObject,
|
|
3923
|
+
verify_write: bool,
|
|
3924
|
+
force_refresh_form: bool,
|
|
3925
|
+
) -> tuple[JSONObject | None, list[JSONObject], JSONObject | None]:
|
|
3926
|
+
"""Try record update routes in the same order a frontend user would expect."""
|
|
3927
|
+
tried_routes: list[JSONObject] = []
|
|
3928
|
+
admin_attempt = self._record_update_route_attempt(
|
|
3929
|
+
route_type="admin_direct",
|
|
3930
|
+
endpoint_kind="app_apply_update",
|
|
3931
|
+
role=1,
|
|
3932
|
+
reason="try data-manager direct edit first",
|
|
3933
|
+
)
|
|
3934
|
+
try:
|
|
3935
|
+
raw_apply = self.record_update(
|
|
3936
|
+
profile=profile,
|
|
3937
|
+
app_key=app_key,
|
|
3938
|
+
apply_id=record_id,
|
|
3939
|
+
answers=normalized_answers,
|
|
3940
|
+
fields={},
|
|
3941
|
+
role=1,
|
|
3942
|
+
verify_write=verify_write,
|
|
3943
|
+
force_refresh_form=force_refresh_form,
|
|
3944
|
+
)
|
|
3945
|
+
admin_attempt["status"] = "success"
|
|
3946
|
+
raw_apply["update_route"] = self._record_update_route_public(admin_attempt)
|
|
3947
|
+
raw_apply["tried_routes"] = [admin_attempt]
|
|
3948
|
+
return raw_apply, [admin_attempt], None
|
|
3949
|
+
except (QingflowApiError, RuntimeError) as exc:
|
|
3950
|
+
api_error = self._record_update_extract_api_error(exc)
|
|
3951
|
+
if api_error is None or not self._record_update_route_permission_denied(api_error):
|
|
3952
|
+
raise
|
|
3953
|
+
admin_attempt.update(self._record_update_route_error_payload(
|
|
3954
|
+
api_error,
|
|
3955
|
+
status="denied",
|
|
3956
|
+
error_code="ADMIN_UPDATE_PERMISSION_DENIED",
|
|
3957
|
+
))
|
|
3958
|
+
tried_routes.append(admin_attempt)
|
|
3959
|
+
|
|
3960
|
+
view_route = self._record_update_selected_custom_view_route(preflight_data)
|
|
3961
|
+
if view_route is None:
|
|
3962
|
+
tried_routes.append(
|
|
3963
|
+
self._record_update_route_attempt(
|
|
3964
|
+
route_type="view_edit",
|
|
3965
|
+
endpoint_kind="view_apply_update",
|
|
3966
|
+
status="skipped",
|
|
3967
|
+
error_code="VIEW_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
3968
|
+
reason="preflight did not select a single custom view route for this payload",
|
|
3969
|
+
)
|
|
3970
|
+
)
|
|
3971
|
+
else:
|
|
3972
|
+
view_attempt = self._record_update_route_attempt(
|
|
3973
|
+
route_type="view_edit",
|
|
3974
|
+
endpoint_kind="view_apply_update",
|
|
3975
|
+
view_id=cast(str, view_route.get("view_id")),
|
|
3976
|
+
view_key=cast(str, view_route.get("view_key")),
|
|
3977
|
+
view_name=_normalize_optional_text(view_route.get("name")),
|
|
3978
|
+
reason="fallback to frontend custom-view detail edit route",
|
|
3979
|
+
)
|
|
3980
|
+
try:
|
|
3981
|
+
raw_apply = self._record_update_via_custom_view(
|
|
3982
|
+
profile=profile,
|
|
3983
|
+
app_key=app_key,
|
|
3984
|
+
apply_id=record_id,
|
|
3985
|
+
view_key=cast(str, view_route["view_key"]),
|
|
3986
|
+
answers=normalized_answers,
|
|
3987
|
+
verify_write=verify_write,
|
|
3988
|
+
force_refresh_form=force_refresh_form,
|
|
3989
|
+
)
|
|
3990
|
+
view_attempt["status"] = "success"
|
|
3991
|
+
tried_routes.append(view_attempt)
|
|
3992
|
+
raw_apply["update_route"] = self._record_update_route_public(view_attempt)
|
|
3993
|
+
raw_apply["tried_routes"] = tried_routes
|
|
3994
|
+
return raw_apply, tried_routes, None
|
|
3995
|
+
except (QingflowApiError, RuntimeError) as exc:
|
|
3996
|
+
api_error = self._record_update_extract_api_error(exc)
|
|
3997
|
+
if api_error is None or not self._record_update_route_permission_denied(api_error):
|
|
3998
|
+
raise
|
|
3999
|
+
view_attempt.update(self._record_update_route_error_payload(
|
|
4000
|
+
api_error,
|
|
4001
|
+
status="denied",
|
|
4002
|
+
error_code="VIEW_UPDATE_PERMISSION_DENIED",
|
|
4003
|
+
))
|
|
4004
|
+
tried_routes.append(view_attempt)
|
|
4005
|
+
|
|
4006
|
+
task_route = self._record_update_task_save_only_candidate(
|
|
4007
|
+
profile=profile,
|
|
4008
|
+
app_key=app_key,
|
|
4009
|
+
record_id=record_id,
|
|
4010
|
+
normalized_answers=normalized_answers,
|
|
4011
|
+
)
|
|
4012
|
+
task_attempt = cast(JSONObject, task_route.get("attempt") if isinstance(task_route.get("attempt"), dict) else {})
|
|
4013
|
+
if not task_route.get("available"):
|
|
4014
|
+
tried_routes.append(task_attempt or self._record_update_route_attempt(
|
|
4015
|
+
route_type="task_save_only",
|
|
4016
|
+
endpoint_kind="workflow_node_save_only",
|
|
4017
|
+
status="skipped",
|
|
4018
|
+
error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
4019
|
+
reason="no unique current-user todo task can edit the requested fields",
|
|
4020
|
+
))
|
|
4021
|
+
else:
|
|
4022
|
+
task_attempt = self._record_update_route_attempt(
|
|
4023
|
+
route_type="task_save_only",
|
|
4024
|
+
endpoint_kind="workflow_node_save_only",
|
|
4025
|
+
role=3,
|
|
4026
|
+
task_id=_normalize_optional_text(task_route.get("task_id")),
|
|
4027
|
+
workflow_node_id=_coerce_count(task_route.get("workflow_node_id")),
|
|
4028
|
+
reason="fallback to current-user workflow todo save-only route",
|
|
4029
|
+
)
|
|
4030
|
+
try:
|
|
4031
|
+
raw_apply = self._record_update_via_task_save_only(
|
|
4032
|
+
profile=profile,
|
|
4033
|
+
app_key=app_key,
|
|
4034
|
+
apply_id=record_id,
|
|
4035
|
+
workflow_node_id=cast(int, task_route["workflow_node_id"]),
|
|
4036
|
+
answers=normalized_answers,
|
|
4037
|
+
verify_write=verify_write,
|
|
4038
|
+
force_refresh_form=force_refresh_form,
|
|
4039
|
+
)
|
|
4040
|
+
task_attempt["status"] = "success"
|
|
4041
|
+
tried_routes.append(task_attempt)
|
|
4042
|
+
raw_apply["update_route"] = self._record_update_route_public(task_attempt)
|
|
4043
|
+
raw_apply["tried_routes"] = tried_routes
|
|
4044
|
+
return raw_apply, tried_routes, None
|
|
4045
|
+
except (QingflowApiError, RuntimeError) as exc:
|
|
4046
|
+
api_error = self._record_update_extract_api_error(exc)
|
|
4047
|
+
if api_error is None or not self._record_update_route_permission_denied(api_error):
|
|
4048
|
+
raise
|
|
4049
|
+
task_attempt.update(self._record_update_route_error_payload(
|
|
4050
|
+
api_error,
|
|
4051
|
+
status="denied",
|
|
4052
|
+
error_code="TASK_UPDATE_PERMISSION_DENIED",
|
|
4053
|
+
))
|
|
4054
|
+
tried_routes.append(task_attempt)
|
|
4055
|
+
return None, tried_routes, {
|
|
4056
|
+
"error_code": "NO_AVAILABLE_UPDATE_ROUTE",
|
|
4057
|
+
"message": "No available record update route could execute this payload for the current user.",
|
|
4058
|
+
"recommended_next_actions": [
|
|
4059
|
+
"If this user should edit the record as a data manager, grant data edit permission and retry record_update.",
|
|
4060
|
+
"If the record is editable from a specific table view in the UI, make sure the target fields are visible and editable in that view.",
|
|
4061
|
+
"If this is workflow work, use task_list -> task_get -> task_action_execute(action='save_only') with the current task context.",
|
|
4062
|
+
],
|
|
4063
|
+
}
|
|
4064
|
+
|
|
4065
|
+
def _record_update_selected_custom_view_route(self, preflight_data: JSONObject) -> JSONObject | None:
|
|
4066
|
+
selection = preflight_data.get("selection")
|
|
4067
|
+
if not isinstance(selection, dict):
|
|
4068
|
+
return None
|
|
4069
|
+
view = selection.get("view")
|
|
4070
|
+
if not isinstance(view, dict):
|
|
4071
|
+
return None
|
|
4072
|
+
view_id = _normalize_optional_text(view.get("view_id"))
|
|
4073
|
+
if not view_id or not view_id.startswith("custom:"):
|
|
4074
|
+
return None
|
|
4075
|
+
view_key = _normalize_optional_text(view.get("view_key")) or view_id.split(":", 1)[1].strip()
|
|
4076
|
+
if not view_key:
|
|
4077
|
+
return None
|
|
4078
|
+
return {
|
|
4079
|
+
"view_id": view_id,
|
|
4080
|
+
"view_key": view_key,
|
|
4081
|
+
"name": view.get("name"),
|
|
4082
|
+
}
|
|
4083
|
+
|
|
4084
|
+
def _record_update_route_attempt(
|
|
4085
|
+
self,
|
|
4086
|
+
*,
|
|
4087
|
+
route_type: str,
|
|
4088
|
+
endpoint_kind: str,
|
|
4089
|
+
status: str = "attempted",
|
|
4090
|
+
role: int | None = None,
|
|
4091
|
+
task_id: str | None = None,
|
|
4092
|
+
workflow_node_id: int | None = None,
|
|
4093
|
+
view_id: str | None = None,
|
|
4094
|
+
view_key: str | None = None,
|
|
4095
|
+
view_name: str | None = None,
|
|
4096
|
+
error_code: str | None = None,
|
|
4097
|
+
reason: str | None = None,
|
|
4098
|
+
) -> JSONObject:
|
|
4099
|
+
payload: JSONObject = {
|
|
4100
|
+
"route_type": route_type,
|
|
4101
|
+
"endpoint_kind": endpoint_kind,
|
|
4102
|
+
"status": status,
|
|
4103
|
+
}
|
|
4104
|
+
if role is not None:
|
|
4105
|
+
payload["role"] = role
|
|
4106
|
+
if task_id:
|
|
4107
|
+
payload["task_id"] = task_id
|
|
4108
|
+
if workflow_node_id is not None:
|
|
4109
|
+
payload["workflow_node_id"] = workflow_node_id
|
|
4110
|
+
if view_id:
|
|
4111
|
+
payload["view_id"] = view_id
|
|
4112
|
+
if view_key:
|
|
4113
|
+
payload["view_key"] = view_key
|
|
4114
|
+
if view_name:
|
|
4115
|
+
payload["view_name"] = view_name
|
|
4116
|
+
if error_code:
|
|
4117
|
+
payload["error_code"] = error_code
|
|
4118
|
+
if reason:
|
|
4119
|
+
payload["reason"] = reason
|
|
4120
|
+
return payload
|
|
4121
|
+
|
|
4122
|
+
def _record_update_route_public(self, attempt: JSONObject) -> JSONObject:
|
|
4123
|
+
return _pick_route_payload(attempt)
|
|
4124
|
+
|
|
4125
|
+
def _record_update_route_error_payload(
|
|
4126
|
+
self,
|
|
4127
|
+
exc: QingflowApiError,
|
|
4128
|
+
*,
|
|
4129
|
+
status: str,
|
|
4130
|
+
error_code: str,
|
|
4131
|
+
) -> JSONObject:
|
|
4132
|
+
payload: JSONObject = {
|
|
4133
|
+
"status": status,
|
|
4134
|
+
"error_code": error_code,
|
|
4135
|
+
"message": exc.message,
|
|
4136
|
+
}
|
|
4137
|
+
if exc.backend_code is not None:
|
|
4138
|
+
payload["backend_code"] = exc.backend_code
|
|
4139
|
+
if exc.http_status is not None:
|
|
4140
|
+
payload["http_status"] = exc.http_status
|
|
4141
|
+
if exc.request_id is not None:
|
|
4142
|
+
payload["request_id"] = exc.request_id
|
|
4143
|
+
return payload
|
|
4144
|
+
|
|
4145
|
+
def _record_update_extract_api_error(self, exc: QingflowApiError | RuntimeError) -> QingflowApiError | None:
|
|
4146
|
+
if isinstance(exc, QingflowApiError):
|
|
4147
|
+
return exc
|
|
4148
|
+
try:
|
|
4149
|
+
payload = json.loads(str(exc))
|
|
4150
|
+
except json.JSONDecodeError:
|
|
4151
|
+
return None
|
|
4152
|
+
if not isinstance(payload, dict):
|
|
4153
|
+
return None
|
|
4154
|
+
return QingflowApiError(
|
|
4155
|
+
category=str(payload.get("category") or "backend"),
|
|
4156
|
+
message=str(payload.get("message") or exc),
|
|
4157
|
+
backend_code=payload.get("backend_code"),
|
|
4158
|
+
request_id=_normalize_optional_text(payload.get("request_id")),
|
|
4159
|
+
http_status=_coerce_count(payload.get("http_status")),
|
|
4160
|
+
details=cast(JSONObject | None, payload.get("details") if isinstance(payload.get("details"), dict) else None),
|
|
4161
|
+
)
|
|
4162
|
+
|
|
4163
|
+
def _record_update_route_permission_denied(self, exc: QingflowApiError) -> bool:
|
|
4164
|
+
if exc.backend_code in {40002, 40027, 40038, 404}:
|
|
4165
|
+
return True
|
|
4166
|
+
if exc.http_status == 404:
|
|
4167
|
+
return True
|
|
4168
|
+
return False
|
|
4169
|
+
|
|
4170
|
+
def _record_update_route_blocked_response(
|
|
4171
|
+
self,
|
|
4172
|
+
*,
|
|
4173
|
+
raw_preflight: JSONObject,
|
|
4174
|
+
operation: str,
|
|
4175
|
+
normalized_payload: JSONObject,
|
|
4176
|
+
output_profile: str,
|
|
4177
|
+
human_review: bool,
|
|
4178
|
+
app_key: str,
|
|
4179
|
+
record_id: int,
|
|
4180
|
+
tried_routes: list[JSONObject],
|
|
4181
|
+
route_blocker: JSONObject,
|
|
4182
|
+
) -> JSONObject:
|
|
4183
|
+
plan_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
4184
|
+
validation = cast(JSONObject, plan_data.get("validation", {}))
|
|
4185
|
+
warnings_payload = validation.get("warnings", [])
|
|
4186
|
+
warnings = [{"code": "PREFLIGHT_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
|
|
4187
|
+
warnings.append(
|
|
4188
|
+
{
|
|
4189
|
+
"code": cast(str, route_blocker.get("error_code") or "NO_AVAILABLE_UPDATE_ROUTE"),
|
|
4190
|
+
"message": cast(str, route_blocker.get("message") or "No update route could execute the write."),
|
|
4191
|
+
}
|
|
4192
|
+
)
|
|
4193
|
+
recommended = list(route_blocker.get("recommended_next_actions") or [])
|
|
4194
|
+
response: JSONObject = {
|
|
4195
|
+
"profile": raw_preflight.get("profile"),
|
|
4196
|
+
"ws_id": raw_preflight.get("ws_id"),
|
|
4197
|
+
"ok": False,
|
|
4198
|
+
"status": "blocked",
|
|
4199
|
+
"write_executed": False,
|
|
4200
|
+
"verification_status": "not_requested",
|
|
4201
|
+
"safe_to_retry": True,
|
|
4202
|
+
"request_route": raw_preflight.get("request_route"),
|
|
4203
|
+
"warnings": warnings,
|
|
4204
|
+
"output_profile": output_profile,
|
|
4205
|
+
"update_route": None,
|
|
4206
|
+
"tried_routes": tried_routes,
|
|
4207
|
+
"error_code": route_blocker.get("error_code"),
|
|
4208
|
+
"data": {
|
|
4209
|
+
"action": {"operation": operation, "executed": False},
|
|
4210
|
+
"resource": {"type": "record", "app_key": app_key, "record_id": stringify_backend_id(record_id), "record_ids": []},
|
|
4211
|
+
"verification": None,
|
|
4212
|
+
"normalized_payload": normalized_payload,
|
|
4213
|
+
"blockers": [route_blocker.get("error_code") or "NO_AVAILABLE_UPDATE_ROUTE"],
|
|
4214
|
+
"field_errors": [],
|
|
4215
|
+
"confirmation_requests": [],
|
|
4216
|
+
"resolved_fields": cast(list[JSONObject], plan_data.get("lookup_resolved_fields", [])),
|
|
4217
|
+
"recommended_next_actions": recommended,
|
|
4218
|
+
"human_review": self._record_write_human_review_payload(operation, enabled=human_review),
|
|
4219
|
+
"error": route_blocker,
|
|
4220
|
+
"update_route": None,
|
|
4221
|
+
"tried_routes": tried_routes,
|
|
4222
|
+
},
|
|
4223
|
+
}
|
|
4224
|
+
if output_profile == "verbose":
|
|
4225
|
+
response["data"]["debug"] = {"preflight": plan_data}
|
|
4226
|
+
return response
|
|
4227
|
+
|
|
4228
|
+
def _record_update_task_save_only_candidate(
|
|
4229
|
+
self,
|
|
4230
|
+
*,
|
|
4231
|
+
profile: str,
|
|
4232
|
+
app_key: str,
|
|
4233
|
+
record_id: int,
|
|
4234
|
+
normalized_answers: list[JSONObject],
|
|
4235
|
+
) -> JSONObject:
|
|
4236
|
+
requested_question_ids = self._record_update_answer_question_ids(normalized_answers)
|
|
4237
|
+
|
|
4238
|
+
def unavailable(*, status: str = "skipped", error_code: str, reason: str, extra: JSONObject | None = None) -> JSONObject:
|
|
4239
|
+
attempt = self._record_update_route_attempt(
|
|
4240
|
+
route_type="task_save_only",
|
|
4241
|
+
endpoint_kind="workflow_node_save_only",
|
|
4242
|
+
status=status,
|
|
4243
|
+
error_code=error_code,
|
|
4244
|
+
reason=reason,
|
|
4245
|
+
)
|
|
4246
|
+
if extra:
|
|
4247
|
+
attempt.update(extra)
|
|
4248
|
+
return {"available": False, "attempt": attempt}
|
|
4249
|
+
|
|
4250
|
+
def runner(session_profile, context):
|
|
4251
|
+
matches: list[JSONObject] = []
|
|
4252
|
+
pages_scanned = 0
|
|
4253
|
+
for page_num in range(1, VERIFY_TASK_FALLBACK_MAX_PAGES + 1):
|
|
4254
|
+
try:
|
|
4255
|
+
task_page = self.backend.request(
|
|
4256
|
+
"POST",
|
|
4257
|
+
context,
|
|
4258
|
+
"/task/dynamic/page",
|
|
4259
|
+
json_body={
|
|
4260
|
+
"type": 1,
|
|
4261
|
+
"processStatus": 1,
|
|
4262
|
+
"appKey": app_key,
|
|
4263
|
+
"pageNum": page_num,
|
|
4264
|
+
"pageSize": VERIFY_TASK_FALLBACK_PAGE_SIZE,
|
|
4265
|
+
},
|
|
4266
|
+
)
|
|
4267
|
+
except QingflowApiError as exc:
|
|
4268
|
+
return unavailable(
|
|
4269
|
+
error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
4270
|
+
reason="current-user todo task list is unavailable",
|
|
4271
|
+
extra=self._record_update_route_error_payload(
|
|
4272
|
+
exc,
|
|
4273
|
+
status="skipped",
|
|
4274
|
+
error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
4275
|
+
),
|
|
4276
|
+
)
|
|
4277
|
+
pages_scanned += 1
|
|
4278
|
+
rows = task_page.get("list") if isinstance(task_page, dict) else None
|
|
4279
|
+
items = [item for item in rows if isinstance(item, dict)] if isinstance(rows, list) else []
|
|
4280
|
+
for item in items:
|
|
4281
|
+
candidate_record_id = _coerce_count(item.get("rowRecordId") or item.get("recordId") or item.get("applyId"))
|
|
4282
|
+
if candidate_record_id == record_id:
|
|
4283
|
+
matches.append(dict(item))
|
|
4284
|
+
if not _page_has_more(cast(JSONObject, task_page if isinstance(task_page, dict) else {}), page_num, VERIFY_TASK_FALLBACK_PAGE_SIZE, len(items)):
|
|
4285
|
+
break
|
|
4286
|
+
|
|
4287
|
+
if not matches:
|
|
4288
|
+
return unavailable(
|
|
4289
|
+
error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
4290
|
+
reason="no current-user todo task was found for this record",
|
|
4291
|
+
extra={"pages_scanned": pages_scanned},
|
|
4292
|
+
)
|
|
4293
|
+
if len(matches) > 1:
|
|
4294
|
+
return unavailable(
|
|
4295
|
+
error_code="TASK_UPDATE_ROUTE_AMBIGUOUS",
|
|
4296
|
+
reason="multiple current-user todo tasks match this record; refusing to guess workflow context",
|
|
4297
|
+
extra={"matched_tasks": [self._record_update_compact_task_match(item) for item in matches[:5]]},
|
|
4298
|
+
)
|
|
4299
|
+
|
|
4300
|
+
task = matches[0]
|
|
4301
|
+
workflow_node_id = _coerce_count(task.get("nodeId") or task.get("auditNodeId"))
|
|
4302
|
+
if workflow_node_id is None:
|
|
4303
|
+
return unavailable(
|
|
4304
|
+
error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
4305
|
+
reason="matched todo task does not expose a workflow node id",
|
|
4306
|
+
extra={"matched_task": self._record_update_compact_task_match(task)},
|
|
4307
|
+
)
|
|
4308
|
+
try:
|
|
4309
|
+
editable_payload = self.backend.request(
|
|
4310
|
+
"GET",
|
|
4311
|
+
context,
|
|
4312
|
+
f"/app/{app_key}/auditNode/{workflow_node_id}/editableQueIds",
|
|
4313
|
+
)
|
|
4314
|
+
except QingflowApiError as exc:
|
|
4315
|
+
return unavailable(
|
|
4316
|
+
error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
|
|
4317
|
+
reason="workflow node editable field list is unavailable; record_update will not guess task editability",
|
|
4318
|
+
extra=self._record_update_route_error_payload(
|
|
4319
|
+
exc,
|
|
4320
|
+
status="skipped",
|
|
4321
|
+
error_code="TASK_EDITABLE_FIELDS_UNAVAILABLE",
|
|
4322
|
+
),
|
|
4323
|
+
)
|
|
4324
|
+
editable_question_ids = self._record_update_extract_question_ids(editable_payload)
|
|
4325
|
+
if not editable_question_ids:
|
|
4326
|
+
return unavailable(
|
|
4327
|
+
error_code="TASK_UPDATE_ROUTE_NOT_AVAILABLE",
|
|
4328
|
+
reason="workflow node editable field list is empty",
|
|
4329
|
+
extra={
|
|
4330
|
+
"task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
|
|
4331
|
+
"workflow_node_id": workflow_node_id,
|
|
4332
|
+
},
|
|
4333
|
+
)
|
|
4334
|
+
effective_editable_question_ids = self._record_update_effective_task_editable_ids(
|
|
4335
|
+
editable_question_ids,
|
|
4336
|
+
normalized_answers=normalized_answers,
|
|
4337
|
+
)
|
|
4338
|
+
non_editable = sorted(
|
|
4339
|
+
question_id for question_id in requested_question_ids
|
|
4340
|
+
if question_id not in effective_editable_question_ids
|
|
4341
|
+
)
|
|
4342
|
+
if non_editable:
|
|
4343
|
+
return unavailable(
|
|
4344
|
+
status="denied",
|
|
4345
|
+
error_code="TASK_UPDATE_FIELD_NOT_EDITABLE",
|
|
4346
|
+
reason="one or more requested fields are not editable on the current workflow node",
|
|
4347
|
+
extra={
|
|
4348
|
+
"task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
|
|
4349
|
+
"workflow_node_id": workflow_node_id,
|
|
4350
|
+
"non_editable_question_ids": non_editable,
|
|
4351
|
+
},
|
|
4352
|
+
)
|
|
4353
|
+
return {
|
|
4354
|
+
"available": True,
|
|
4355
|
+
"task_id": stringify_backend_id(task.get("id") or task.get("taskId")),
|
|
4356
|
+
"workflow_node_id": workflow_node_id,
|
|
4357
|
+
"matched_task": self._record_update_compact_task_match(task),
|
|
4358
|
+
"editable_question_ids": sorted(editable_question_ids),
|
|
4359
|
+
"effective_editable_question_ids": sorted(effective_editable_question_ids),
|
|
4360
|
+
}
|
|
4361
|
+
|
|
4362
|
+
return self._run_record_tool(profile, runner)
|
|
4363
|
+
|
|
4364
|
+
def _record_update_compact_task_match(self, item: JSONObject) -> JSONObject:
|
|
4365
|
+
return {
|
|
4366
|
+
key: value
|
|
4367
|
+
for key, value in {
|
|
4368
|
+
"task_id": stringify_backend_id(item.get("id") or item.get("taskId")),
|
|
4369
|
+
"record_id": stringify_backend_id(item.get("rowRecordId") or item.get("recordId") or item.get("applyId")),
|
|
4370
|
+
"workflow_node_id": item.get("nodeId") or item.get("auditNodeId"),
|
|
4371
|
+
"workflow_node_name": item.get("nodeName") or item.get("auditNodeName"),
|
|
4372
|
+
}.items()
|
|
4373
|
+
if value not in (None, "", [], {})
|
|
4374
|
+
}
|
|
4375
|
+
|
|
4376
|
+
def _record_update_answer_question_ids(self, answers: list[JSONObject]) -> set[int]:
|
|
4377
|
+
question_ids: set[int] = set()
|
|
4378
|
+
for answer in answers:
|
|
4379
|
+
if not isinstance(answer, dict):
|
|
4380
|
+
continue
|
|
4381
|
+
que_id = _coerce_count(answer.get("queId"))
|
|
4382
|
+
if que_id is not None and que_id > 0:
|
|
4383
|
+
question_ids.add(que_id)
|
|
4384
|
+
table_values = answer.get("tableValues")
|
|
4385
|
+
if not isinstance(table_values, list):
|
|
4386
|
+
continue
|
|
4387
|
+
for row in table_values:
|
|
4388
|
+
if not isinstance(row, list):
|
|
4389
|
+
continue
|
|
4390
|
+
for cell in row:
|
|
4391
|
+
if not isinstance(cell, dict):
|
|
4392
|
+
continue
|
|
4393
|
+
cell_que_id = _coerce_count(cell.get("queId"))
|
|
4394
|
+
if cell_que_id is not None and cell_que_id > 0:
|
|
4395
|
+
question_ids.add(cell_que_id)
|
|
4396
|
+
return question_ids
|
|
4397
|
+
|
|
4398
|
+
def _record_update_extract_question_ids(self, payload: JSONValue) -> set[int]:
|
|
4399
|
+
candidates: list[Any] = []
|
|
4400
|
+
if isinstance(payload, list):
|
|
4401
|
+
candidates = payload
|
|
4402
|
+
elif isinstance(payload, dict):
|
|
4403
|
+
for key in ("editableQueIds", "editableQuestionIds", "queIds", "questionIds", "ids", "list", "result"):
|
|
4404
|
+
value = payload.get(key)
|
|
4405
|
+
if isinstance(value, list):
|
|
4406
|
+
candidates = value
|
|
4407
|
+
break
|
|
4408
|
+
question_ids: set[int] = set()
|
|
4409
|
+
for item in candidates:
|
|
4410
|
+
value: Any = item
|
|
4411
|
+
if isinstance(item, dict):
|
|
4412
|
+
value = item.get("queId", item.get("questionId", item.get("id")))
|
|
4413
|
+
que_id = _coerce_count(value)
|
|
4414
|
+
if que_id is not None and que_id > 0:
|
|
4415
|
+
question_ids.add(que_id)
|
|
4416
|
+
return question_ids
|
|
4417
|
+
|
|
4418
|
+
def _record_update_effective_task_editable_ids(
|
|
4419
|
+
self,
|
|
4420
|
+
editable_question_ids: set[int],
|
|
4421
|
+
*,
|
|
4422
|
+
normalized_answers: list[JSONObject],
|
|
4423
|
+
) -> set[int]:
|
|
4424
|
+
effective_editable_ids = set(editable_question_ids)
|
|
4425
|
+
for answer in normalized_answers:
|
|
4426
|
+
if not isinstance(answer, dict):
|
|
4427
|
+
continue
|
|
4428
|
+
parent_que_id = _coerce_count(answer.get("queId"))
|
|
4429
|
+
if parent_que_id is None or parent_que_id <= 0:
|
|
4430
|
+
continue
|
|
4431
|
+
table_values = answer.get("tableValues")
|
|
4432
|
+
if not isinstance(table_values, list) or not table_values:
|
|
4433
|
+
continue
|
|
4434
|
+
row_subfield_ids: set[int] = set()
|
|
4435
|
+
for row in table_values:
|
|
4436
|
+
if not isinstance(row, list):
|
|
4437
|
+
continue
|
|
4438
|
+
for cell in row:
|
|
4439
|
+
if not isinstance(cell, dict):
|
|
4440
|
+
continue
|
|
4441
|
+
cell_que_id = _coerce_count(cell.get("queId"))
|
|
4442
|
+
if cell_que_id is not None and cell_que_id > 0:
|
|
4443
|
+
row_subfield_ids.add(cell_que_id)
|
|
4444
|
+
if row_subfield_ids & editable_question_ids:
|
|
4445
|
+
effective_editable_ids.add(parent_que_id)
|
|
4446
|
+
return effective_editable_ids
|
|
4447
|
+
|
|
4448
|
+
def _record_update_via_custom_view(
|
|
4449
|
+
self,
|
|
4450
|
+
*,
|
|
4451
|
+
profile: str,
|
|
4452
|
+
app_key: str,
|
|
4453
|
+
apply_id: int,
|
|
4454
|
+
view_key: str,
|
|
4455
|
+
answers: list[JSONObject],
|
|
4456
|
+
verify_write: bool,
|
|
4457
|
+
force_refresh_form: bool,
|
|
4458
|
+
) -> JSONObject:
|
|
4459
|
+
normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
|
|
4460
|
+
normalized_view_key = view_key.strip()
|
|
4461
|
+
if not normalized_view_key:
|
|
4462
|
+
raise_tool_error(QingflowApiError.config_error("view_key is required for custom view update"))
|
|
4463
|
+
|
|
4464
|
+
def runner(session_profile, context):
|
|
4465
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form) if verify_write else None
|
|
4466
|
+
normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
|
|
4467
|
+
self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
|
|
4468
|
+
result = self.backend.request(
|
|
4469
|
+
"POST",
|
|
4470
|
+
context,
|
|
4471
|
+
f"/view/{normalized_view_key}/apply/{normalized_apply_id}",
|
|
4472
|
+
json_body={"answers": normalized_answers},
|
|
4473
|
+
)
|
|
4474
|
+
verification = self._verify_record_write_result(
|
|
4475
|
+
context,
|
|
4476
|
+
app_key=app_key,
|
|
4477
|
+
apply_id=normalized_apply_id,
|
|
4478
|
+
normalized_answers=normalized_answers,
|
|
4479
|
+
index=cast(FieldIndex, index),
|
|
4480
|
+
verify_view_key=normalized_view_key,
|
|
4481
|
+
) if verify_write and index is not None else None
|
|
4482
|
+
verified = True if verification is None else bool(verification.get("verified"))
|
|
4483
|
+
return self._attach_human_review_notice(
|
|
4484
|
+
{
|
|
4485
|
+
"profile": profile,
|
|
4486
|
+
"ws_id": session_profile.selected_ws_id,
|
|
4487
|
+
"request_route": self._request_route_payload(context),
|
|
4488
|
+
"app_key": app_key,
|
|
4489
|
+
"apply_id": normalized_apply_id,
|
|
4490
|
+
"record_id": normalized_apply_id,
|
|
4491
|
+
"result": result,
|
|
4492
|
+
"normalized_answers": normalized_answers,
|
|
4493
|
+
"status": "completed" if verified else "verification_failed",
|
|
4494
|
+
"ok": True,
|
|
4495
|
+
"verify_write": verify_write,
|
|
4496
|
+
"write_verified": verified if verify_write else None,
|
|
4497
|
+
"verification": verification,
|
|
4498
|
+
"resource": _record_resource_payload(normalized_apply_id),
|
|
4499
|
+
},
|
|
4500
|
+
operation="update",
|
|
4501
|
+
target="record data",
|
|
4502
|
+
)
|
|
4503
|
+
|
|
4504
|
+
return self._run_record_tool(profile, runner)
|
|
4505
|
+
|
|
4506
|
+
def _record_update_via_task_save_only(
|
|
3534
4507
|
self,
|
|
3535
4508
|
*,
|
|
3536
4509
|
profile: str,
|
|
3537
4510
|
app_key: str,
|
|
3538
|
-
|
|
3539
|
-
|
|
4511
|
+
apply_id: int,
|
|
4512
|
+
workflow_node_id: int,
|
|
4513
|
+
answers: list[JSONObject],
|
|
3540
4514
|
verify_write: bool,
|
|
3541
|
-
|
|
4515
|
+
force_refresh_form: bool,
|
|
3542
4516
|
) -> JSONObject:
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
record_ids=[],
|
|
3557
|
-
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3558
|
-
submit_type=1,
|
|
3559
|
-
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
3560
|
-
)
|
|
3561
|
-
if preflight_data.get("blockers"):
|
|
3562
|
-
return self._record_write_blocked_response(
|
|
3563
|
-
raw_preflight,
|
|
3564
|
-
operation="update",
|
|
3565
|
-
normalized_payload=normalized_payload,
|
|
3566
|
-
output_profile=output_profile,
|
|
3567
|
-
human_review=True,
|
|
3568
|
-
target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
|
|
4517
|
+
normalized_apply_id = self._validate_app_and_record(app_key, apply_id)
|
|
4518
|
+
if workflow_node_id <= 0:
|
|
4519
|
+
raise_tool_error(QingflowApiError.config_error("workflow_node_id must be positive for task save-only update"))
|
|
4520
|
+
|
|
4521
|
+
def runner(session_profile, context):
|
|
4522
|
+
index = self._get_field_index(profile, context, app_key, force_refresh=force_refresh_form) if verify_write else None
|
|
4523
|
+
normalized_answers = [dict(item) for item in answers if isinstance(item, dict)]
|
|
4524
|
+
self._validate_record_write(app_key, normalized_answers, apply_id=normalized_apply_id)
|
|
4525
|
+
result = self.backend.request(
|
|
4526
|
+
"POST",
|
|
4527
|
+
context,
|
|
4528
|
+
f"/app/{app_key}/apply/{normalized_apply_id}",
|
|
4529
|
+
json_body={"role": 3, "auditNodeId": workflow_node_id, "answers": normalized_answers},
|
|
3569
4530
|
)
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
profile=profile,
|
|
4531
|
+
verification = self._verify_record_write_result(
|
|
4532
|
+
context,
|
|
3573
4533
|
app_key=app_key,
|
|
3574
|
-
apply_id=
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
4534
|
+
apply_id=normalized_apply_id,
|
|
4535
|
+
normalized_answers=normalized_answers,
|
|
4536
|
+
index=cast(FieldIndex, index),
|
|
4537
|
+
) if verify_write and index is not None else None
|
|
4538
|
+
verified = True if verification is None else bool(verification.get("verified"))
|
|
4539
|
+
return self._attach_human_review_notice(
|
|
4540
|
+
{
|
|
4541
|
+
"profile": profile,
|
|
4542
|
+
"ws_id": session_profile.selected_ws_id,
|
|
4543
|
+
"request_route": self._request_route_payload(context),
|
|
4544
|
+
"app_key": app_key,
|
|
4545
|
+
"apply_id": normalized_apply_id,
|
|
4546
|
+
"record_id": normalized_apply_id,
|
|
4547
|
+
"result": result,
|
|
4548
|
+
"normalized_answers": normalized_answers,
|
|
4549
|
+
"status": "completed" if verified else "verification_failed",
|
|
4550
|
+
"ok": True,
|
|
4551
|
+
"verify_write": verify_write,
|
|
4552
|
+
"write_verified": verified if verify_write else None,
|
|
4553
|
+
"verification": verification,
|
|
4554
|
+
"resource": _record_resource_payload(normalized_apply_id),
|
|
4555
|
+
},
|
|
3584
4556
|
operation="update",
|
|
3585
|
-
|
|
3586
|
-
record_id=record_id,
|
|
3587
|
-
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
4557
|
+
target="record data",
|
|
3588
4558
|
)
|
|
3589
|
-
|
|
3590
|
-
return self.
|
|
3591
|
-
raw_apply,
|
|
3592
|
-
operation="update",
|
|
3593
|
-
normalized_payload=normalized_payload,
|
|
3594
|
-
output_profile=output_profile,
|
|
3595
|
-
human_review=True,
|
|
3596
|
-
preflight=raw_preflight,
|
|
3597
|
-
)
|
|
4559
|
+
|
|
4560
|
+
return self._run_record_tool(profile, runner)
|
|
3598
4561
|
|
|
3599
4562
|
def _record_update_public_batch(
|
|
3600
4563
|
self,
|
|
@@ -3757,13 +4720,44 @@ class RecordTools(ToolBase):
|
|
|
3757
4720
|
"""执行内部辅助逻辑。"""
|
|
3758
4721
|
summary = self._record_update_batch_summary(responses)
|
|
3759
4722
|
batch_items = [self._record_update_batch_item_from_response(response, output_profile=output_profile) for response in responses]
|
|
4723
|
+
public_items = [self._record_update_public_batch_item(item, index=index) for index, item in enumerate(batch_items)]
|
|
3760
4724
|
status, ok, message = self._record_update_batch_envelope_status(summary=summary, dry_run=dry_run)
|
|
3761
4725
|
first_response = responses[0] if responses else {}
|
|
4726
|
+
applied_count = int(summary.get("applied_count") or 0)
|
|
4727
|
+
ready_count = int(summary.get("ready_count") or 0)
|
|
4728
|
+
verified_count = int(summary.get("verified_count") or 0)
|
|
4729
|
+
field_level_verified_count = int(summary.get("field_level_verified_count") or 0)
|
|
4730
|
+
confirmation_count = int(summary.get("confirmation_count") or 0)
|
|
4731
|
+
blocked_count = int(summary.get("blocked_count") or 0)
|
|
4732
|
+
failed_count = int(summary.get("failed_count") or 0)
|
|
4733
|
+
write_executed = applied_count > 0
|
|
4734
|
+
verification_status = "not_requested"
|
|
4735
|
+
if write_executed:
|
|
4736
|
+
verification_status = "verified" if verified_count == applied_count else "failed"
|
|
4737
|
+
updated_record_ids = [
|
|
4738
|
+
str(item.get("record_id"))
|
|
4739
|
+
for item in public_items
|
|
4740
|
+
if item.get("record_id") not in (None, "") and str(item.get("status") or "").lower() == "success"
|
|
4741
|
+
]
|
|
3762
4742
|
return {
|
|
3763
4743
|
"profile": first_response.get("profile", profile),
|
|
3764
4744
|
"ws_id": first_response.get("ws_id"),
|
|
3765
4745
|
"ok": ok,
|
|
3766
4746
|
"status": status,
|
|
4747
|
+
"mode": "batch",
|
|
4748
|
+
"dry_run": dry_run,
|
|
4749
|
+
"app_key": app_key,
|
|
4750
|
+
"total": int(summary.get("total") or 0),
|
|
4751
|
+
"succeeded": ready_count if dry_run else applied_count,
|
|
4752
|
+
"failed": blocked_count + failed_count,
|
|
4753
|
+
"needs_confirmation": confirmation_count,
|
|
4754
|
+
"updated_record_ids": updated_record_ids,
|
|
4755
|
+
"write_executed": write_executed,
|
|
4756
|
+
"safe_to_retry": not write_executed,
|
|
4757
|
+
"verification_status": verification_status,
|
|
4758
|
+
"field_level_verified_count": field_level_verified_count,
|
|
4759
|
+
"summary": summary,
|
|
4760
|
+
"items": public_items,
|
|
3767
4761
|
"request_route": first_response.get("request_route"),
|
|
3768
4762
|
"warnings": [],
|
|
3769
4763
|
"output_profile": output_profile,
|
|
@@ -3777,6 +4771,31 @@ class RecordTools(ToolBase):
|
|
|
3777
4771
|
"message": message,
|
|
3778
4772
|
}
|
|
3779
4773
|
|
|
4774
|
+
def _record_update_public_batch_item(self, item: JSONObject, *, index: int) -> JSONObject:
|
|
4775
|
+
"""执行内部辅助逻辑。"""
|
|
4776
|
+
public = dict(item)
|
|
4777
|
+
public.setdefault("index", index)
|
|
4778
|
+
public.setdefault("row_number", index + 1)
|
|
4779
|
+
resource = public.get("resource")
|
|
4780
|
+
if isinstance(resource, dict):
|
|
4781
|
+
record_id = resource.get("record_id")
|
|
4782
|
+
apply_id = resource.get("apply_id")
|
|
4783
|
+
if record_id not in (None, ""):
|
|
4784
|
+
public["record_id"] = str(record_id)
|
|
4785
|
+
if apply_id not in (None, ""):
|
|
4786
|
+
public["apply_id"] = str(apply_id)
|
|
4787
|
+
status = str(public.get("status") or "").lower()
|
|
4788
|
+
verification = public.get("verification")
|
|
4789
|
+
if isinstance(verification, dict):
|
|
4790
|
+
if bool(verification.get("verified")):
|
|
4791
|
+
public.setdefault("verification_status", "verified")
|
|
4792
|
+
elif status == "success":
|
|
4793
|
+
public.setdefault("verification_status", "failed")
|
|
4794
|
+
public.setdefault("write_executed", status == "success")
|
|
4795
|
+
public.setdefault("safe_to_retry", not bool(public.get("write_executed")))
|
|
4796
|
+
public.setdefault("verification_status", "not_requested")
|
|
4797
|
+
return public
|
|
4798
|
+
|
|
3780
4799
|
def _record_update_batch_summary(self, responses: list[JSONObject]) -> JSONObject:
|
|
3781
4800
|
"""执行内部辅助逻辑。"""
|
|
3782
4801
|
summary: JSONObject = {
|
|
@@ -3837,6 +4856,12 @@ class RecordTools(ToolBase):
|
|
|
3837
4856
|
"confirmation_requests": cast(list[JSONObject], data.get("confirmation_requests", [])),
|
|
3838
4857
|
"resolved_fields": cast(list[JSONObject], data.get("resolved_fields", [])),
|
|
3839
4858
|
}
|
|
4859
|
+
update_route = response.get("update_route")
|
|
4860
|
+
if isinstance(update_route, dict):
|
|
4861
|
+
item["update_route"] = update_route
|
|
4862
|
+
tried_routes = response.get("tried_routes")
|
|
4863
|
+
if isinstance(tried_routes, list):
|
|
4864
|
+
item["tried_routes"] = tried_routes
|
|
3840
4865
|
blockers = data.get("blockers")
|
|
3841
4866
|
if isinstance(blockers, list) and blockers:
|
|
3842
4867
|
item["blockers"] = blockers
|
|
@@ -4702,6 +5727,11 @@ class RecordTools(ToolBase):
|
|
|
4702
5727
|
delete_ids = [normalize_positive_id_int(record_id, field_name="record_id")]
|
|
4703
5728
|
if not delete_ids:
|
|
4704
5729
|
raise_tool_error(QingflowApiError.config_error("record_id or record_ids is required"))
|
|
5730
|
+
seen_delete_ids: set[int] = set()
|
|
5731
|
+
for item in delete_ids:
|
|
5732
|
+
if item in seen_delete_ids:
|
|
5733
|
+
raise_tool_error(QingflowApiError.config_error(f"duplicate record id in delete payload: {stringify_backend_id(item)}"))
|
|
5734
|
+
seen_delete_ids.add(item)
|
|
4705
5735
|
normalized_payload = {
|
|
4706
5736
|
"operation": "delete",
|
|
4707
5737
|
"record_id": stringify_backend_id(record_id) if record_id is not None else None,
|
|
@@ -4709,16 +5739,134 @@ class RecordTools(ToolBase):
|
|
|
4709
5739
|
"answers": [],
|
|
4710
5740
|
"submit_type": 1,
|
|
4711
5741
|
}
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
5742
|
+
return self._record_delete_public_batch(
|
|
5743
|
+
profile=profile,
|
|
5744
|
+
app_key=app_key,
|
|
5745
|
+
delete_ids=delete_ids,
|
|
4716
5746
|
normalized_payload=normalized_payload,
|
|
4717
5747
|
output_profile=normalized_output_profile,
|
|
4718
|
-
human_review=True,
|
|
4719
|
-
preflight=None,
|
|
4720
5748
|
)
|
|
4721
5749
|
|
|
5750
|
+
def _record_delete_public_batch(
|
|
5751
|
+
self,
|
|
5752
|
+
*,
|
|
5753
|
+
profile: str,
|
|
5754
|
+
app_key: str,
|
|
5755
|
+
delete_ids: list[int],
|
|
5756
|
+
normalized_payload: JSONObject,
|
|
5757
|
+
output_profile: str,
|
|
5758
|
+
) -> JSONObject:
|
|
5759
|
+
items: list[JSONObject] = []
|
|
5760
|
+
request_route: JSONObject | None = None
|
|
5761
|
+
ws_id: object = None
|
|
5762
|
+
for index, delete_id in enumerate(delete_ids):
|
|
5763
|
+
record_id_text = stringify_backend_id(delete_id)
|
|
5764
|
+
try:
|
|
5765
|
+
raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=[delete_id])
|
|
5766
|
+
request_route = cast(JSONObject, raw_apply.get("request_route")) if isinstance(raw_apply.get("request_route"), dict) else request_route
|
|
5767
|
+
ws_id = raw_apply.get("ws_id", ws_id)
|
|
5768
|
+
single_payload = {
|
|
5769
|
+
"operation": "delete",
|
|
5770
|
+
"record_id": record_id_text,
|
|
5771
|
+
"record_ids": [record_id_text],
|
|
5772
|
+
"answers": [],
|
|
5773
|
+
"submit_type": 1,
|
|
5774
|
+
}
|
|
5775
|
+
single_response = self._record_write_apply_response(
|
|
5776
|
+
raw_apply,
|
|
5777
|
+
operation="delete",
|
|
5778
|
+
normalized_payload=single_payload,
|
|
5779
|
+
output_profile=output_profile,
|
|
5780
|
+
human_review=True,
|
|
5781
|
+
preflight=None,
|
|
5782
|
+
)
|
|
5783
|
+
item_status = str(single_response.get("status") or "success")
|
|
5784
|
+
item: JSONObject = {
|
|
5785
|
+
"index": index,
|
|
5786
|
+
"row_number": index + 1,
|
|
5787
|
+
"record_id": record_id_text,
|
|
5788
|
+
"status": item_status,
|
|
5789
|
+
"write_executed": bool(single_response.get("write_executed")),
|
|
5790
|
+
"verification_status": single_response.get("verification_status", "not_requested"),
|
|
5791
|
+
"safe_to_retry": bool(single_response.get("safe_to_retry", False)),
|
|
5792
|
+
}
|
|
5793
|
+
if item_status != "success":
|
|
5794
|
+
item["error"] = (single_response.get("data") or {}).get("error") if isinstance(single_response.get("data"), dict) else None
|
|
5795
|
+
items.append(item)
|
|
5796
|
+
except (QingflowApiError, RuntimeError) as exc:
|
|
5797
|
+
error_response = self._record_write_exception_response(
|
|
5798
|
+
exc,
|
|
5799
|
+
operation="delete",
|
|
5800
|
+
profile=profile,
|
|
5801
|
+
app_key=app_key,
|
|
5802
|
+
record_id=record_id_text,
|
|
5803
|
+
output_profile=output_profile,
|
|
5804
|
+
human_review=True,
|
|
5805
|
+
write_executed=False,
|
|
5806
|
+
)
|
|
5807
|
+
request_route = cast(JSONObject, error_response.get("request_route")) if isinstance(error_response.get("request_route"), dict) else request_route
|
|
5808
|
+
item = {
|
|
5809
|
+
"index": index,
|
|
5810
|
+
"row_number": index + 1,
|
|
5811
|
+
"record_id": record_id_text,
|
|
5812
|
+
"status": "failed",
|
|
5813
|
+
"write_executed": False,
|
|
5814
|
+
"verification_status": "not_requested",
|
|
5815
|
+
"safe_to_retry": True,
|
|
5816
|
+
"error": (error_response.get("data") or {}).get("error") if isinstance(error_response.get("data"), dict) else {"message": str(exc)},
|
|
5817
|
+
}
|
|
5818
|
+
items.append(item)
|
|
5819
|
+
deleted_ids = [
|
|
5820
|
+
str(item["record_id"])
|
|
5821
|
+
for item in items
|
|
5822
|
+
if str(item.get("status") or "") == "success"
|
|
5823
|
+
]
|
|
5824
|
+
failed_ids = [
|
|
5825
|
+
str(item["record_id"])
|
|
5826
|
+
for item in items
|
|
5827
|
+
if str(item.get("status") or "") != "success"
|
|
5828
|
+
]
|
|
5829
|
+
total = len(items)
|
|
5830
|
+
succeeded = len(deleted_ids)
|
|
5831
|
+
failed = len(failed_ids)
|
|
5832
|
+
if succeeded and failed:
|
|
5833
|
+
status = "partial_success"
|
|
5834
|
+
ok = False
|
|
5835
|
+
elif succeeded:
|
|
5836
|
+
status = "success"
|
|
5837
|
+
ok = True
|
|
5838
|
+
else:
|
|
5839
|
+
status = "failed"
|
|
5840
|
+
ok = False
|
|
5841
|
+
write_executed = any(bool(item.get("write_executed")) for item in items)
|
|
5842
|
+
return {
|
|
5843
|
+
"profile": profile,
|
|
5844
|
+
"ws_id": ws_id,
|
|
5845
|
+
"ok": ok,
|
|
5846
|
+
"status": status,
|
|
5847
|
+
"mode": "batch",
|
|
5848
|
+
"total": total,
|
|
5849
|
+
"succeeded": succeeded,
|
|
5850
|
+
"failed": failed,
|
|
5851
|
+
"deleted_ids": deleted_ids,
|
|
5852
|
+
"failed_ids": failed_ids,
|
|
5853
|
+
"write_executed": write_executed,
|
|
5854
|
+
"verification_status": "not_requested",
|
|
5855
|
+
"safe_to_retry": False if write_executed else True,
|
|
5856
|
+
"request_route": request_route,
|
|
5857
|
+
"warnings": [],
|
|
5858
|
+
"output_profile": output_profile,
|
|
5859
|
+
"items": items,
|
|
5860
|
+
"data": {
|
|
5861
|
+
"action": {"operation": "delete", "executed": write_executed},
|
|
5862
|
+
"resource": {"type": "record", "app_key": app_key, "record_id": None, "record_ids": [stringify_backend_id(item) for item in delete_ids]},
|
|
5863
|
+
"normalized_payload": normalized_payload,
|
|
5864
|
+
"deleted_ids": deleted_ids,
|
|
5865
|
+
"failed_ids": failed_ids,
|
|
5866
|
+
"items": items,
|
|
5867
|
+
},
|
|
5868
|
+
}
|
|
5869
|
+
|
|
4722
5870
|
@tool_cn_name("写入记录")
|
|
4723
5871
|
def record_write(
|
|
4724
5872
|
self,
|
|
@@ -7277,6 +8425,7 @@ class RecordTools(ToolBase):
|
|
|
7277
8425
|
field_index_override=index,
|
|
7278
8426
|
)
|
|
7279
8427
|
except RecordInputError as error:
|
|
8428
|
+
normalized_answers = list(lookup_resolution.normalized_answers)
|
|
7280
8429
|
invalid_fields.append(
|
|
7281
8430
|
{
|
|
7282
8431
|
"location": _stringify_json(error.details.get("location") if error.details else None),
|
|
@@ -10632,6 +11781,8 @@ class RecordTools(ToolBase):
|
|
|
10632
11781
|
response_status = "verification_failed" if verification_status == "failed" else "success"
|
|
10633
11782
|
if not bool(raw_apply.get("ok", True)) and verification_status != "failed":
|
|
10634
11783
|
response_status = raw_status or "failed"
|
|
11784
|
+
update_route = raw_apply.get("update_route") if isinstance(raw_apply.get("update_route"), dict) else None
|
|
11785
|
+
tried_routes = raw_apply.get("tried_routes") if isinstance(raw_apply.get("tried_routes"), list) else []
|
|
10635
11786
|
response: JSONObject = {
|
|
10636
11787
|
"profile": raw_apply.get("profile"),
|
|
10637
11788
|
"ws_id": raw_apply.get("ws_id"),
|
|
@@ -10643,6 +11794,8 @@ class RecordTools(ToolBase):
|
|
|
10643
11794
|
"request_route": raw_apply.get("request_route"),
|
|
10644
11795
|
"warnings": warnings,
|
|
10645
11796
|
"output_profile": output_profile,
|
|
11797
|
+
"update_route": update_route,
|
|
11798
|
+
"tried_routes": tried_routes,
|
|
10646
11799
|
"data": {
|
|
10647
11800
|
"action": {"operation": operation, "executed": True},
|
|
10648
11801
|
"resource": resource,
|
|
@@ -10653,6 +11806,8 @@ class RecordTools(ToolBase):
|
|
|
10653
11806
|
"confirmation_requests": [],
|
|
10654
11807
|
"resolved_fields": resolved_fields,
|
|
10655
11808
|
"human_review": self._record_write_human_review_payload(operation, enabled=human_review),
|
|
11809
|
+
"update_route": update_route,
|
|
11810
|
+
"tried_routes": tried_routes,
|
|
10656
11811
|
},
|
|
10657
11812
|
}
|
|
10658
11813
|
if record_id is not None:
|
|
@@ -12224,6 +13379,7 @@ class RecordTools(ToolBase):
|
|
|
12224
13379
|
normalized_answers: list[JSONObject],
|
|
12225
13380
|
index: FieldIndex,
|
|
12226
13381
|
verify_list_type: int = DEFAULT_RECORD_LIST_TYPE,
|
|
13382
|
+
verify_view_key: str | None = None,
|
|
12227
13383
|
) -> JSONObject:
|
|
12228
13384
|
"""执行内部辅助逻辑。"""
|
|
12229
13385
|
if apply_id is None:
|
|
@@ -12235,13 +13391,36 @@ class RecordTools(ToolBase):
|
|
|
12235
13391
|
"count_mismatches": [],
|
|
12236
13392
|
}
|
|
12237
13393
|
try:
|
|
12238
|
-
|
|
12239
|
-
|
|
12240
|
-
|
|
12241
|
-
|
|
12242
|
-
|
|
12243
|
-
|
|
13394
|
+
if verify_view_key:
|
|
13395
|
+
record = self.backend.request(
|
|
13396
|
+
"GET",
|
|
13397
|
+
context,
|
|
13398
|
+
f"/view/{verify_view_key}/apply/{apply_id}",
|
|
13399
|
+
)
|
|
13400
|
+
else:
|
|
13401
|
+
record = self.backend.request(
|
|
13402
|
+
"GET",
|
|
13403
|
+
context,
|
|
13404
|
+
f"/app/{app_key}/apply/{apply_id}",
|
|
13405
|
+
params={"role": 1, "listType": verify_list_type},
|
|
13406
|
+
)
|
|
12244
13407
|
except QingflowApiError as exc:
|
|
13408
|
+
if verify_view_key:
|
|
13409
|
+
return {
|
|
13410
|
+
"verified": False,
|
|
13411
|
+
"verification_mode": "custom_view_record_detail",
|
|
13412
|
+
"field_level_verified": False,
|
|
13413
|
+
"error": "custom_view_readback_failed",
|
|
13414
|
+
"missing_fields": [],
|
|
13415
|
+
"empty_fields": [],
|
|
13416
|
+
"count_mismatches": [],
|
|
13417
|
+
"warnings": [{
|
|
13418
|
+
"code": "WRITE_VERIFY_CUSTOM_VIEW_READBACK_FAILED",
|
|
13419
|
+
"message": "Write was sent through a custom view route, but the same view could not be re-read for field-level verification.",
|
|
13420
|
+
"backend_code": exc.backend_code,
|
|
13421
|
+
"http_status": exc.http_status,
|
|
13422
|
+
}],
|
|
13423
|
+
}
|
|
12245
13424
|
if exc.backend_code != 40002:
|
|
12246
13425
|
raise
|
|
12247
13426
|
return self._verify_record_write_result_via_initiated_tasks(
|
|
@@ -12305,7 +13484,7 @@ class RecordTools(ToolBase):
|
|
|
12305
13484
|
)
|
|
12306
13485
|
return {
|
|
12307
13486
|
"verified": not missing_fields and not empty_fields and not count_mismatches,
|
|
12308
|
-
"verification_mode": "initiated_record_view",
|
|
13487
|
+
"verification_mode": "custom_view_record_detail" if verify_view_key else "initiated_record_view",
|
|
12309
13488
|
"field_level_verified": True,
|
|
12310
13489
|
"missing_fields": missing_fields,
|
|
12311
13490
|
"empty_fields": empty_fields,
|
|
@@ -13217,6 +14396,13 @@ def _record_access_run_dir() -> Path:
|
|
|
13217
14396
|
return base_dir / run_id
|
|
13218
14397
|
|
|
13219
14398
|
|
|
14399
|
+
def _record_logs_run_dir() -> Path:
|
|
14400
|
+
custom_home = os.getenv("QINGFLOW_MCP_RECORD_LOGS_HOME")
|
|
14401
|
+
base_dir = Path(custom_home).expanduser() if custom_home else get_mcp_home() / "record-logs"
|
|
14402
|
+
run_id = f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%SZ')}-{uuid4().hex[:8]}"
|
|
14403
|
+
return base_dir / run_id
|
|
14404
|
+
|
|
14405
|
+
|
|
13220
14406
|
def _record_access_field_payload(field: FormField) -> JSONObject:
|
|
13221
14407
|
return {
|
|
13222
14408
|
"field_id": field.que_id,
|
|
@@ -13985,6 +15171,159 @@ def _record_detail_log_unavailable_payload(source: str, reason: str) -> JSONObje
|
|
|
13985
15171
|
}
|
|
13986
15172
|
|
|
13987
15173
|
|
|
15174
|
+
def _record_logs_hidden_payload(source: str) -> JSONObject:
|
|
15175
|
+
return {
|
|
15176
|
+
"status": "hidden",
|
|
15177
|
+
"visible": False,
|
|
15178
|
+
"source": source,
|
|
15179
|
+
"complete": False,
|
|
15180
|
+
"items_count": 0,
|
|
15181
|
+
"pages_fetched": 0,
|
|
15182
|
+
"reported_total": None,
|
|
15183
|
+
"local_path": None,
|
|
15184
|
+
"preview_items": [],
|
|
15185
|
+
"warnings": [],
|
|
15186
|
+
}
|
|
15187
|
+
|
|
15188
|
+
|
|
15189
|
+
def _record_logs_unavailable_payload(source: str, reason: str) -> JSONObject:
|
|
15190
|
+
return {
|
|
15191
|
+
"status": "unavailable",
|
|
15192
|
+
"visible": None,
|
|
15193
|
+
"source": source,
|
|
15194
|
+
"reason": reason,
|
|
15195
|
+
"complete": False,
|
|
15196
|
+
"items_count": 0,
|
|
15197
|
+
"pages_fetched": 0,
|
|
15198
|
+
"reported_total": None,
|
|
15199
|
+
"local_path": None,
|
|
15200
|
+
"preview_items": [],
|
|
15201
|
+
"warnings": [],
|
|
15202
|
+
}
|
|
15203
|
+
|
|
15204
|
+
|
|
15205
|
+
def _record_logs_fetch_all_to_jsonl(
|
|
15206
|
+
*,
|
|
15207
|
+
fetch_page,
|
|
15208
|
+
normalizer,
|
|
15209
|
+
source: str,
|
|
15210
|
+
file_path: Path,
|
|
15211
|
+
deadline: float,
|
|
15212
|
+
) -> JSONObject: # type: ignore[no-untyped-def]
|
|
15213
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
15214
|
+
page_num = 1
|
|
15215
|
+
pages_fetched = 0
|
|
15216
|
+
items_count = 0
|
|
15217
|
+
reported_total: int | None = None
|
|
15218
|
+
preview_items: list[JSONObject] = []
|
|
15219
|
+
warnings: list[JSONObject] = []
|
|
15220
|
+
stopped_reason: str | None = None
|
|
15221
|
+
complete = True
|
|
15222
|
+
|
|
15223
|
+
with file_path.open("w", encoding="utf-8") as handle:
|
|
15224
|
+
while True:
|
|
15225
|
+
if _record_logs_time_budget_exceeded(deadline=deadline):
|
|
15226
|
+
complete = False
|
|
15227
|
+
stopped_reason = "time_budget_exceeded"
|
|
15228
|
+
warnings.append(_record_logs_time_budget_warning(source=source, pages_fetched=pages_fetched, items_count=items_count))
|
|
15229
|
+
break
|
|
15230
|
+
payload = fetch_page(page_num)
|
|
15231
|
+
pages_fetched += 1
|
|
15232
|
+
items = _record_detail_page_items(payload)
|
|
15233
|
+
if reported_total is None:
|
|
15234
|
+
reported_total = _record_detail_page_total(payload)
|
|
15235
|
+
if not items:
|
|
15236
|
+
break
|
|
15237
|
+
for item in items:
|
|
15238
|
+
normalized = normalizer(item)
|
|
15239
|
+
handle.write(json.dumps(normalized, ensure_ascii=False) + "\n")
|
|
15240
|
+
items_count += 1
|
|
15241
|
+
if len(preview_items) < RECORD_LOGS_PREVIEW_LIMIT:
|
|
15242
|
+
preview_items.append(normalized)
|
|
15243
|
+
if items_count >= RECORD_LOGS_MAX_ITEMS:
|
|
15244
|
+
complete = False
|
|
15245
|
+
stopped_reason = "item_limit_exceeded"
|
|
15246
|
+
warnings.append(_record_logs_item_limit_warning(source=source, item_limit=RECORD_LOGS_MAX_ITEMS))
|
|
15247
|
+
break
|
|
15248
|
+
if stopped_reason:
|
|
15249
|
+
break
|
|
15250
|
+
if reported_total is not None and items_count >= reported_total:
|
|
15251
|
+
break
|
|
15252
|
+
if reported_total is None and len(items) < RECORD_LOGS_PAGE_SIZE:
|
|
15253
|
+
break
|
|
15254
|
+
page_num += 1
|
|
15255
|
+
|
|
15256
|
+
return {
|
|
15257
|
+
"status": "ok" if complete else "partial",
|
|
15258
|
+
"visible": True,
|
|
15259
|
+
"source": source,
|
|
15260
|
+
"complete": complete,
|
|
15261
|
+
"items_count": items_count,
|
|
15262
|
+
"pages_fetched": pages_fetched,
|
|
15263
|
+
"page_size": RECORD_LOGS_PAGE_SIZE,
|
|
15264
|
+
"reported_total": reported_total,
|
|
15265
|
+
"local_path": str(file_path),
|
|
15266
|
+
"preview_items": preview_items,
|
|
15267
|
+
"warnings": warnings,
|
|
15268
|
+
"stopped_reason": stopped_reason,
|
|
15269
|
+
}
|
|
15270
|
+
|
|
15271
|
+
|
|
15272
|
+
def _record_logs_time_budget_exceeded(*, deadline: float) -> bool:
|
|
15273
|
+
return time.monotonic() + RECORD_LOGS_MIN_REMAINING_SECONDS >= deadline
|
|
15274
|
+
|
|
15275
|
+
|
|
15276
|
+
def _record_logs_time_budget_warning(*, source: str, pages_fetched: int, items_count: int) -> JSONObject:
|
|
15277
|
+
return {
|
|
15278
|
+
"code": "RECORD_LOGS_TIME_BUDGET_EXCEEDED",
|
|
15279
|
+
"source": source,
|
|
15280
|
+
"message": "record_logs_get stopped early to return partial JSONL files before the caller timeout.",
|
|
15281
|
+
"pages_fetched": pages_fetched,
|
|
15282
|
+
"items_count": items_count,
|
|
15283
|
+
}
|
|
15284
|
+
|
|
15285
|
+
|
|
15286
|
+
def _record_logs_item_limit_warning(*, source: str, item_limit: int) -> JSONObject:
|
|
15287
|
+
return {
|
|
15288
|
+
"code": "RECORD_LOGS_ITEM_LIMIT_EXCEEDED",
|
|
15289
|
+
"source": source,
|
|
15290
|
+
"message": f"record_logs_get stopped after the internal {item_limit} item limit.",
|
|
15291
|
+
"item_limit": item_limit,
|
|
15292
|
+
}
|
|
15293
|
+
|
|
15294
|
+
|
|
15295
|
+
def _record_logs_overall_status(*, data_logs: JSONObject, workflow_logs: JSONObject) -> str:
|
|
15296
|
+
statuses = {str(data_logs.get("status") or ""), str(workflow_logs.get("status") or "")}
|
|
15297
|
+
if statuses == {"unavailable"}:
|
|
15298
|
+
return "unavailable"
|
|
15299
|
+
if "partial" in statuses or "unavailable" in statuses:
|
|
15300
|
+
return "partial"
|
|
15301
|
+
return "success"
|
|
15302
|
+
|
|
15303
|
+
|
|
15304
|
+
def _record_logs_context_integrity(*, data_logs: JSONObject, workflow_logs: JSONObject) -> JSONObject:
|
|
15305
|
+
data_integrity = _record_logs_section_integrity(data_logs)
|
|
15306
|
+
workflow_integrity = _record_logs_section_integrity(workflow_logs)
|
|
15307
|
+
return {
|
|
15308
|
+
"data_logs": data_integrity,
|
|
15309
|
+
"workflow_logs": workflow_integrity,
|
|
15310
|
+
"safe_for_full_log_conclusion": data_integrity == "full" and workflow_integrity == "full",
|
|
15311
|
+
}
|
|
15312
|
+
|
|
15313
|
+
|
|
15314
|
+
def _record_logs_section_integrity(section: JSONObject) -> str:
|
|
15315
|
+
status = str(section.get("status") or "")
|
|
15316
|
+
if status == "ok" and section.get("complete") is True:
|
|
15317
|
+
return "full"
|
|
15318
|
+
if status == "hidden":
|
|
15319
|
+
return "hidden"
|
|
15320
|
+
if status == "partial":
|
|
15321
|
+
return "partial"
|
|
15322
|
+
if status == "unavailable":
|
|
15323
|
+
return "unavailable"
|
|
15324
|
+
return "unknown"
|
|
15325
|
+
|
|
15326
|
+
|
|
13988
15327
|
def _record_detail_log_page_payload(payload: JSONValue, *, normalizer, source: str) -> JSONObject: # type: ignore[no-untyped-def]
|
|
13989
15328
|
items = _record_detail_page_items(payload)
|
|
13990
15329
|
total = _record_detail_page_total(payload)
|
|
@@ -18780,6 +20119,15 @@ def _write_format_for_field(field: FormField) -> JSONObject:
|
|
|
18780
20119
|
return _write_support_payload(support_level="full", kind="boolean_label", examples=["是", "否"])
|
|
18781
20120
|
if field.que_type in DATE_QUE_TYPES:
|
|
18782
20121
|
return _write_support_payload(support_level="full", kind="date_string", examples=["2026-03-13 10:00:00"])
|
|
20122
|
+
if field.que_type == 8:
|
|
20123
|
+
allow_decimal = bool((field.raw or {}).get("canDecimal"))
|
|
20124
|
+
payload = _write_support_payload(
|
|
20125
|
+
support_level="full",
|
|
20126
|
+
kind="amount_number",
|
|
20127
|
+
examples=[100.5 if allow_decimal else 100],
|
|
20128
|
+
)
|
|
20129
|
+
payload["allow_decimal"] = allow_decimal
|
|
20130
|
+
return payload
|
|
18783
20131
|
return _write_support_payload(support_level="full", kind="scalar_text")
|
|
18784
20132
|
|
|
18785
20133
|
|
|
@@ -18805,6 +20153,11 @@ def _ready_schema_format_hint(kind: str, write_format: JSONObject) -> str:
|
|
|
18805
20153
|
if kind == "date":
|
|
18806
20154
|
return "推荐传 'YYYY-MM-DD HH:MM:SS';只有日期时可传 'YYYY-MM-DD'。"
|
|
18807
20155
|
if kind == "number":
|
|
20156
|
+
write_kind = _normalize_optional_text(write_format.get("kind"))
|
|
20157
|
+
if write_kind == "amount_number":
|
|
20158
|
+
if bool(write_format.get("allow_decimal")):
|
|
20159
|
+
return "传数字或数字字符串,支持小数。"
|
|
20160
|
+
return "传整数或整数字符串;该字段后端不接受小数。"
|
|
18808
20161
|
return "传数字或数字字符串。"
|
|
18809
20162
|
if kind == "unsupported":
|
|
18810
20163
|
reason = _normalize_optional_text(write_format.get("reason"))
|