@josephyan/qingflow-cli 1.0.11 → 1.1.2
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 +3 -3
- package/npm/bin/qingflow.mjs +40 -2
- package/npm/lib/runtime.mjs +386 -15
- package/npm/scripts/postinstall.mjs +7 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +440 -0
- package/skills/qingflow-cli/manifest.yaml +10 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +94 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_APP_DELIVERY_WORKFLOW.md +485 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_CHARTS_WORKFLOW.md +237 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_MATCH_RULES.md +137 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_PORTAL_WORKFLOW.md +263 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_VIEWS_WORKFLOW.md +304 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_WORKSPACE_ICONS.md +41 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +139 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +84 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +129 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +195 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +159 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +20 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md +176 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +163 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_SCHEMA_APPLY_FIELD_TYPES_AND_SCENARIOS.md +107 -0
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md +151 -0
- package/skills/qingflow-cli/reference/_batch_schema_complex.json +18 -0
- package/skills/qingflow-cli/reference/_batch_schema_scalar.json +17 -0
- package/skills/qingflow-cli/reference/charts_remove.example.json +1 -0
- package/skills/qingflow-cli/reference/charts_reorder.example.json +1 -0
- package/skills/qingflow-cli/reference/charts_upsert_bar.example.json +8 -0
- package/skills/qingflow-cli/reference/charts_upsert_dashboard_starter.example.json +37 -0
- package/skills/qingflow-cli/reference/charts_upsert_minimal.example.json +13 -0
- package/skills/qingflow-cli/reference/portal_sections_all_types.example.json +131 -0
- package/skills/qingflow-cli/reference/portal_sections_five_types.example.json +126 -0
- package/skills/qingflow-cli/reference/portal_sections_standard_workbench.example.json +128 -0
- package/skills/qingflow-cli/reference/schema_add_fields_minimal.example.json +7 -0
- package/skills/qingflow-cli/reference/schema_apply_add_fields_all_types.json +78 -0
- package/skills/qingflow-cli/reference/views_upsert_table_minimal.example.json +7 -0
- package/skills/qingflow-cli/scripts/builder-package-from-app-list.py +140 -0
- package/skills/qingflow-cli/scripts/find-app-by-keyword.py +132 -0
- package/skills/qingflow-cli/scripts/validate_qingflow_output_files.py +87 -0
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/builder_facade/models.py +532 -48
- package/src/qingflow_mcp/builder_facade/service.py +9194 -2384
- package/src/qingflow_mcp/builder_facade/workflow_spec.py +111 -0
- package/src/qingflow_mcp/cli/commands/app.py +3 -16
- package/src/qingflow_mcp/cli/commands/builder.py +354 -56
- package/src/qingflow_mcp/cli/commands/record.py +89 -2
- package/src/qingflow_mcp/cli/formatters.py +32 -1
- package/src/qingflow_mcp/cli/main.py +245 -3
- package/src/qingflow_mcp/public_surface.py +11 -8
- package/src/qingflow_mcp/response_trim.py +143 -14
- package/src/qingflow_mcp/server.py +15 -12
- package/src/qingflow_mcp/server_app_builder.py +108 -30
- package/src/qingflow_mcp/server_app_user.py +17 -18
- package/src/qingflow_mcp/solution/compiler/__init__.py +1 -3
- package/src/qingflow_mcp/solution/compiler/icon_utils.py +294 -0
- package/src/qingflow_mcp/solution/executor.py +3 -133
- package/src/qingflow_mcp/tools/ai_builder_tools.py +2617 -440
- 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 +2095 -176
- package/src/qingflow_mcp/tools/resource_read_tools.py +3 -0
- package/src/qingflow_mcp/tools/solution_tools.py +30 -2
- package/src/qingflow_mcp/tools/workflow_tools.py +3 -31
- package/src/qingflow_mcp/version.py +110 -0
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +0 -173
|
@@ -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 = "",
|
|
@@ -470,21 +513,22 @@ class RecordTools(ToolBase):
|
|
|
470
513
|
|
|
471
514
|
@mcp.tool(
|
|
472
515
|
description=(
|
|
473
|
-
"Insert
|
|
516
|
+
"Insert Qingflow records using applicant-node field maps. "
|
|
474
517
|
"Use record_insert_schema_get first. "
|
|
475
|
-
"
|
|
518
|
+
"Prefer items=[{'fields': {...}}]; a single insert is one item. "
|
|
519
|
+
"Each item performs internal preflight validation before that item is written."
|
|
476
520
|
)
|
|
477
521
|
)
|
|
478
522
|
def record_insert(
|
|
479
523
|
app_key: str = "",
|
|
480
|
-
|
|
524
|
+
items: list[JSONObject] | None = None,
|
|
481
525
|
verify_write: bool = True,
|
|
482
526
|
output_profile: str = "normal",
|
|
483
527
|
) -> JSONObject:
|
|
484
528
|
return self.record_insert_public(
|
|
485
529
|
profile=DEFAULT_PROFILE,
|
|
486
530
|
app_key=app_key,
|
|
487
|
-
|
|
531
|
+
items=items,
|
|
488
532
|
verify_write=verify_write,
|
|
489
533
|
output_profile=output_profile,
|
|
490
534
|
)
|
|
@@ -492,8 +536,9 @@ class RecordTools(ToolBase):
|
|
|
492
536
|
@mcp.tool(
|
|
493
537
|
description=(
|
|
494
538
|
"Update one Qingflow record using a field map. "
|
|
495
|
-
"
|
|
496
|
-
"
|
|
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."
|
|
497
542
|
)
|
|
498
543
|
)
|
|
499
544
|
def record_update(
|
|
@@ -994,15 +1039,59 @@ class RecordTools(ToolBase):
|
|
|
994
1039
|
item["title"]: self._ready_schema_template_value(item)
|
|
995
1040
|
for item in writable_fields
|
|
996
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
|
+
},
|
|
997
1048
|
}
|
|
998
1049
|
if normalized_output_profile == "verbose":
|
|
999
1050
|
response["view_probe_summary"] = probe_summary
|
|
1000
1051
|
response["record_context_probe"] = probe_summary
|
|
1001
1052
|
response["ambiguous_fields"] = ambiguous_fields
|
|
1053
|
+
response["route_probe_summary"] = probe_summary
|
|
1002
1054
|
return response
|
|
1003
1055
|
|
|
1004
1056
|
return self._run_record_tool(profile, runner)
|
|
1005
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
|
+
|
|
1006
1095
|
def _record_update_schema_blocked_response(
|
|
1007
1096
|
self,
|
|
1008
1097
|
*,
|
|
@@ -1064,11 +1153,16 @@ class RecordTools(ToolBase):
|
|
|
1064
1153
|
required = bool(required_override) if required_override is not None else bool(field.required or any(item.get("required") for item in row_fields))
|
|
1065
1154
|
else:
|
|
1066
1155
|
required = bool(required_override) if required_override is not None else bool(field.required)
|
|
1156
|
+
write_format = _write_format_for_field(field)
|
|
1067
1157
|
payload: JSONObject = {
|
|
1068
1158
|
"title": field.que_title,
|
|
1069
1159
|
"kind": kind,
|
|
1070
1160
|
"required": required,
|
|
1161
|
+
"format_hint": _ready_schema_format_hint(kind, write_format),
|
|
1071
1162
|
}
|
|
1163
|
+
example_value = _ready_schema_example_value(kind, field, write_format, row_fields=row_fields)
|
|
1164
|
+
if example_value is not None:
|
|
1165
|
+
payload["example_value"] = example_value
|
|
1072
1166
|
if include_field_id:
|
|
1073
1167
|
payload["field_id"] = field.que_id
|
|
1074
1168
|
if kind in {"single_select", "multi_select"} and field.options:
|
|
@@ -2169,6 +2263,130 @@ class RecordTools(ToolBase):
|
|
|
2169
2263
|
|
|
2170
2264
|
return self._run_record_tool(profile, runner)
|
|
2171
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
|
+
|
|
2172
2390
|
def _record_get_detail_context(
|
|
2173
2391
|
self,
|
|
2174
2392
|
*,
|
|
@@ -2818,6 +3036,108 @@ class RecordTools(ToolBase):
|
|
|
2818
3036
|
unavailable_context.append(_record_detail_unavailable_context("workflow_logs", "流程日志本次获取失败。", exc))
|
|
2819
3037
|
return _record_detail_log_unavailable_payload("workflow_logs", "fetch_unavailable")
|
|
2820
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
|
+
|
|
2821
3141
|
def _record_get_associated_resources(
|
|
2822
3142
|
self,
|
|
2823
3143
|
context, # type: ignore[no-untyped-def]
|
|
@@ -2995,86 +3315,7 @@ class RecordTools(ToolBase):
|
|
|
2995
3315
|
profile: str = DEFAULT_PROFILE,
|
|
2996
3316
|
app_key: str,
|
|
2997
3317
|
fields: JSONObject | None = None,
|
|
2998
|
-
verify_write: bool = True,
|
|
2999
|
-
output_profile: str = "normal",
|
|
3000
|
-
) -> JSONObject:
|
|
3001
|
-
"""执行记录相关逻辑。"""
|
|
3002
|
-
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
3003
|
-
if not app_key:
|
|
3004
|
-
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
3005
|
-
if fields is not None and not isinstance(fields, dict):
|
|
3006
|
-
raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
|
|
3007
|
-
submit_type_value = self._normalize_record_write_submit_type("submit")
|
|
3008
|
-
raw_preflight = self._preflight_record_write(
|
|
3009
|
-
profile=profile,
|
|
3010
|
-
operation="create",
|
|
3011
|
-
app_key=app_key,
|
|
3012
|
-
apply_id=None,
|
|
3013
|
-
answers=[],
|
|
3014
|
-
fields=cast(JSONObject, fields or {}),
|
|
3015
|
-
force_refresh_form=False,
|
|
3016
|
-
view_id=None,
|
|
3017
|
-
list_type=None,
|
|
3018
|
-
view_key=None,
|
|
3019
|
-
view_name=None,
|
|
3020
|
-
)
|
|
3021
|
-
preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
|
|
3022
|
-
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
3023
|
-
normalized_payload: JSONObject = self._record_write_normalized_payload(
|
|
3024
|
-
operation="insert",
|
|
3025
|
-
record_id=None,
|
|
3026
|
-
record_ids=[],
|
|
3027
|
-
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3028
|
-
submit_type=submit_type_value,
|
|
3029
|
-
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
3030
|
-
)
|
|
3031
|
-
if preflight_data.get("blockers"):
|
|
3032
|
-
return self._record_write_blocked_response(
|
|
3033
|
-
raw_preflight,
|
|
3034
|
-
operation="insert",
|
|
3035
|
-
normalized_payload=normalized_payload,
|
|
3036
|
-
output_profile=normalized_output_profile,
|
|
3037
|
-
human_review=False,
|
|
3038
|
-
target_resource={"type": "record", "app_key": app_key, "record_id": None, "record_ids": []},
|
|
3039
|
-
)
|
|
3040
|
-
try:
|
|
3041
|
-
raw_apply = self.record_create(
|
|
3042
|
-
profile=profile,
|
|
3043
|
-
app_key=app_key,
|
|
3044
|
-
answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3045
|
-
fields={},
|
|
3046
|
-
submit_type=submit_type_value,
|
|
3047
|
-
verify_write=verify_write,
|
|
3048
|
-
force_refresh_form=preflight_used_force_refresh,
|
|
3049
|
-
)
|
|
3050
|
-
except QingflowApiError as exc:
|
|
3051
|
-
self._raise_record_write_permission_error(
|
|
3052
|
-
exc,
|
|
3053
|
-
operation="insert",
|
|
3054
|
-
app_key=app_key,
|
|
3055
|
-
record_id=None,
|
|
3056
|
-
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
3057
|
-
)
|
|
3058
|
-
raise
|
|
3059
|
-
return self._record_write_apply_response(
|
|
3060
|
-
raw_apply,
|
|
3061
|
-
operation="insert",
|
|
3062
|
-
normalized_payload=normalized_payload,
|
|
3063
|
-
output_profile=normalized_output_profile,
|
|
3064
|
-
human_review=False,
|
|
3065
|
-
preflight=raw_preflight,
|
|
3066
|
-
)
|
|
3067
|
-
|
|
3068
|
-
@tool_cn_name("更新记录")
|
|
3069
|
-
def record_update_public(
|
|
3070
|
-
self,
|
|
3071
|
-
*,
|
|
3072
|
-
profile: str = DEFAULT_PROFILE,
|
|
3073
|
-
app_key: str,
|
|
3074
|
-
record_id: Any | None,
|
|
3075
|
-
fields: JSONObject | None = None,
|
|
3076
3318
|
items: list[JSONObject] | None = None,
|
|
3077
|
-
dry_run: bool = False,
|
|
3078
3319
|
verify_write: bool = True,
|
|
3079
3320
|
output_profile: str = "normal",
|
|
3080
3321
|
) -> JSONObject:
|
|
@@ -3083,102 +3324,1240 @@ class RecordTools(ToolBase):
|
|
|
3083
3324
|
if not app_key:
|
|
3084
3325
|
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
3085
3326
|
if items is not None:
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
normalized_items = self._normalize_public_record_update_batch_items(
|
|
3089
|
-
record_id=record_id,
|
|
3090
|
-
fields=fields,
|
|
3091
|
-
items=items,
|
|
3092
|
-
)
|
|
3093
|
-
return self._record_update_public_batch(
|
|
3327
|
+
normalized_items = self._normalize_public_record_insert_batch_items(fields=fields, items=items)
|
|
3328
|
+
return self._record_insert_public_batch(
|
|
3094
3329
|
profile=profile,
|
|
3095
3330
|
app_key=app_key,
|
|
3096
3331
|
items=normalized_items,
|
|
3097
|
-
dry_run=dry_run,
|
|
3098
3332
|
verify_write=verify_write,
|
|
3099
3333
|
output_profile=normalized_output_profile,
|
|
3100
3334
|
)
|
|
3101
|
-
if dry_run:
|
|
3102
|
-
raise_tool_error(QingflowApiError.config_error("dry_run currently requires items"))
|
|
3103
|
-
if record_id is None:
|
|
3104
|
-
raise_tool_error(QingflowApiError.config_error("record_id is required"))
|
|
3105
|
-
record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
|
|
3106
3335
|
if fields is not None and not isinstance(fields, dict):
|
|
3107
3336
|
raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
|
|
3108
|
-
return self.
|
|
3337
|
+
return self._record_insert_public_single(
|
|
3109
3338
|
profile=profile,
|
|
3110
3339
|
app_key=app_key,
|
|
3111
|
-
record_id=record_id_int,
|
|
3112
3340
|
fields=cast(JSONObject, fields or {}),
|
|
3113
3341
|
verify_write=verify_write,
|
|
3114
3342
|
output_profile=normalized_output_profile,
|
|
3343
|
+
capture_exceptions=False,
|
|
3115
3344
|
)
|
|
3116
3345
|
|
|
3117
|
-
def
|
|
3346
|
+
def _record_insert_public_single(
|
|
3347
|
+
self,
|
|
3348
|
+
*,
|
|
3349
|
+
profile: str,
|
|
3350
|
+
app_key: str,
|
|
3351
|
+
fields: JSONObject,
|
|
3352
|
+
verify_write: bool,
|
|
3353
|
+
output_profile: str,
|
|
3354
|
+
capture_exceptions: bool,
|
|
3355
|
+
) -> JSONObject:
|
|
3356
|
+
"""执行内部辅助逻辑。"""
|
|
3357
|
+
submit_type_value = self._normalize_record_write_submit_type("submit")
|
|
3358
|
+
write_attempted = False
|
|
3359
|
+
try:
|
|
3360
|
+
raw_preflight = self._preflight_record_write(
|
|
3361
|
+
profile=profile,
|
|
3362
|
+
operation="create",
|
|
3363
|
+
app_key=app_key,
|
|
3364
|
+
apply_id=None,
|
|
3365
|
+
answers=[],
|
|
3366
|
+
fields=fields,
|
|
3367
|
+
force_refresh_form=False,
|
|
3368
|
+
view_id=None,
|
|
3369
|
+
list_type=None,
|
|
3370
|
+
view_key=None,
|
|
3371
|
+
view_name=None,
|
|
3372
|
+
)
|
|
3373
|
+
preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
|
|
3374
|
+
preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
|
|
3375
|
+
normalized_payload: JSONObject = self._record_write_normalized_payload(
|
|
3376
|
+
operation="insert",
|
|
3377
|
+
record_id=None,
|
|
3378
|
+
record_ids=[],
|
|
3379
|
+
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3380
|
+
submit_type=submit_type_value,
|
|
3381
|
+
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
3382
|
+
)
|
|
3383
|
+
if preflight_data.get("blockers"):
|
|
3384
|
+
return self._record_write_blocked_response(
|
|
3385
|
+
raw_preflight,
|
|
3386
|
+
operation="insert",
|
|
3387
|
+
normalized_payload=normalized_payload,
|
|
3388
|
+
output_profile=output_profile,
|
|
3389
|
+
human_review=False,
|
|
3390
|
+
target_resource={"type": "record", "app_key": app_key, "record_id": None, "record_ids": []},
|
|
3391
|
+
)
|
|
3392
|
+
try:
|
|
3393
|
+
write_attempted = True
|
|
3394
|
+
raw_apply = self.record_create(
|
|
3395
|
+
profile=profile,
|
|
3396
|
+
app_key=app_key,
|
|
3397
|
+
answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3398
|
+
fields={},
|
|
3399
|
+
submit_type=submit_type_value,
|
|
3400
|
+
verify_write=verify_write,
|
|
3401
|
+
force_refresh_form=preflight_used_force_refresh,
|
|
3402
|
+
)
|
|
3403
|
+
except QingflowApiError as exc:
|
|
3404
|
+
self._raise_record_write_permission_error(
|
|
3405
|
+
exc,
|
|
3406
|
+
operation="insert",
|
|
3407
|
+
app_key=app_key,
|
|
3408
|
+
record_id=None,
|
|
3409
|
+
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
3410
|
+
)
|
|
3411
|
+
raise
|
|
3412
|
+
return self._record_write_apply_response(
|
|
3413
|
+
raw_apply,
|
|
3414
|
+
operation="insert",
|
|
3415
|
+
normalized_payload=normalized_payload,
|
|
3416
|
+
output_profile=output_profile,
|
|
3417
|
+
human_review=False,
|
|
3418
|
+
preflight=raw_preflight,
|
|
3419
|
+
)
|
|
3420
|
+
except (QingflowApiError, RuntimeError) as exc:
|
|
3421
|
+
if not capture_exceptions:
|
|
3422
|
+
raise
|
|
3423
|
+
return self._record_write_exception_response(
|
|
3424
|
+
exc,
|
|
3425
|
+
operation="insert",
|
|
3426
|
+
profile=profile,
|
|
3427
|
+
app_key=app_key,
|
|
3428
|
+
record_id=None,
|
|
3429
|
+
output_profile=output_profile,
|
|
3430
|
+
human_review=False,
|
|
3431
|
+
write_executed=write_attempted,
|
|
3432
|
+
)
|
|
3433
|
+
|
|
3434
|
+
def _normalize_public_record_insert_batch_items(
|
|
3435
|
+
self,
|
|
3436
|
+
*,
|
|
3437
|
+
fields: JSONObject | None,
|
|
3438
|
+
items: list[JSONObject] | None,
|
|
3439
|
+
) -> list[JSONObject]:
|
|
3440
|
+
"""执行内部辅助逻辑。"""
|
|
3441
|
+
if fields is not None:
|
|
3442
|
+
raise_tool_error(QingflowApiError.config_error("record_insert batch mode does not accept fields"))
|
|
3443
|
+
if not isinstance(items, list) or not items:
|
|
3444
|
+
raise_tool_error(QingflowApiError.config_error("items must be a non-empty list"))
|
|
3445
|
+
normalized_items: list[JSONObject] = []
|
|
3446
|
+
for index, item in enumerate(items):
|
|
3447
|
+
if not isinstance(item, dict):
|
|
3448
|
+
raise_tool_error(QingflowApiError.config_error(f"items[{index}] must be an object"))
|
|
3449
|
+
item_fields = item.get("fields")
|
|
3450
|
+
if not isinstance(item_fields, dict):
|
|
3451
|
+
raise_tool_error(QingflowApiError.config_error(f"items[{index}].fields must be an object map keyed by field title"))
|
|
3452
|
+
normalized_items.append({"fields": cast(JSONObject, item_fields)})
|
|
3453
|
+
return normalized_items
|
|
3454
|
+
|
|
3455
|
+
def _record_insert_public_batch(
|
|
3456
|
+
self,
|
|
3457
|
+
*,
|
|
3458
|
+
profile: str,
|
|
3459
|
+
app_key: str,
|
|
3460
|
+
items: list[JSONObject],
|
|
3461
|
+
verify_write: bool,
|
|
3462
|
+
output_profile: str,
|
|
3463
|
+
) -> JSONObject:
|
|
3464
|
+
"""执行内部辅助逻辑。"""
|
|
3465
|
+
responses: list[JSONObject] = []
|
|
3466
|
+
for item in items:
|
|
3467
|
+
responses.append(
|
|
3468
|
+
self._record_insert_public_single(
|
|
3469
|
+
profile=profile,
|
|
3470
|
+
app_key=app_key,
|
|
3471
|
+
fields=cast(JSONObject, item["fields"]),
|
|
3472
|
+
verify_write=verify_write,
|
|
3473
|
+
output_profile=output_profile,
|
|
3474
|
+
capture_exceptions=True,
|
|
3475
|
+
)
|
|
3476
|
+
)
|
|
3477
|
+
return self._record_insert_batch_response(
|
|
3478
|
+
profile=profile,
|
|
3479
|
+
app_key=app_key,
|
|
3480
|
+
responses=responses,
|
|
3481
|
+
output_profile=output_profile,
|
|
3482
|
+
)
|
|
3483
|
+
|
|
3484
|
+
def _record_insert_batch_response(
|
|
3485
|
+
self,
|
|
3486
|
+
*,
|
|
3487
|
+
profile: str,
|
|
3488
|
+
app_key: str,
|
|
3489
|
+
responses: list[JSONObject],
|
|
3490
|
+
output_profile: str,
|
|
3491
|
+
) -> JSONObject:
|
|
3492
|
+
"""执行内部辅助逻辑。"""
|
|
3493
|
+
items = [
|
|
3494
|
+
self._record_insert_batch_item_from_response(index=index, response=response, output_profile=output_profile)
|
|
3495
|
+
for index, response in enumerate(responses)
|
|
3496
|
+
]
|
|
3497
|
+
summary = self._record_insert_batch_summary(items)
|
|
3498
|
+
status, ok, message = self._record_insert_batch_envelope_status(summary=summary)
|
|
3499
|
+
first_response = responses[0] if responses else {}
|
|
3500
|
+
created_record_ids = [
|
|
3501
|
+
cast(str, item["record_id"])
|
|
3502
|
+
for item in items
|
|
3503
|
+
if isinstance(item.get("record_id"), str) and item.get("record_id")
|
|
3504
|
+
]
|
|
3505
|
+
write_executed = any(bool(item.get("write_executed")) for item in items)
|
|
3506
|
+
verification_status = self._record_insert_batch_verification_status(items)
|
|
3507
|
+
return {
|
|
3508
|
+
"profile": first_response.get("profile", profile),
|
|
3509
|
+
"ws_id": first_response.get("ws_id"),
|
|
3510
|
+
"ok": ok,
|
|
3511
|
+
"status": status,
|
|
3512
|
+
"mode": "batch",
|
|
3513
|
+
"total": summary["total"],
|
|
3514
|
+
"succeeded": summary["succeeded"],
|
|
3515
|
+
"failed": summary["failed"],
|
|
3516
|
+
"created_record_ids": created_record_ids,
|
|
3517
|
+
"write_executed": write_executed,
|
|
3518
|
+
"verification_status": verification_status,
|
|
3519
|
+
"safe_to_retry": not write_executed,
|
|
3520
|
+
"request_route": first_response.get("request_route"),
|
|
3521
|
+
"warnings": [],
|
|
3522
|
+
"output_profile": output_profile,
|
|
3523
|
+
"items": items,
|
|
3524
|
+
"data": {
|
|
3525
|
+
"app_key": app_key,
|
|
3526
|
+
"mode": "batch",
|
|
3527
|
+
"summary": summary,
|
|
3528
|
+
"created_record_ids": created_record_ids,
|
|
3529
|
+
"items": items,
|
|
3530
|
+
},
|
|
3531
|
+
"message": message,
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
def _record_insert_batch_summary(self, items: list[JSONObject]) -> JSONObject:
|
|
3535
|
+
"""执行内部辅助逻辑。"""
|
|
3536
|
+
created = [item for item in items if isinstance(item.get("record_id"), str) and item.get("record_id")]
|
|
3537
|
+
failed = [item for item in items if item.get("status") not in {"success", "verification_failed"}]
|
|
3538
|
+
return {
|
|
3539
|
+
"total": len(items),
|
|
3540
|
+
"succeeded": len(created),
|
|
3541
|
+
"failed": len(failed),
|
|
3542
|
+
"created_count": len(created),
|
|
3543
|
+
"blocked_count": sum(1 for item in items if item.get("status") == "blocked"),
|
|
3544
|
+
"confirmation_count": sum(1 for item in items if item.get("status") == "needs_confirmation"),
|
|
3545
|
+
"verification_failed_count": sum(1 for item in items if item.get("status") == "verification_failed"),
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
def _record_insert_batch_envelope_status(self, *, summary: JSONObject) -> tuple[str, bool, str]:
|
|
3549
|
+
"""执行内部辅助逻辑。"""
|
|
3550
|
+
succeeded = int(summary["succeeded"])
|
|
3551
|
+
failed = int(summary["failed"])
|
|
3552
|
+
if succeeded and failed:
|
|
3553
|
+
return "partial_success", False, "batch insert completed with partial failures"
|
|
3554
|
+
if succeeded and int(summary["verification_failed_count"]):
|
|
3555
|
+
return "verification_failed", True, "batch insert completed but verification failed for some created records"
|
|
3556
|
+
if succeeded:
|
|
3557
|
+
return "success", True, "batch insert completed"
|
|
3558
|
+
if int(summary["confirmation_count"]):
|
|
3559
|
+
return "needs_confirmation", False, "batch insert requires confirmation before retrying failed rows"
|
|
3560
|
+
if int(summary["blocked_count"]):
|
|
3561
|
+
return "blocked", False, "batch insert preflight blocked all rows"
|
|
3562
|
+
return "failed", False, "batch insert failed"
|
|
3563
|
+
|
|
3564
|
+
def _record_insert_batch_verification_status(self, items: list[JSONObject]) -> str:
|
|
3565
|
+
"""执行内部辅助逻辑。"""
|
|
3566
|
+
statuses = {str(item.get("verification_status") or "not_requested") for item in items}
|
|
3567
|
+
if "failed" in statuses:
|
|
3568
|
+
return "failed"
|
|
3569
|
+
if "verified" in statuses:
|
|
3570
|
+
return "verified"
|
|
3571
|
+
return "not_requested"
|
|
3572
|
+
|
|
3573
|
+
def _record_insert_batch_item_from_response(
|
|
3574
|
+
self,
|
|
3575
|
+
*,
|
|
3576
|
+
index: int,
|
|
3577
|
+
response: JSONObject,
|
|
3578
|
+
output_profile: str,
|
|
3579
|
+
) -> JSONObject:
|
|
3580
|
+
"""执行内部辅助逻辑。"""
|
|
3581
|
+
data = cast(JSONObject, response.get("data", {})) if isinstance(response.get("data"), dict) else {}
|
|
3582
|
+
resource = _public_record_resource(data.get("resource"))
|
|
3583
|
+
record_id = _public_record_id_text(response.get("record_id"))
|
|
3584
|
+
apply_id = _public_record_id_text(response.get("apply_id"))
|
|
3585
|
+
if record_id is None and isinstance(resource, dict):
|
|
3586
|
+
record_id = _public_record_id_text(resource.get("record_id"))
|
|
3587
|
+
if apply_id is None and isinstance(resource, dict):
|
|
3588
|
+
apply_id = _public_record_id_text(resource.get("apply_id"))
|
|
3589
|
+
item: JSONObject = {
|
|
3590
|
+
"index": index,
|
|
3591
|
+
"row_number": index + 1,
|
|
3592
|
+
"status": response.get("status"),
|
|
3593
|
+
"write_executed": bool(response.get("write_executed")),
|
|
3594
|
+
"verification_status": response.get("verification_status", "not_requested"),
|
|
3595
|
+
"safe_to_retry": bool(response.get("safe_to_retry", True)),
|
|
3596
|
+
}
|
|
3597
|
+
if record_id is not None:
|
|
3598
|
+
item["record_id"] = record_id
|
|
3599
|
+
if apply_id is not None:
|
|
3600
|
+
item["apply_id"] = apply_id
|
|
3601
|
+
if resource:
|
|
3602
|
+
item["resource"] = resource
|
|
3603
|
+
verification = data.get("verification")
|
|
3604
|
+
if isinstance(verification, dict):
|
|
3605
|
+
compact_verification = {
|
|
3606
|
+
key: verification[key]
|
|
3607
|
+
for key in ("verified", "verification_mode", "field_level_verified")
|
|
3608
|
+
if key in verification
|
|
3609
|
+
}
|
|
3610
|
+
if compact_verification:
|
|
3611
|
+
item["verification"] = compact_verification
|
|
3612
|
+
field_errors = cast(list[JSONObject], data.get("field_errors", [])) if isinstance(data.get("field_errors"), list) else []
|
|
3613
|
+
confirmation_requests = cast(list[JSONObject], data.get("confirmation_requests", [])) if isinstance(data.get("confirmation_requests"), list) else []
|
|
3614
|
+
failed_fields = self._record_write_failed_fields(field_errors=field_errors, confirmation_requests=confirmation_requests)
|
|
3615
|
+
if failed_fields:
|
|
3616
|
+
item["failed_fields"] = failed_fields
|
|
3617
|
+
if confirmation_requests:
|
|
3618
|
+
item["confirmation_requests"] = [
|
|
3619
|
+
self._record_write_semantic_confirmation_request(request)
|
|
3620
|
+
for request in confirmation_requests
|
|
3621
|
+
if isinstance(request, dict)
|
|
3622
|
+
]
|
|
3623
|
+
blockers = data.get("blockers")
|
|
3624
|
+
if isinstance(blockers, list) and blockers:
|
|
3625
|
+
item["blockers"] = blockers
|
|
3626
|
+
warnings = response.get("warnings")
|
|
3627
|
+
if isinstance(warnings, list) and warnings:
|
|
3628
|
+
item["warnings"] = warnings
|
|
3629
|
+
error = data.get("error")
|
|
3630
|
+
if isinstance(error, dict):
|
|
3631
|
+
item["error"] = error
|
|
3632
|
+
if output_profile == "verbose" and isinstance(data.get("debug"), dict):
|
|
3633
|
+
item["debug"] = data.get("debug")
|
|
3634
|
+
return item
|
|
3635
|
+
|
|
3636
|
+
def _record_write_failed_fields(
|
|
3637
|
+
self,
|
|
3638
|
+
*,
|
|
3639
|
+
field_errors: list[JSONObject],
|
|
3640
|
+
confirmation_requests: list[JSONObject],
|
|
3641
|
+
) -> list[JSONObject]:
|
|
3642
|
+
"""执行内部辅助逻辑。"""
|
|
3643
|
+
failed_fields = [
|
|
3644
|
+
self._record_write_semantic_field_error(error)
|
|
3645
|
+
for error in field_errors
|
|
3646
|
+
if isinstance(error, dict)
|
|
3647
|
+
]
|
|
3648
|
+
failed_fields.extend(
|
|
3649
|
+
self._record_write_failed_field_from_confirmation(request)
|
|
3650
|
+
for request in confirmation_requests
|
|
3651
|
+
if isinstance(request, dict)
|
|
3652
|
+
)
|
|
3653
|
+
return failed_fields
|
|
3654
|
+
|
|
3655
|
+
def _record_write_semantic_field_error(self, error: JSONObject) -> JSONObject:
|
|
3656
|
+
"""执行内部辅助逻辑。"""
|
|
3657
|
+
field = error.get("field")
|
|
3658
|
+
field_payload = cast(JSONObject, field) if isinstance(field, dict) else {}
|
|
3659
|
+
error_code = _normalize_optional_text(error.get("error_code")) or "INVALID_FIELD_VALUE"
|
|
3660
|
+
title = (
|
|
3661
|
+
_normalize_optional_text(field_payload.get("que_title"))
|
|
3662
|
+
or _normalize_optional_text(field_payload.get("title"))
|
|
3663
|
+
or _normalize_optional_text(error.get("location"))
|
|
3664
|
+
or "unknown field"
|
|
3665
|
+
)
|
|
3666
|
+
field_id = (
|
|
3667
|
+
field_payload.get("que_id")
|
|
3668
|
+
if field_payload.get("que_id") is not None
|
|
3669
|
+
else field_payload.get("field_id")
|
|
3670
|
+
)
|
|
3671
|
+
expected_format = error.get("expected_format") if isinstance(error.get("expected_format"), dict) else None
|
|
3672
|
+
if expected_format is None:
|
|
3673
|
+
expected_format = self._record_write_expected_format_from_field_payload(field_payload)
|
|
3674
|
+
payload: JSONObject = {
|
|
3675
|
+
"title": title,
|
|
3676
|
+
"field_id": field_id,
|
|
3677
|
+
"error_code": error_code,
|
|
3678
|
+
"message": self._record_write_semantic_error_message(error_code, error.get("message")),
|
|
3679
|
+
"next_action": self._record_write_next_action_for_error(error_code),
|
|
3680
|
+
}
|
|
3681
|
+
if expected_format is not None:
|
|
3682
|
+
payload["expected_format"] = expected_format
|
|
3683
|
+
payload["example_value"] = self._record_write_example_value_for_format(expected_format, field_payload)
|
|
3684
|
+
if error.get("received_value") is not None:
|
|
3685
|
+
payload["received_value"] = error.get("received_value")
|
|
3686
|
+
if error.get("fix_hint") is not None:
|
|
3687
|
+
payload["fix_hint"] = error.get("fix_hint")
|
|
3688
|
+
if error.get("details") is not None:
|
|
3689
|
+
payload["details"] = error.get("details")
|
|
3690
|
+
return payload
|
|
3691
|
+
|
|
3692
|
+
def _record_write_semantic_confirmation_request(self, request: JSONObject) -> JSONObject:
|
|
3693
|
+
"""执行内部辅助逻辑。"""
|
|
3694
|
+
field_ref = request.get("field_ref")
|
|
3695
|
+
field_payload = cast(JSONObject, field_ref) if isinstance(field_ref, dict) else {}
|
|
3696
|
+
payload: JSONObject = {
|
|
3697
|
+
"field": request.get("field"),
|
|
3698
|
+
"title": _normalize_optional_text(request.get("field")) or _normalize_optional_text(field_payload.get("que_title")),
|
|
3699
|
+
"field_id": field_payload.get("que_id"),
|
|
3700
|
+
"kind": request.get("kind"),
|
|
3701
|
+
"input": request.get("input"),
|
|
3702
|
+
"candidates": request.get("candidates", []),
|
|
3703
|
+
"next_action": "让用户确认候选,或用显式 id/object 只重试本行。",
|
|
3704
|
+
}
|
|
3705
|
+
if request.get("parent_field") is not None:
|
|
3706
|
+
payload["parent_field"] = request.get("parent_field")
|
|
3707
|
+
if request.get("row_ordinal") is not None:
|
|
3708
|
+
payload["row_ordinal"] = request.get("row_ordinal")
|
|
3709
|
+
return payload
|
|
3710
|
+
|
|
3711
|
+
def _record_write_failed_field_from_confirmation(self, request: JSONObject) -> JSONObject:
|
|
3712
|
+
"""执行内部辅助逻辑。"""
|
|
3713
|
+
semantic = self._record_write_semantic_confirmation_request(request)
|
|
3714
|
+
return {
|
|
3715
|
+
"title": semantic.get("title") or semantic.get("field"),
|
|
3716
|
+
"field_id": semantic.get("field_id"),
|
|
3717
|
+
"error_code": "LOOKUP_NEEDS_CONFIRMATION",
|
|
3718
|
+
"message": "候选不唯一,需要用户确认。",
|
|
3719
|
+
"kind": semantic.get("kind"),
|
|
3720
|
+
"input": semantic.get("input"),
|
|
3721
|
+
"candidates": semantic.get("candidates", []),
|
|
3722
|
+
"next_action": semantic.get("next_action"),
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3725
|
+
def _record_write_expected_format_from_field_payload(self, field_payload: JSONObject) -> JSONObject | None:
|
|
3726
|
+
"""执行内部辅助逻辑。"""
|
|
3727
|
+
que_type = _coerce_count(field_payload.get("que_type"))
|
|
3728
|
+
if que_type is None:
|
|
3729
|
+
return None
|
|
3730
|
+
synthetic_field = FormField(
|
|
3731
|
+
que_id=_coerce_count(field_payload.get("que_id")) or 0,
|
|
3732
|
+
que_title=_normalize_optional_text(field_payload.get("que_title")) or _normalize_optional_text(field_payload.get("title")) or "",
|
|
3733
|
+
que_type=que_type,
|
|
3734
|
+
required=False,
|
|
3735
|
+
readonly=False,
|
|
3736
|
+
system=False,
|
|
3737
|
+
options=[],
|
|
3738
|
+
aliases=[],
|
|
3739
|
+
target_app_key=None,
|
|
3740
|
+
target_app_name_hint=None,
|
|
3741
|
+
member_select_scope_type=None,
|
|
3742
|
+
member_select_scope=None,
|
|
3743
|
+
dept_select_scope_type=None,
|
|
3744
|
+
dept_select_scope=None,
|
|
3745
|
+
raw={},
|
|
3746
|
+
)
|
|
3747
|
+
return _write_format_for_field(synthetic_field)
|
|
3748
|
+
|
|
3749
|
+
def _record_write_example_value_for_format(self, expected_format: JSONObject, field_payload: JSONObject) -> JSONValue:
|
|
3750
|
+
"""执行内部辅助逻辑。"""
|
|
3751
|
+
examples = expected_format.get("examples")
|
|
3752
|
+
if isinstance(examples, list) and examples:
|
|
3753
|
+
return cast(JSONValue, examples[0])
|
|
3754
|
+
kind = _normalize_optional_text(expected_format.get("kind"))
|
|
3755
|
+
if kind == "member_list":
|
|
3756
|
+
return "张三"
|
|
3757
|
+
if kind == "department_list":
|
|
3758
|
+
return "直销部"
|
|
3759
|
+
if kind == "relation_record":
|
|
3760
|
+
return {"apply_id": "5001"}
|
|
3761
|
+
if kind == "attachment_list":
|
|
3762
|
+
return {"value": "<file_upload_local 返回的 value/url>", "name": "example.pdf"}
|
|
3763
|
+
if kind == "subtable_rows":
|
|
3764
|
+
return {"rows": [{"子字段": "值"}]}
|
|
3765
|
+
if kind == "date_string":
|
|
3766
|
+
return "2026-03-13 10:00:00"
|
|
3767
|
+
if kind == "boolean_label":
|
|
3768
|
+
return "是"
|
|
3769
|
+
if kind in {"single_select", "multi_select"}:
|
|
3770
|
+
options = expected_format.get("options")
|
|
3771
|
+
if isinstance(options, list) and options:
|
|
3772
|
+
return cast(JSONValue, options[0])
|
|
3773
|
+
que_type = _coerce_count(field_payload.get("que_type"))
|
|
3774
|
+
if que_type in NUMBER_QUE_TYPES:
|
|
3775
|
+
return 100
|
|
3776
|
+
return "文本"
|
|
3777
|
+
|
|
3778
|
+
def _record_write_semantic_error_message(self, error_code: str, fallback: JSONValue) -> str:
|
|
3779
|
+
"""执行内部辅助逻辑。"""
|
|
3780
|
+
if error_code == "MISSING_REQUIRED_FIELD":
|
|
3781
|
+
return "缺少必填字段。"
|
|
3782
|
+
if error_code == "FIELD_NOT_FOUND":
|
|
3783
|
+
return "字段不存在或字段标题不匹配。"
|
|
3784
|
+
if error_code == "AMBIGUOUS_FIELD":
|
|
3785
|
+
return "字段标题存在歧义。"
|
|
3786
|
+
if error_code in {"INVALID_FIELD_VALUE", "INVALID_MEMBER_VALUE", "INVALID_DEPARTMENT_VALUE", "INVALID_RELATION_VALUE"}:
|
|
3787
|
+
return _normalize_optional_text(fallback) or "字段值格式不正确。"
|
|
3788
|
+
return _normalize_optional_text(fallback) or "字段写入失败。"
|
|
3789
|
+
|
|
3790
|
+
def _record_write_next_action_for_error(self, error_code: str) -> str:
|
|
3791
|
+
"""执行内部辅助逻辑。"""
|
|
3792
|
+
if error_code == "MISSING_REQUIRED_FIELD":
|
|
3793
|
+
return "补充该字段后只重试本行。"
|
|
3794
|
+
if error_code in {"FIELD_NOT_FOUND", "AMBIGUOUS_FIELD"}:
|
|
3795
|
+
return "重新调用 schema 工具确认字段标题或 field_id 后,只重试本行。"
|
|
3796
|
+
return "修正该字段值后只重试本行。"
|
|
3797
|
+
|
|
3798
|
+
@tool_cn_name("更新记录")
|
|
3799
|
+
def record_update_public(
|
|
3800
|
+
self,
|
|
3801
|
+
*,
|
|
3802
|
+
profile: str = DEFAULT_PROFILE,
|
|
3803
|
+
app_key: str,
|
|
3804
|
+
record_id: Any | None,
|
|
3805
|
+
fields: JSONObject | None = None,
|
|
3806
|
+
items: list[JSONObject] | None = None,
|
|
3807
|
+
dry_run: bool = False,
|
|
3808
|
+
verify_write: bool = True,
|
|
3809
|
+
output_profile: str = "normal",
|
|
3810
|
+
) -> JSONObject:
|
|
3811
|
+
"""执行记录相关逻辑。"""
|
|
3812
|
+
normalized_output_profile = self._normalize_public_output_profile(output_profile)
|
|
3813
|
+
if not app_key:
|
|
3814
|
+
raise_tool_error(QingflowApiError.config_error("app_key is required"))
|
|
3815
|
+
if items is not None:
|
|
3816
|
+
if dry_run not in {True, False}:
|
|
3817
|
+
raise_tool_error(QingflowApiError.config_error("dry_run must be boolean"))
|
|
3818
|
+
normalized_items = self._normalize_public_record_update_batch_items(
|
|
3819
|
+
record_id=record_id,
|
|
3820
|
+
fields=fields,
|
|
3821
|
+
items=items,
|
|
3822
|
+
)
|
|
3823
|
+
return self._record_update_public_batch(
|
|
3824
|
+
profile=profile,
|
|
3825
|
+
app_key=app_key,
|
|
3826
|
+
items=normalized_items,
|
|
3827
|
+
dry_run=dry_run,
|
|
3828
|
+
verify_write=verify_write,
|
|
3829
|
+
output_profile=normalized_output_profile,
|
|
3830
|
+
)
|
|
3831
|
+
if dry_run:
|
|
3832
|
+
raise_tool_error(QingflowApiError.config_error("dry_run currently requires items"))
|
|
3833
|
+
if record_id is None:
|
|
3834
|
+
raise_tool_error(QingflowApiError.config_error("record_id is required"))
|
|
3835
|
+
record_id_int = normalize_positive_id_int(record_id, field_name="record_id")
|
|
3836
|
+
if fields is not None and not isinstance(fields, dict):
|
|
3837
|
+
raise_tool_error(QingflowApiError.config_error("fields must be an object map keyed by field title"))
|
|
3838
|
+
return self._record_update_public_single(
|
|
3839
|
+
profile=profile,
|
|
3840
|
+
app_key=app_key,
|
|
3841
|
+
record_id=record_id_int,
|
|
3842
|
+
fields=cast(JSONObject, fields or {}),
|
|
3843
|
+
verify_write=verify_write,
|
|
3844
|
+
output_profile=normalized_output_profile,
|
|
3845
|
+
)
|
|
3846
|
+
|
|
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(
|
|
3118
4507
|
self,
|
|
3119
4508
|
*,
|
|
3120
4509
|
profile: str,
|
|
3121
4510
|
app_key: str,
|
|
3122
|
-
|
|
3123
|
-
|
|
4511
|
+
apply_id: int,
|
|
4512
|
+
workflow_node_id: int,
|
|
4513
|
+
answers: list[JSONObject],
|
|
3124
4514
|
verify_write: bool,
|
|
3125
|
-
|
|
4515
|
+
force_refresh_form: bool,
|
|
3126
4516
|
) -> JSONObject:
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
record_ids=[],
|
|
3141
|
-
normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
|
|
3142
|
-
submit_type=1,
|
|
3143
|
-
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
3144
|
-
)
|
|
3145
|
-
if preflight_data.get("blockers"):
|
|
3146
|
-
return self._record_write_blocked_response(
|
|
3147
|
-
raw_preflight,
|
|
3148
|
-
operation="update",
|
|
3149
|
-
normalized_payload=normalized_payload,
|
|
3150
|
-
output_profile=output_profile,
|
|
3151
|
-
human_review=True,
|
|
3152
|
-
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},
|
|
3153
4530
|
)
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
profile=profile,
|
|
4531
|
+
verification = self._verify_record_write_result(
|
|
4532
|
+
context,
|
|
3157
4533
|
app_key=app_key,
|
|
3158
|
-
apply_id=
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
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
|
+
},
|
|
3168
4556
|
operation="update",
|
|
3169
|
-
|
|
3170
|
-
record_id=record_id,
|
|
3171
|
-
selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
|
|
4557
|
+
target="record data",
|
|
3172
4558
|
)
|
|
3173
|
-
|
|
3174
|
-
return self.
|
|
3175
|
-
raw_apply,
|
|
3176
|
-
operation="update",
|
|
3177
|
-
normalized_payload=normalized_payload,
|
|
3178
|
-
output_profile=output_profile,
|
|
3179
|
-
human_review=True,
|
|
3180
|
-
preflight=raw_preflight,
|
|
3181
|
-
)
|
|
4559
|
+
|
|
4560
|
+
return self._run_record_tool(profile, runner)
|
|
3182
4561
|
|
|
3183
4562
|
def _record_update_public_batch(
|
|
3184
4563
|
self,
|
|
@@ -3341,13 +4720,44 @@ class RecordTools(ToolBase):
|
|
|
3341
4720
|
"""执行内部辅助逻辑。"""
|
|
3342
4721
|
summary = self._record_update_batch_summary(responses)
|
|
3343
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)]
|
|
3344
4724
|
status, ok, message = self._record_update_batch_envelope_status(summary=summary, dry_run=dry_run)
|
|
3345
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
|
+
]
|
|
3346
4742
|
return {
|
|
3347
4743
|
"profile": first_response.get("profile", profile),
|
|
3348
4744
|
"ws_id": first_response.get("ws_id"),
|
|
3349
4745
|
"ok": ok,
|
|
3350
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,
|
|
3351
4761
|
"request_route": first_response.get("request_route"),
|
|
3352
4762
|
"warnings": [],
|
|
3353
4763
|
"output_profile": output_profile,
|
|
@@ -3361,6 +4771,31 @@ class RecordTools(ToolBase):
|
|
|
3361
4771
|
"message": message,
|
|
3362
4772
|
}
|
|
3363
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
|
+
|
|
3364
4799
|
def _record_update_batch_summary(self, responses: list[JSONObject]) -> JSONObject:
|
|
3365
4800
|
"""执行内部辅助逻辑。"""
|
|
3366
4801
|
summary: JSONObject = {
|
|
@@ -3421,6 +4856,12 @@ class RecordTools(ToolBase):
|
|
|
3421
4856
|
"confirmation_requests": cast(list[JSONObject], data.get("confirmation_requests", [])),
|
|
3422
4857
|
"resolved_fields": cast(list[JSONObject], data.get("resolved_fields", [])),
|
|
3423
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
|
|
3424
4865
|
blockers = data.get("blockers")
|
|
3425
4866
|
if isinstance(blockers, list) and blockers:
|
|
3426
4867
|
item["blockers"] = blockers
|
|
@@ -4286,6 +5727,11 @@ class RecordTools(ToolBase):
|
|
|
4286
5727
|
delete_ids = [normalize_positive_id_int(record_id, field_name="record_id")]
|
|
4287
5728
|
if not delete_ids:
|
|
4288
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)
|
|
4289
5735
|
normalized_payload = {
|
|
4290
5736
|
"operation": "delete",
|
|
4291
5737
|
"record_id": stringify_backend_id(record_id) if record_id is not None else None,
|
|
@@ -4293,16 +5739,134 @@ class RecordTools(ToolBase):
|
|
|
4293
5739
|
"answers": [],
|
|
4294
5740
|
"submit_type": 1,
|
|
4295
5741
|
}
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
5742
|
+
return self._record_delete_public_batch(
|
|
5743
|
+
profile=profile,
|
|
5744
|
+
app_key=app_key,
|
|
5745
|
+
delete_ids=delete_ids,
|
|
4300
5746
|
normalized_payload=normalized_payload,
|
|
4301
5747
|
output_profile=normalized_output_profile,
|
|
4302
|
-
human_review=True,
|
|
4303
|
-
preflight=None,
|
|
4304
5748
|
)
|
|
4305
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
|
+
|
|
4306
5870
|
@tool_cn_name("写入记录")
|
|
4307
5871
|
def record_write(
|
|
4308
5872
|
self,
|
|
@@ -6861,6 +8425,7 @@ class RecordTools(ToolBase):
|
|
|
6861
8425
|
field_index_override=index,
|
|
6862
8426
|
)
|
|
6863
8427
|
except RecordInputError as error:
|
|
8428
|
+
normalized_answers = list(lookup_resolution.normalized_answers)
|
|
6864
8429
|
invalid_fields.append(
|
|
6865
8430
|
{
|
|
6866
8431
|
"location": _stringify_json(error.details.get("location") if error.details else None),
|
|
@@ -7357,7 +8922,7 @@ class RecordTools(ToolBase):
|
|
|
7357
8922
|
"result": result,
|
|
7358
8923
|
"normalized_answers": normalized_answers,
|
|
7359
8924
|
"status": "completed" if verified else "verification_failed",
|
|
7360
|
-
"ok":
|
|
8925
|
+
"ok": True,
|
|
7361
8926
|
"apply_id": apply_id,
|
|
7362
8927
|
"record_id": apply_id,
|
|
7363
8928
|
"verify_write": verify_write,
|
|
@@ -7561,7 +9126,7 @@ class RecordTools(ToolBase):
|
|
|
7561
9126
|
"result": result,
|
|
7562
9127
|
"normalized_answers": normalized_answers,
|
|
7563
9128
|
"status": "completed" if verified else "verification_failed",
|
|
7564
|
-
"ok":
|
|
9129
|
+
"ok": True,
|
|
7565
9130
|
"verify_write": verify_write,
|
|
7566
9131
|
"write_verified": verified if verify_write else None,
|
|
7567
9132
|
"verification": verification,
|
|
@@ -10100,6 +11665,9 @@ class RecordTools(ToolBase):
|
|
|
10100
11665
|
"ws_id": raw_preflight.get("ws_id"),
|
|
10101
11666
|
"ok": False,
|
|
10102
11667
|
"status": status,
|
|
11668
|
+
"write_executed": False,
|
|
11669
|
+
"verification_status": "not_requested",
|
|
11670
|
+
"safe_to_retry": True,
|
|
10103
11671
|
"request_route": raw_preflight.get("request_route"),
|
|
10104
11672
|
"warnings": warnings,
|
|
10105
11673
|
"output_profile": output_profile,
|
|
@@ -10143,6 +11711,9 @@ class RecordTools(ToolBase):
|
|
|
10143
11711
|
"ws_id": raw_preflight.get("ws_id"),
|
|
10144
11712
|
"ok": True,
|
|
10145
11713
|
"status": "ready",
|
|
11714
|
+
"write_executed": False,
|
|
11715
|
+
"verification_status": "not_requested",
|
|
11716
|
+
"safe_to_retry": True,
|
|
10146
11717
|
"request_route": raw_preflight.get("request_route"),
|
|
10147
11718
|
"warnings": warnings,
|
|
10148
11719
|
"output_profile": output_profile,
|
|
@@ -10185,17 +11756,49 @@ class RecordTools(ToolBase):
|
|
|
10185
11756
|
resolved_fields = cast(list[JSONObject], preflight_data.get("lookup_resolved_fields", []))
|
|
10186
11757
|
if isinstance(verification_warnings, list):
|
|
10187
11758
|
warnings.extend(cast(list[JSONObject], [item for item in verification_warnings if isinstance(item, dict)]))
|
|
11759
|
+
resource = _public_record_resource(raw_apply.get("resource"))
|
|
11760
|
+
record_id = _public_record_id_text(resource.get("record_id")) if isinstance(resource, dict) else None
|
|
11761
|
+
apply_id = _public_record_id_text(resource.get("apply_id")) if isinstance(resource, dict) else None
|
|
11762
|
+
if record_id is None:
|
|
11763
|
+
record_id = _public_record_id_text(raw_apply.get("record_id"))
|
|
11764
|
+
if apply_id is None:
|
|
11765
|
+
apply_id = _public_record_id_text(raw_apply.get("apply_id"))
|
|
11766
|
+
if apply_id is None:
|
|
11767
|
+
apply_id = record_id
|
|
11768
|
+
if record_id is None:
|
|
11769
|
+
record_id = apply_id
|
|
11770
|
+
write_executed = True
|
|
11771
|
+
verification_requested = (
|
|
11772
|
+
raw_apply.get("verify_write") is True
|
|
11773
|
+
or raw_apply.get("write_verified") is not None
|
|
11774
|
+
or isinstance(raw_apply.get("verification"), dict)
|
|
11775
|
+
)
|
|
11776
|
+
if verification_requested:
|
|
11777
|
+
verification_status = "verified" if bool(verification.get("verified")) else "failed"
|
|
11778
|
+
else:
|
|
11779
|
+
verification_status = "not_requested"
|
|
11780
|
+
raw_status = _normalize_optional_text(raw_apply.get("status"))
|
|
11781
|
+
response_status = "verification_failed" if verification_status == "failed" else "success"
|
|
11782
|
+
if not bool(raw_apply.get("ok", True)) and verification_status != "failed":
|
|
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 []
|
|
10188
11786
|
response: JSONObject = {
|
|
10189
11787
|
"profile": raw_apply.get("profile"),
|
|
10190
11788
|
"ws_id": raw_apply.get("ws_id"),
|
|
10191
|
-
"ok": bool(raw_apply.get("ok", True)),
|
|
10192
|
-
"status":
|
|
11789
|
+
"ok": True if verification_status == "failed" and write_executed else bool(raw_apply.get("ok", True)),
|
|
11790
|
+
"status": response_status,
|
|
11791
|
+
"write_executed": write_executed,
|
|
11792
|
+
"verification_status": verification_status,
|
|
11793
|
+
"safe_to_retry": False,
|
|
10193
11794
|
"request_route": raw_apply.get("request_route"),
|
|
10194
11795
|
"warnings": warnings,
|
|
10195
11796
|
"output_profile": output_profile,
|
|
11797
|
+
"update_route": update_route,
|
|
11798
|
+
"tried_routes": tried_routes,
|
|
10196
11799
|
"data": {
|
|
10197
11800
|
"action": {"operation": operation, "executed": True},
|
|
10198
|
-
"resource":
|
|
11801
|
+
"resource": resource,
|
|
10199
11802
|
"verification": raw_apply.get("verification"),
|
|
10200
11803
|
"normalized_payload": normalized_payload,
|
|
10201
11804
|
"blockers": [],
|
|
@@ -10203,8 +11806,14 @@ class RecordTools(ToolBase):
|
|
|
10203
11806
|
"confirmation_requests": [],
|
|
10204
11807
|
"resolved_fields": resolved_fields,
|
|
10205
11808
|
"human_review": self._record_write_human_review_payload(operation, enabled=human_review),
|
|
11809
|
+
"update_route": update_route,
|
|
11810
|
+
"tried_routes": tried_routes,
|
|
10206
11811
|
},
|
|
10207
11812
|
}
|
|
11813
|
+
if record_id is not None:
|
|
11814
|
+
response["record_id"] = record_id
|
|
11815
|
+
if apply_id is not None:
|
|
11816
|
+
response["apply_id"] = apply_id
|
|
10208
11817
|
if output_profile == "verbose":
|
|
10209
11818
|
debug: JSONObject = {
|
|
10210
11819
|
"legacy_result": raw_apply.get("result"),
|
|
@@ -10223,9 +11832,10 @@ class RecordTools(ToolBase):
|
|
|
10223
11832
|
operation: str,
|
|
10224
11833
|
profile: str,
|
|
10225
11834
|
app_key: str,
|
|
10226
|
-
record_id:
|
|
11835
|
+
record_id: Any | None,
|
|
10227
11836
|
output_profile: str,
|
|
10228
11837
|
human_review: bool,
|
|
11838
|
+
write_executed: bool = True,
|
|
10229
11839
|
) -> JSONObject:
|
|
10230
11840
|
"""执行内部辅助逻辑。"""
|
|
10231
11841
|
error_payload: JSONObject = {
|
|
@@ -10266,11 +11876,14 @@ class RecordTools(ToolBase):
|
|
|
10266
11876
|
"ws_id": None,
|
|
10267
11877
|
"ok": False,
|
|
10268
11878
|
"status": "failed",
|
|
11879
|
+
"write_executed": write_executed,
|
|
11880
|
+
"verification_status": "failed" if write_executed else "not_requested",
|
|
11881
|
+
"safe_to_retry": not write_executed,
|
|
10269
11882
|
"request_route": request_route,
|
|
10270
11883
|
"warnings": [],
|
|
10271
11884
|
"output_profile": output_profile,
|
|
10272
11885
|
"data": {
|
|
10273
|
-
"action": {"operation": operation, "executed":
|
|
11886
|
+
"action": {"operation": operation, "executed": write_executed},
|
|
10274
11887
|
"resource": {"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
|
|
10275
11888
|
"verification": None,
|
|
10276
11889
|
"normalized_payload": None,
|
|
@@ -11766,6 +13379,7 @@ class RecordTools(ToolBase):
|
|
|
11766
13379
|
normalized_answers: list[JSONObject],
|
|
11767
13380
|
index: FieldIndex,
|
|
11768
13381
|
verify_list_type: int = DEFAULT_RECORD_LIST_TYPE,
|
|
13382
|
+
verify_view_key: str | None = None,
|
|
11769
13383
|
) -> JSONObject:
|
|
11770
13384
|
"""执行内部辅助逻辑。"""
|
|
11771
13385
|
if apply_id is None:
|
|
@@ -11777,13 +13391,36 @@ class RecordTools(ToolBase):
|
|
|
11777
13391
|
"count_mismatches": [],
|
|
11778
13392
|
}
|
|
11779
13393
|
try:
|
|
11780
|
-
|
|
11781
|
-
|
|
11782
|
-
|
|
11783
|
-
|
|
11784
|
-
|
|
11785
|
-
|
|
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
|
+
)
|
|
11786
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
|
+
}
|
|
11787
13424
|
if exc.backend_code != 40002:
|
|
11788
13425
|
raise
|
|
11789
13426
|
return self._verify_record_write_result_via_initiated_tasks(
|
|
@@ -11847,7 +13484,7 @@ class RecordTools(ToolBase):
|
|
|
11847
13484
|
)
|
|
11848
13485
|
return {
|
|
11849
13486
|
"verified": not missing_fields and not empty_fields and not count_mismatches,
|
|
11850
|
-
"verification_mode": "initiated_record_view",
|
|
13487
|
+
"verification_mode": "custom_view_record_detail" if verify_view_key else "initiated_record_view",
|
|
11851
13488
|
"field_level_verified": True,
|
|
11852
13489
|
"missing_fields": missing_fields,
|
|
11853
13490
|
"empty_fields": empty_fields,
|
|
@@ -12759,6 +14396,13 @@ def _record_access_run_dir() -> Path:
|
|
|
12759
14396
|
return base_dir / run_id
|
|
12760
14397
|
|
|
12761
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
|
+
|
|
12762
14406
|
def _record_access_field_payload(field: FormField) -> JSONObject:
|
|
12763
14407
|
return {
|
|
12764
14408
|
"field_id": field.que_id,
|
|
@@ -13527,6 +15171,159 @@ def _record_detail_log_unavailable_payload(source: str, reason: str) -> JSONObje
|
|
|
13527
15171
|
}
|
|
13528
15172
|
|
|
13529
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
|
+
|
|
13530
15327
|
def _record_detail_log_page_payload(payload: JSONValue, *, normalizer, source: str) -> JSONObject: # type: ignore[no-untyped-def]
|
|
13531
15328
|
items = _record_detail_page_items(payload)
|
|
13532
15329
|
total = _record_detail_page_total(payload)
|
|
@@ -13658,11 +15455,15 @@ def _record_detail_associated_resource(raw: JSONObject) -> JSONObject:
|
|
|
13658
15455
|
view_key = _normalize_optional_text(raw.get("viewKey", raw.get("viewgraphKey")))
|
|
13659
15456
|
chart_key = _normalize_optional_text(raw.get("chartKey", raw.get("chartId")))
|
|
13660
15457
|
is_view = bool(view_key) or graph_type.endswith("view") or graph_type == "view"
|
|
15458
|
+
if is_view and not view_key and chart_key:
|
|
15459
|
+
view_key = chart_key
|
|
15460
|
+
chart_key = None
|
|
13661
15461
|
resource_type = "view" if is_view else "report"
|
|
13662
15462
|
view_type = _normalize_optional_text(raw.get("viewType", raw.get("viewgraphType", raw.get("graphType"))))
|
|
13663
15463
|
data_access = _record_detail_resource_data_access(resource_type=resource_type, view_type=view_type)
|
|
13664
15464
|
return {
|
|
13665
15465
|
"type": resource_type,
|
|
15466
|
+
"resource_type": resource_type,
|
|
13666
15467
|
"name": _normalize_optional_text(raw.get("viewName", raw.get("chartName", raw.get("name", raw.get("title"))))),
|
|
13667
15468
|
"app_key": _normalize_optional_text(raw.get("appKey", raw.get("targetAppKey"))),
|
|
13668
15469
|
"app_name": _normalize_optional_text(raw.get("formTitle", raw.get("appName", raw.get("targetAppName")))),
|
|
@@ -13670,10 +15471,15 @@ def _record_detail_associated_resource(raw: JSONObject) -> JSONObject:
|
|
|
13670
15471
|
"chart_key": chart_key,
|
|
13671
15472
|
"view_type": view_type,
|
|
13672
15473
|
"graph_type": raw.get("graphType"),
|
|
15474
|
+
"report_source": _record_detail_report_source(raw.get("sourceType")) if resource_type == "report" else None,
|
|
13673
15475
|
"data_access": data_access,
|
|
13674
15476
|
}
|
|
13675
15477
|
|
|
13676
15478
|
|
|
15479
|
+
def _record_detail_report_source(source_type: Any) -> str:
|
|
15480
|
+
return "dataset" if str(source_type or "").strip().upper() == "BI_DATASET" else "app"
|
|
15481
|
+
|
|
15482
|
+
|
|
13677
15483
|
def _record_detail_resource_data_access(*, resource_type: str, view_type: str | None) -> JSONObject:
|
|
13678
15484
|
if resource_type == "report":
|
|
13679
15485
|
return {
|
|
@@ -18313,9 +20119,122 @@ def _write_format_for_field(field: FormField) -> JSONObject:
|
|
|
18313
20119
|
return _write_support_payload(support_level="full", kind="boolean_label", examples=["是", "否"])
|
|
18314
20120
|
if field.que_type in DATE_QUE_TYPES:
|
|
18315
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
|
|
18316
20131
|
return _write_support_payload(support_level="full", kind="scalar_text")
|
|
18317
20132
|
|
|
18318
20133
|
|
|
20134
|
+
def _ready_schema_format_hint(kind: str, write_format: JSONObject) -> str:
|
|
20135
|
+
if kind == "member":
|
|
20136
|
+
return "可直接填成员姓名;唯一匹配会自动解析,重名时会返回候选确认。也可传成员 id/value 对象。"
|
|
20137
|
+
if kind == "department":
|
|
20138
|
+
return "可直接填部门名称;唯一匹配会自动解析,重名时会返回候选确认。也可传部门 id/value 对象。"
|
|
20139
|
+
if kind == "relation":
|
|
20140
|
+
return "可传目标记录 apply_id/record_id 对象,也可填目标记录的可搜索文本;多候选时会返回确认。"
|
|
20141
|
+
if kind == "attachment":
|
|
20142
|
+
return "先调用 file_upload_local 上传文件,再写入上传返回的附件对象或 value/name。"
|
|
20143
|
+
if kind == "subtable":
|
|
20144
|
+
return "传 {'rows': [{...}]} 或直接传行对象数组;每行 key 使用子字段标题。"
|
|
20145
|
+
if kind == "address":
|
|
20146
|
+
return "传省/市/区/详细地址对象、地址明细字符串,或后端地址 parts 数组。"
|
|
20147
|
+
if kind == "single_select":
|
|
20148
|
+
return "传 options 中的一个选项文本。"
|
|
20149
|
+
if kind == "multi_select":
|
|
20150
|
+
return "传 options 中的多个选项文本数组。"
|
|
20151
|
+
if kind == "boolean":
|
|
20152
|
+
return "传 '是' 或 '否'。"
|
|
20153
|
+
if kind == "date":
|
|
20154
|
+
return "推荐传 'YYYY-MM-DD HH:MM:SS';只有日期时可传 'YYYY-MM-DD'。"
|
|
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 "传整数或整数字符串;该字段后端不接受小数。"
|
|
20161
|
+
return "传数字或数字字符串。"
|
|
20162
|
+
if kind == "unsupported":
|
|
20163
|
+
reason = _normalize_optional_text(write_format.get("reason"))
|
|
20164
|
+
return reason or "该字段不支持直接写入。"
|
|
20165
|
+
return "传文本值。"
|
|
20166
|
+
|
|
20167
|
+
|
|
20168
|
+
def _ready_schema_example_value(
|
|
20169
|
+
kind: str,
|
|
20170
|
+
field: FormField,
|
|
20171
|
+
write_format: JSONObject,
|
|
20172
|
+
*,
|
|
20173
|
+
row_fields: list[JSONObject],
|
|
20174
|
+
) -> JSONValue:
|
|
20175
|
+
if kind == "member":
|
|
20176
|
+
return "张三"
|
|
20177
|
+
if kind == "department":
|
|
20178
|
+
return "直销部"
|
|
20179
|
+
if kind == "relation":
|
|
20180
|
+
return {"apply_id": "5001"}
|
|
20181
|
+
if kind == "attachment":
|
|
20182
|
+
return {"value": "<file_upload_local 返回的 value/url>", "name": "example.pdf"}
|
|
20183
|
+
if kind == "subtable":
|
|
20184
|
+
row: JSONObject = {}
|
|
20185
|
+
for item in row_fields:
|
|
20186
|
+
if not isinstance(item, dict):
|
|
20187
|
+
continue
|
|
20188
|
+
title = _normalize_optional_text(item.get("title"))
|
|
20189
|
+
if not title:
|
|
20190
|
+
continue
|
|
20191
|
+
row[title] = item.get("example_value", _ready_schema_template_scalar(item.get("kind")))
|
|
20192
|
+
if not row:
|
|
20193
|
+
row = {"子字段": "值"}
|
|
20194
|
+
return {"rows": [row]}
|
|
20195
|
+
if kind == "address":
|
|
20196
|
+
examples = write_format.get("examples")
|
|
20197
|
+
if isinstance(examples, list) and examples:
|
|
20198
|
+
return deepcopy(cast(JSONValue, examples[0]))
|
|
20199
|
+
return {"province": "上海市", "city": "上海市", "district": "闵行区", "detail": "浦江路99号"}
|
|
20200
|
+
if kind == "single_select":
|
|
20201
|
+
return field.options[0] if field.options else "选项A"
|
|
20202
|
+
if kind == "multi_select":
|
|
20203
|
+
return [field.options[0]] if field.options else ["选项A"]
|
|
20204
|
+
if kind == "boolean":
|
|
20205
|
+
return "是"
|
|
20206
|
+
if kind == "date":
|
|
20207
|
+
return "2026-03-13 10:00:00"
|
|
20208
|
+
if kind == "number":
|
|
20209
|
+
return 100
|
|
20210
|
+
if kind == "unsupported":
|
|
20211
|
+
return None
|
|
20212
|
+
return "示例文本"
|
|
20213
|
+
|
|
20214
|
+
|
|
20215
|
+
def _ready_schema_template_scalar(kind: Any) -> JSONValue:
|
|
20216
|
+
normalized = _normalize_optional_text(kind)
|
|
20217
|
+
if normalized == "number":
|
|
20218
|
+
return 100
|
|
20219
|
+
if normalized == "date":
|
|
20220
|
+
return "2026-03-13 10:00:00"
|
|
20221
|
+
if normalized == "boolean":
|
|
20222
|
+
return "是"
|
|
20223
|
+
if normalized == "member":
|
|
20224
|
+
return "张三"
|
|
20225
|
+
if normalized == "department":
|
|
20226
|
+
return "直销部"
|
|
20227
|
+
if normalized == "relation":
|
|
20228
|
+
return {"apply_id": "5001"}
|
|
20229
|
+
if normalized == "multi_select":
|
|
20230
|
+
return ["选项A"]
|
|
20231
|
+
if normalized == "attachment":
|
|
20232
|
+
return {"value": "<file_upload_local 返回的 value/url>", "name": "example.pdf"}
|
|
20233
|
+
if normalized == "address":
|
|
20234
|
+
return {"province": "上海市", "city": "上海市", "district": "闵行区", "detail": "浦江路99号"}
|
|
20235
|
+
return "值"
|
|
20236
|
+
|
|
20237
|
+
|
|
18319
20238
|
def _summarize_write_support(resolved_fields: list[JSONObject]) -> JSONObject:
|
|
18320
20239
|
summary: JSONObject = {
|
|
18321
20240
|
"full": [],
|