@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.
@@ -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
- "Use record_update_schema_get first. "
497
- "This tool automatically probes accessible views in order and uses the first safe match."
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
- record_id: int,
3539
- fields: JSONObject,
4511
+ apply_id: int,
4512
+ workflow_node_id: int,
4513
+ answers: list[JSONObject],
3540
4514
  verify_write: bool,
3541
- output_profile: str,
4515
+ force_refresh_form: bool,
3542
4516
  ) -> JSONObject:
3543
- """执行内部辅助逻辑。"""
3544
- raw_preflight = self._preflight_record_update_with_auto_view(
3545
- profile=profile,
3546
- app_key=app_key,
3547
- record_id=record_id,
3548
- fields=fields,
3549
- force_refresh_form=False,
3550
- )
3551
- preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
3552
- preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
3553
- normalized_payload = self._record_write_normalized_payload(
3554
- operation="update",
3555
- record_id=record_id,
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
- try:
3571
- raw_apply = self.record_update(
3572
- profile=profile,
4531
+ verification = self._verify_record_write_result(
4532
+ context,
3573
4533
  app_key=app_key,
3574
- apply_id=record_id,
3575
- answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
3576
- fields={},
3577
- role=1,
3578
- verify_write=verify_write,
3579
- force_refresh_form=preflight_used_force_refresh,
3580
- )
3581
- except QingflowApiError as exc:
3582
- self._raise_record_write_permission_error(
3583
- exc,
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
- app_key=app_key,
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
- raise
3590
- return self._record_write_apply_response(
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
- raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=delete_ids)
4713
- return self._record_write_apply_response(
4714
- raw_apply,
4715
- operation="delete",
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
- record = self.backend.request(
12239
- "GET",
12240
- context,
12241
- f"/app/{app_key}/apply/{apply_id}",
12242
- params={"role": 1, "listType": verify_list_type},
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"))