@josephyan/qingflow-cli 0.2.0-beta.999 → 1.0.6

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.
Files changed (36) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/src/qingflow_mcp/__init__.py +1 -1
  5. package/src/qingflow_mcp/backend_client.py +109 -0
  6. package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
  7. package/src/qingflow_mcp/builder_facade/models.py +44 -5
  8. package/src/qingflow_mcp/builder_facade/service.py +21 -8
  9. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  10. package/src/qingflow_mcp/cli/commands/app.py +47 -1
  11. package/src/qingflow_mcp/cli/commands/builder.py +7 -0
  12. package/src/qingflow_mcp/cli/commands/exports.py +111 -0
  13. package/src/qingflow_mcp/cli/commands/record.py +20 -0
  14. package/src/qingflow_mcp/cli/commands/task.py +644 -22
  15. package/src/qingflow_mcp/cli/commands/workspace.py +49 -50
  16. package/src/qingflow_mcp/cli/context.py +3 -0
  17. package/src/qingflow_mcp/cli/formatters.py +139 -4
  18. package/src/qingflow_mcp/cli/interaction.py +72 -0
  19. package/src/qingflow_mcp/cli/main.py +2 -0
  20. package/src/qingflow_mcp/cli/terminal_ui.py +55 -9
  21. package/src/qingflow_mcp/errors.py +2 -2
  22. package/src/qingflow_mcp/export_store.py +14 -0
  23. package/src/qingflow_mcp/public_surface.py +6 -0
  24. package/src/qingflow_mcp/response_trim.py +40 -1
  25. package/src/qingflow_mcp/server.py +22 -0
  26. package/src/qingflow_mcp/server_app_builder.py +4 -0
  27. package/src/qingflow_mcp/server_app_user.py +104 -8
  28. package/src/qingflow_mcp/session_store.py +57 -6
  29. package/src/qingflow_mcp/tools/ai_builder_tools.py +59 -16
  30. package/src/qingflow_mcp/tools/auth_tools.py +26 -0
  31. package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
  32. package/src/qingflow_mcp/tools/export_tools.py +1565 -0
  33. package/src/qingflow_mcp/tools/import_tools.py +42 -2
  34. package/src/qingflow_mcp/tools/record_tools.py +515 -45
  35. package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
  36. package/src/qingflow_mcp/tools/task_context_tools.py +26 -8
@@ -0,0 +1,1565 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from copy import deepcopy
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+ from time import monotonic, sleep
9
+ from typing import Any, cast
10
+ from uuid import uuid4
11
+
12
+ from mcp.server.fastmcp import FastMCP
13
+
14
+ from ..backend_client import BackendRequestContext
15
+ from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
16
+ from ..errors import QingflowApiError
17
+ from ..export_store import ExportJobStore
18
+ from ..json_types import JSONObject
19
+ from .base import ToolBase, tool_cn_name
20
+ from .record_tools import (
21
+ AccessibleViewRoute,
22
+ DEFAULT_LIST_PAGE_SIZE,
23
+ FormField,
24
+ LAYOUT_ONLY_QUE_TYPES,
25
+ RecordTools,
26
+ _normalize_public_column_selectors,
27
+ )
28
+
29
+
30
+ EXPORT_STATUS_BY_PROCESS_STATUS = {
31
+ 1: "queued",
32
+ 2: "running",
33
+ 3: "succeeded",
34
+ 4: "failed",
35
+ 5: "failed",
36
+ }
37
+ DEFAULT_EXPORT_TIMEOUT_SECONDS = 60.0
38
+ EXPORT_POLL_INTERVAL_SECONDS = 1.5
39
+ EXPORT_ROWS_LIMIT = 20_000
40
+ EXPORT_CELL_LIMIT = 4_000_000
41
+ _SAFE_FILE_CHARS = re.compile(r"[^0-9A-Za-z._-]+")
42
+ LOCAL_TIMEZONE = datetime.now().astimezone().tzinfo or timezone.utc
43
+
44
+
45
+ class ExportTools(ToolBase):
46
+ def __init__(
47
+ self,
48
+ sessions,
49
+ backend,
50
+ *,
51
+ job_store: ExportJobStore | None = None,
52
+ ) -> None:
53
+ super().__init__(sessions, backend)
54
+ self._record_tools = RecordTools(sessions, backend)
55
+ self._job_store = job_store or ExportJobStore()
56
+
57
+ def register(self, mcp: FastMCP) -> None:
58
+ @mcp.tool(description="Start an asynchronous xlsx export for a Qingflow record view and return an opaque export_handle.")
59
+ def record_export_start(
60
+ profile: str = DEFAULT_PROFILE,
61
+ app_key: str = "",
62
+ view_id: str = "system:all",
63
+ columns: list[JSONObject | int] | None = None,
64
+ where: list[JSONObject] | None = None,
65
+ order_by: list[JSONObject] | None = None,
66
+ record_ids: list[str | int] | None = None,
67
+ include_workflow_log: bool = False,
68
+ ) -> dict[str, Any]:
69
+ return self.record_export_start(
70
+ profile=profile,
71
+ app_key=app_key,
72
+ view_id=view_id,
73
+ columns=columns or [],
74
+ where=where or [],
75
+ order_by=order_by or [],
76
+ record_ids=record_ids or [],
77
+ include_workflow_log=include_workflow_log,
78
+ )
79
+
80
+ @mcp.tool(description="Get asynchronous record export status by opaque export_handle.")
81
+ def record_export_status_get(
82
+ profile: str = DEFAULT_PROFILE,
83
+ export_handle: str = "",
84
+ ) -> dict[str, Any]:
85
+ return self.record_export_status_get(
86
+ profile=profile,
87
+ export_handle=export_handle,
88
+ )
89
+
90
+ @mcp.tool(description="Get completed record export result, returning remote file links and optionally downloading files locally.")
91
+ def record_export_get(
92
+ profile: str = DEFAULT_PROFILE,
93
+ export_handle: str = "",
94
+ download_to_path: str | None = None,
95
+ ) -> dict[str, Any]:
96
+ return self.record_export_get(
97
+ profile=profile,
98
+ export_handle=export_handle,
99
+ download_to_path=download_to_path,
100
+ )
101
+
102
+ @mcp.tool(description="Start a record export, wait for completion, and download the xlsx locally while also returning remote download links.")
103
+ def record_export_direct(
104
+ profile: str = DEFAULT_PROFILE,
105
+ app_key: str = "",
106
+ view_id: str = "system:all",
107
+ columns: list[JSONObject | int] | None = None,
108
+ where: list[JSONObject] | None = None,
109
+ order_by: list[JSONObject] | None = None,
110
+ record_ids: list[str | int] | None = None,
111
+ include_workflow_log: bool = False,
112
+ download_to_path: str | None = None,
113
+ wait_timeout_seconds: float | None = None,
114
+ ) -> dict[str, Any]:
115
+ return self.record_export_direct(
116
+ profile=profile,
117
+ app_key=app_key,
118
+ view_id=view_id,
119
+ columns=columns or [],
120
+ where=where or [],
121
+ order_by=order_by or [],
122
+ record_ids=record_ids or [],
123
+ include_workflow_log=include_workflow_log,
124
+ download_to_path=download_to_path,
125
+ wait_timeout_seconds=wait_timeout_seconds,
126
+ )
127
+
128
+ @tool_cn_name("记录导出启动")
129
+ def record_export_start(
130
+ self,
131
+ *,
132
+ profile: str = DEFAULT_PROFILE,
133
+ app_key: str,
134
+ view_id: str = "system:all",
135
+ columns: list[JSONObject | int] | None = None,
136
+ where: list[JSONObject] | None = None,
137
+ order_by: list[JSONObject] | None = None,
138
+ record_ids: list[str | int] | None = None,
139
+ include_workflow_log: bool = False,
140
+ ) -> dict[str, Any]:
141
+ normalized_app_key = str(app_key or "").strip()
142
+ normalized_view_id = str(view_id or "").strip() or "system:all"
143
+ normalized_columns = _normalize_export_columns(columns or [])
144
+ normalized_where = self._record_tools._normalize_record_list_where(where or [])
145
+ normalized_order_by = self._record_tools._normalize_record_list_order_by(order_by or [])
146
+ normalized_record_ids = _normalize_export_record_ids(record_ids or [])
147
+ if not normalized_app_key:
148
+ return self._failed_export_result(
149
+ error_code="EXPORT_START_FAILED",
150
+ message="app_key is required",
151
+ extra={"view_id": normalized_view_id, "status": "failed"},
152
+ )
153
+
154
+ def runner(session_profile, context):
155
+ resolved_view, compatibility_warnings = self._record_tools._resolve_accessible_view_route(
156
+ profile,
157
+ context,
158
+ normalized_app_key,
159
+ view_id=normalized_view_id,
160
+ list_type=None,
161
+ view_key=None,
162
+ view_name=None,
163
+ allow_default=True,
164
+ )
165
+ export_config, export_config_warnings = self._build_export_config(
166
+ profile=profile,
167
+ context=context,
168
+ app_key=normalized_app_key,
169
+ resolved_view=resolved_view,
170
+ column_selectors=normalized_columns,
171
+ )
172
+ effective_record_ids, row_scope = self._resolve_export_record_scope(
173
+ profile=profile,
174
+ context=context,
175
+ app_key=normalized_app_key,
176
+ resolved_view=resolved_view,
177
+ selected_record_ids=normalized_record_ids,
178
+ where_filters=normalized_where,
179
+ order_by=normalized_order_by,
180
+ select_columns=cast(list[JSONObject], export_config.get("questionExportConfigList") or []),
181
+ )
182
+ result_amount = self._estimate_export_result_amount(
183
+ context,
184
+ app_key=normalized_app_key,
185
+ resolved_view=resolved_view,
186
+ selected_record_ids=effective_record_ids,
187
+ )
188
+ self._validate_export_limits(
189
+ result_amount=result_amount,
190
+ selected_field_count=len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
191
+ include_workflow_log=include_workflow_log,
192
+ )
193
+ filter_bean = self._build_export_filter_bean(
194
+ resolved_view,
195
+ selected_record_ids=effective_record_ids,
196
+ order_by=normalized_order_by,
197
+ row_scope=row_scope,
198
+ include_workflow_log=include_workflow_log,
199
+ )
200
+ started_at = _utc_now().replace(microsecond=0).isoformat()
201
+ socket_result = self.backend.start_socket_record_export(
202
+ context,
203
+ app_key=normalized_app_key,
204
+ view_id=resolved_view.view_id,
205
+ view_key=resolved_view.view_selection.view_key if resolved_view.view_selection is not None else None,
206
+ filter_bean=filter_bean,
207
+ export_config=export_config,
208
+ result_amount=result_amount,
209
+ )
210
+ column_scope = "selected" if normalized_columns else "all"
211
+ export_handle = uuid4().hex
212
+ self._job_store.put(
213
+ export_handle,
214
+ {
215
+ "created_at": started_at,
216
+ "profile": profile,
217
+ "base_url": context.base_url,
218
+ "ws_id": context.ws_id,
219
+ "qf_version": context.qf_version,
220
+ "qf_version_source": context.qf_version_source,
221
+ "app_key": normalized_app_key,
222
+ "view_id": resolved_view.view_id,
223
+ "backend_export_id": str(socket_result.get("backend_export_id") or ""),
224
+ "started_at": started_at,
225
+ "uid": session_profile.uid,
226
+ "row_scope": row_scope,
227
+ "selected_record_count": len(effective_record_ids),
228
+ "field_scope": column_scope,
229
+ "selected_field_count": len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
230
+ "include_workflow_log": bool(include_workflow_log),
231
+ },
232
+ )
233
+ warnings = [
234
+ *deepcopy(compatibility_warnings),
235
+ *deepcopy(export_config_warnings),
236
+ *deepcopy(cast(list[JSONObject], socket_result.get("warnings") or [])),
237
+ ]
238
+ return {
239
+ "ok": True,
240
+ "status": "accepted",
241
+ "app_key": normalized_app_key,
242
+ "view_id": resolved_view.view_id,
243
+ "export_handle": export_handle,
244
+ "row_scope": row_scope,
245
+ "selected_record_count": len(effective_record_ids),
246
+ "field_scope": column_scope,
247
+ "selected_field_count": len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
248
+ "include_workflow_log": bool(include_workflow_log),
249
+ "file_urls": [],
250
+ "file_names": [],
251
+ "downloaded_files": [],
252
+ "warnings": warnings,
253
+ "verification": {
254
+ "export_acknowledged": bool(socket_result.get("backend_export_id")),
255
+ "view_route_supported": True,
256
+ "row_selection_applied": bool(effective_record_ids),
257
+ "query_filter_applied": bool(normalized_where),
258
+ "query_sort_applied": bool(normalized_order_by),
259
+ "field_selection_applied": bool(normalized_columns),
260
+ },
261
+ "request_route": self.backend.describe_route(context),
262
+ }
263
+
264
+ try:
265
+ return self._run(profile, runner)
266
+ except RuntimeError as exc:
267
+ return self._runtime_error_as_result(
268
+ exc,
269
+ error_code="EXPORT_START_FAILED",
270
+ extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
271
+ )
272
+
273
+ @tool_cn_name("记录导出状态")
274
+ def record_export_status_get(
275
+ self,
276
+ *,
277
+ profile: str = DEFAULT_PROFILE,
278
+ export_handle: str,
279
+ ) -> dict[str, Any]:
280
+ normalized_handle = str(export_handle or "").strip()
281
+ if not normalized_handle:
282
+ return self._failed_export_result(
283
+ error_code="CONFIG_ERROR",
284
+ message="export_handle is required",
285
+ extra={"status": "failed"},
286
+ )
287
+
288
+ def runner(session_profile, context):
289
+ local_job = self._job_store.get(normalized_handle)
290
+ if local_job is None:
291
+ return self._failed_export_result(
292
+ error_code="EXPORT_HANDLE_UNKNOWN",
293
+ message="export_handle is missing or expired",
294
+ extra={"export_handle": normalized_handle, "status": "failed"},
295
+ )
296
+ lookup_context = self._build_export_lookup_context(
297
+ profile=profile,
298
+ session_profile=session_profile,
299
+ current_context=context,
300
+ local_job=local_job,
301
+ )
302
+ snapshot = self._resolve_export_snapshot(lookup_context, local_job)
303
+ return self._status_payload_from_snapshot(local_job, normalized_handle, snapshot)
304
+
305
+ try:
306
+ return self._run(profile, runner)
307
+ except RuntimeError as exc:
308
+ return self._runtime_error_as_result(
309
+ exc,
310
+ error_code="EXPORT_STATUS_FAILED",
311
+ extra={"export_handle": normalized_handle},
312
+ )
313
+
314
+ @tool_cn_name("记录导出结果")
315
+ def record_export_get(
316
+ self,
317
+ *,
318
+ profile: str = DEFAULT_PROFILE,
319
+ export_handle: str,
320
+ download_to_path: str | None = None,
321
+ ) -> dict[str, Any]:
322
+ normalized_handle = str(export_handle or "").strip()
323
+ if not normalized_handle:
324
+ return self._failed_export_result(
325
+ error_code="CONFIG_ERROR",
326
+ message="export_handle is required",
327
+ extra={"status": "failed"},
328
+ )
329
+
330
+ def runner(session_profile, context):
331
+ local_job = self._job_store.get(normalized_handle)
332
+ if local_job is None:
333
+ return self._failed_export_result(
334
+ error_code="EXPORT_HANDLE_UNKNOWN",
335
+ message="export_handle is missing or expired",
336
+ extra={"export_handle": normalized_handle, "status": "failed"},
337
+ )
338
+ lookup_context = self._build_export_lookup_context(
339
+ profile=profile,
340
+ session_profile=session_profile,
341
+ current_context=context,
342
+ local_job=local_job,
343
+ )
344
+ snapshot = self._resolve_export_snapshot(lookup_context, local_job)
345
+ normalized_status = str(snapshot.get("status") or "unknown")
346
+ if normalized_status not in {"succeeded", "failed"}:
347
+ return self._failed_export_result(
348
+ error_code="EXPORT_NOT_READY",
349
+ message="export is not ready yet",
350
+ extra={
351
+ "status": "blocked",
352
+ "export_handle": normalized_handle,
353
+ "app_key": str(local_job.get("app_key") or ""),
354
+ "view_id": str(local_job.get("view_id") or ""),
355
+ "row_scope": str(local_job.get("row_scope") or "all"),
356
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
357
+ "field_scope": str(local_job.get("field_scope") or "all"),
358
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
359
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
360
+ "file_urls": snapshot.get("file_urls") or [],
361
+ "file_names": snapshot.get("file_names") or [],
362
+ "downloaded_files": [],
363
+ "warnings": snapshot.get("warnings") or [],
364
+ "verification": snapshot.get("verification") or {},
365
+ },
366
+ )
367
+ if normalized_status == "failed":
368
+ return {
369
+ **self._failed_export_result(
370
+ error_code=str(snapshot.get("error_code") or "EXPORT_FAILED"),
371
+ message=str(snapshot.get("message") or "export failed"),
372
+ extra={
373
+ "status": "failed",
374
+ "export_handle": normalized_handle,
375
+ "app_key": str(local_job.get("app_key") or ""),
376
+ "view_id": str(local_job.get("view_id") or ""),
377
+ "row_scope": str(local_job.get("row_scope") or "all"),
378
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
379
+ "field_scope": str(local_job.get("field_scope") or "all"),
380
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
381
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
382
+ "file_urls": snapshot.get("file_urls") or [],
383
+ "file_names": snapshot.get("file_names") or [],
384
+ "downloaded_files": [],
385
+ "warnings": snapshot.get("warnings") or [],
386
+ "verification": snapshot.get("verification") or {},
387
+ "num": snapshot.get("num"),
388
+ "process_status": snapshot.get("process_status"),
389
+ "audit_record_status": snapshot.get("audit_record_status"),
390
+ },
391
+ ),
392
+ }
393
+ file_infos = cast(list[JSONObject], snapshot.get("file_infos") or [])
394
+ if not file_infos:
395
+ return self._failed_export_result(
396
+ error_code="EXPORT_FILE_UNAVAILABLE",
397
+ message="export completed but did not return downloadable files",
398
+ extra={
399
+ "status": "failed",
400
+ "export_handle": normalized_handle,
401
+ "app_key": str(local_job.get("app_key") or ""),
402
+ "view_id": str(local_job.get("view_id") or ""),
403
+ "warnings": snapshot.get("warnings") or [],
404
+ "verification": snapshot.get("verification") or {},
405
+ },
406
+ )
407
+ downloaded_files = self._download_export_files(
408
+ file_infos=file_infos,
409
+ download_to_path=download_to_path,
410
+ default_directory=None,
411
+ app_key=str(local_job.get("app_key") or ""),
412
+ view_id=str(local_job.get("view_id") or ""),
413
+ )
414
+ return {
415
+ "ok": True,
416
+ "status": "succeeded",
417
+ "export_handle": normalized_handle,
418
+ "app_key": str(local_job.get("app_key") or ""),
419
+ "view_id": str(local_job.get("view_id") or ""),
420
+ "row_scope": str(local_job.get("row_scope") or "all"),
421
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
422
+ "field_scope": str(local_job.get("field_scope") or "all"),
423
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
424
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
425
+ "num": snapshot.get("num"),
426
+ "process_status": snapshot.get("process_status"),
427
+ "error_code": snapshot.get("error_code"),
428
+ "audit_record_status": snapshot.get("audit_record_status"),
429
+ "file_urls": snapshot.get("file_urls") or [],
430
+ "file_names": snapshot.get("file_names") or [],
431
+ "downloaded_files": downloaded_files,
432
+ "warnings": snapshot.get("warnings") or [],
433
+ "verification": snapshot.get("verification") or {},
434
+ "request_route": self.backend.describe_route(lookup_context),
435
+ }
436
+
437
+ try:
438
+ return self._run(profile, runner)
439
+ except RuntimeError as exc:
440
+ return self._runtime_error_as_result(
441
+ exc,
442
+ error_code="EXPORT_GET_FAILED",
443
+ extra={"export_handle": normalized_handle},
444
+ )
445
+
446
+ @tool_cn_name("记录直接导出")
447
+ def record_export_direct(
448
+ self,
449
+ *,
450
+ profile: str = DEFAULT_PROFILE,
451
+ app_key: str,
452
+ view_id: str = "system:all",
453
+ columns: list[JSONObject | int] | None = None,
454
+ where: list[JSONObject] | None = None,
455
+ order_by: list[JSONObject] | None = None,
456
+ record_ids: list[str | int] | None = None,
457
+ include_workflow_log: bool = False,
458
+ download_to_path: str | None = None,
459
+ wait_timeout_seconds: float | None = None,
460
+ ) -> dict[str, Any]:
461
+ normalized_app_key = str(app_key or "").strip()
462
+ normalized_view_id = str(view_id or "").strip() or "system:all"
463
+ if not normalized_app_key:
464
+ return self._failed_export_result(
465
+ error_code="EXPORT_START_FAILED",
466
+ message="app_key is required",
467
+ extra={"status": "failed", "view_id": normalized_view_id},
468
+ )
469
+ timeout_seconds = self._normalize_timeout_seconds(wait_timeout_seconds)
470
+
471
+ def runner(session_profile, context):
472
+ start_result = self.record_export_start(
473
+ profile=profile,
474
+ app_key=normalized_app_key,
475
+ view_id=normalized_view_id,
476
+ columns=columns or [],
477
+ where=where or [],
478
+ order_by=order_by or [],
479
+ record_ids=record_ids or [],
480
+ include_workflow_log=include_workflow_log,
481
+ )
482
+ if not bool(start_result.get("ok")):
483
+ return start_result
484
+ export_handle = str(start_result.get("export_handle") or "")
485
+ deadline = monotonic() + timeout_seconds
486
+ last_snapshot: dict[str, Any] | None = None
487
+ while monotonic() < deadline:
488
+ local_job = self._job_store.get(export_handle)
489
+ if local_job is None:
490
+ return self._failed_export_result(
491
+ error_code="EXPORT_HANDLE_UNKNOWN",
492
+ message="export_handle is missing or expired",
493
+ extra={"status": "failed", "export_handle": export_handle},
494
+ )
495
+ lookup_context = self._build_export_lookup_context(
496
+ profile=profile,
497
+ session_profile=session_profile,
498
+ current_context=context,
499
+ local_job=local_job,
500
+ )
501
+ snapshot = self._resolve_export_snapshot(lookup_context, local_job)
502
+ last_snapshot = snapshot
503
+ normalized_status = str(snapshot.get("status") or "unknown")
504
+ if normalized_status == "succeeded":
505
+ effective_download_path = download_to_path or str(Path.cwd())
506
+ get_result = self.record_export_get(
507
+ profile=profile,
508
+ export_handle=export_handle,
509
+ download_to_path=effective_download_path,
510
+ )
511
+ if bool(get_result.get("ok")):
512
+ return get_result
513
+ return {
514
+ **get_result,
515
+ "export_handle": export_handle,
516
+ "file_urls": snapshot.get("file_urls") or [],
517
+ "file_names": snapshot.get("file_names") or [],
518
+ }
519
+ if normalized_status == "failed":
520
+ return {
521
+ "ok": False,
522
+ "status": "failed",
523
+ "error_code": str(snapshot.get("error_code") or "EXPORT_FAILED"),
524
+ "message": str(snapshot.get("message") or "export failed"),
525
+ "export_handle": export_handle,
526
+ "app_key": str(local_job.get("app_key") or ""),
527
+ "view_id": str(local_job.get("view_id") or ""),
528
+ "row_scope": str(local_job.get("row_scope") or "all"),
529
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
530
+ "field_scope": str(local_job.get("field_scope") or "all"),
531
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
532
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
533
+ "num": snapshot.get("num"),
534
+ "process_status": snapshot.get("process_status"),
535
+ "audit_record_status": snapshot.get("audit_record_status"),
536
+ "file_urls": snapshot.get("file_urls") or [],
537
+ "file_names": snapshot.get("file_names") or [],
538
+ "downloaded_files": [],
539
+ "warnings": snapshot.get("warnings") or [],
540
+ "verification": snapshot.get("verification") or {},
541
+ }
542
+ if normalized_status == "unknown":
543
+ warning_codes = {
544
+ str(item.get("code") or "")
545
+ for item in cast(list[JSONObject], snapshot.get("warnings") or [])
546
+ if isinstance(item, dict)
547
+ }
548
+ if "EXPORT_HISTORY_AMBIGUOUS" in warning_codes:
549
+ return {
550
+ "ok": True,
551
+ "status": "unknown",
552
+ "export_handle": export_handle,
553
+ "app_key": str(local_job.get("app_key") or ""),
554
+ "view_id": str(local_job.get("view_id") or ""),
555
+ "row_scope": str(local_job.get("row_scope") or "all"),
556
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
557
+ "field_scope": str(local_job.get("field_scope") or "all"),
558
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
559
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
560
+ "file_urls": snapshot.get("file_urls") or [],
561
+ "file_names": snapshot.get("file_names") or [],
562
+ "downloaded_files": [],
563
+ "warnings": snapshot.get("warnings") or [],
564
+ "verification": snapshot.get("verification") or {},
565
+ "message": "export result could not be matched uniquely",
566
+ }
567
+ remaining = deadline - monotonic()
568
+ if remaining <= 0:
569
+ break
570
+ sleep(min(EXPORT_POLL_INTERVAL_SECONDS, remaining))
571
+ timeout_status = "running"
572
+ if isinstance(last_snapshot, dict) and str(last_snapshot.get("status") or "").strip():
573
+ timeout_status = str(last_snapshot.get("status") or timeout_status)
574
+ warnings = list(cast(list[JSONObject], start_result.get("warnings") or []))
575
+ warnings.append(
576
+ {
577
+ "code": "EXPORT_WAIT_TIMEOUT",
578
+ "message": "export is still running; reuse export_handle with record_export_status_get or record_export_get",
579
+ }
580
+ )
581
+ return {
582
+ "ok": True,
583
+ "status": timeout_status,
584
+ "export_handle": str(start_result.get("export_handle") or ""),
585
+ "app_key": normalized_app_key,
586
+ "view_id": str(start_result.get("view_id") or normalized_view_id),
587
+ "row_scope": start_result.get("row_scope") or ("selected" if record_ids else "all"),
588
+ "selected_record_count": start_result.get("selected_record_count") or 0,
589
+ "field_scope": start_result.get("field_scope") or ("selected" if columns else "all"),
590
+ "selected_field_count": start_result.get("selected_field_count"),
591
+ "include_workflow_log": start_result.get("include_workflow_log"),
592
+ "file_urls": (last_snapshot or {}).get("file_urls") or [],
593
+ "file_names": (last_snapshot or {}).get("file_names") or [],
594
+ "downloaded_files": [],
595
+ "warnings": warnings,
596
+ "verification": (last_snapshot or {}).get("verification") or start_result.get("verification") or {},
597
+ "message": "export did not finish before wait_timeout_seconds",
598
+ }
599
+
600
+ try:
601
+ return self._run(profile, runner)
602
+ except RuntimeError as exc:
603
+ return self._runtime_error_as_result(
604
+ exc,
605
+ error_code="EXPORT_DIRECT_FAILED",
606
+ extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
607
+ )
608
+
609
+ def _build_export_lookup_context(
610
+ self,
611
+ *,
612
+ profile: str,
613
+ session_profile,
614
+ current_context: BackendRequestContext,
615
+ local_job: dict[str, Any],
616
+ ) -> BackendRequestContext:
617
+ stored_profile = str(local_job.get("profile") or "").strip()
618
+ if stored_profile and stored_profile != profile:
619
+ raise QingflowApiError.config_error(
620
+ "export_handle was created under a different profile",
621
+ details={
622
+ "error_code": "EXPORT_HANDLE_PROFILE_MISMATCH",
623
+ "expected_profile": stored_profile,
624
+ "received_profile": profile,
625
+ },
626
+ )
627
+ stored_uid = _coerce_positive_int(local_job.get("uid"))
628
+ if stored_uid is not None and stored_uid != session_profile.uid:
629
+ raise QingflowApiError.config_error(
630
+ "export_handle belongs to a different authenticated user",
631
+ details={
632
+ "error_code": "EXPORT_HANDLE_OWNER_MISMATCH",
633
+ "expected_uid": stored_uid,
634
+ "current_uid": session_profile.uid,
635
+ },
636
+ )
637
+ stored_base_url = str(local_job.get("base_url") or "").strip() or current_context.base_url
638
+ stored_ws_id = _coerce_positive_int(local_job.get("ws_id"))
639
+ stored_qf_version = str(local_job.get("qf_version") or "").strip() or current_context.qf_version
640
+ stored_qf_version_source = (
641
+ str(local_job.get("qf_version_source") or "").strip() or current_context.qf_version_source
642
+ )
643
+ return BackendRequestContext(
644
+ base_url=stored_base_url,
645
+ token=current_context.token,
646
+ ws_id=stored_ws_id if stored_ws_id is not None else current_context.ws_id,
647
+ qf_request_id=current_context.qf_request_id,
648
+ qf_version=stored_qf_version,
649
+ qf_version_source=stored_qf_version_source,
650
+ )
651
+
652
+ def _resolve_export_record_scope(
653
+ self,
654
+ *,
655
+ profile: str,
656
+ context,
657
+ app_key: str,
658
+ resolved_view: AccessibleViewRoute,
659
+ selected_record_ids: list[int],
660
+ where_filters: list[JSONObject],
661
+ order_by: list[JSONObject],
662
+ select_columns: list[JSONObject],
663
+ ) -> tuple[list[int], str]:
664
+ if selected_record_ids and (where_filters or order_by):
665
+ raise QingflowApiError(
666
+ category="config",
667
+ message="record export does not allow record_ids together with query selectors",
668
+ details={
669
+ "error_code": "EXPORT_ROW_SCOPE_CONFLICT",
670
+ "fix_hint": "Use record_ids for explicit selected rows, or use where/order_by for internal query selection, but not both.",
671
+ },
672
+ )
673
+ if selected_record_ids:
674
+ return selected_record_ids, "selected"
675
+ if not where_filters and not order_by:
676
+ return [], "all"
677
+ resolved_ids = self._collect_record_ids_from_query(
678
+ profile=profile,
679
+ context=context,
680
+ app_key=app_key,
681
+ resolved_view=resolved_view,
682
+ where_filters=where_filters,
683
+ order_by=order_by,
684
+ select_columns=select_columns,
685
+ )
686
+ if not resolved_ids:
687
+ raise QingflowApiError.config_error(
688
+ "record export query did not match any records",
689
+ details={
690
+ "error_code": "EXPORT_NO_MATCHED_RECORDS",
691
+ "view_id": resolved_view.view_id,
692
+ },
693
+ )
694
+ return resolved_ids, "queried"
695
+
696
+ def _collect_record_ids_from_query(
697
+ self,
698
+ *,
699
+ profile: str,
700
+ context,
701
+ app_key: str,
702
+ resolved_view: AccessibleViewRoute,
703
+ where_filters: list[JSONObject],
704
+ order_by: list[JSONObject],
705
+ select_columns: list[JSONObject],
706
+ ) -> list[int]:
707
+ browse_scope = self._record_tools._build_browse_write_scope(
708
+ profile,
709
+ context,
710
+ app_key,
711
+ resolved_view,
712
+ force_refresh=False,
713
+ )
714
+ index = browse_scope["index"]
715
+ match_rules = self._record_tools._resolve_match_rules(context, where_filters, index)
716
+ query_sorts = [
717
+ {
718
+ "queId": _coerce_int(item.get("field_id")),
719
+ "direction": str(item.get("direction") or "asc"),
720
+ }
721
+ for item in order_by
722
+ if isinstance(item, dict) and _coerce_int(item.get("field_id")) is not None
723
+ ]
724
+ dept_member_cache: dict[int, set[int]] = {}
725
+ current_page = 1
726
+ selected_ids: list[int] = []
727
+ seen: set[int] = set()
728
+ primary_field_ids = [
729
+ _coerce_int(item.get("queId"))
730
+ for item in select_columns
731
+ if isinstance(item, dict)
732
+ ]
733
+ primary_search_que_ids = [item for item in primary_field_ids if item is not None][:1] or None
734
+
735
+ while True:
736
+ page = self._record_tools._search_page(
737
+ context,
738
+ app_key=app_key,
739
+ view_selection=resolved_view.view_selection,
740
+ page_num=current_page,
741
+ page_size=DEFAULT_LIST_PAGE_SIZE,
742
+ query_key=None,
743
+ match_rules=match_rules,
744
+ sorts=cast(list[JSONObject], query_sorts),
745
+ search_que_ids=primary_search_que_ids,
746
+ list_type=resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE,
747
+ )
748
+ raw_rows = page.get("list")
749
+ items = raw_rows if isinstance(raw_rows, list) else []
750
+ for item in items:
751
+ if not isinstance(item, dict):
752
+ continue
753
+ answers = item.get("answers")
754
+ answer_list = answers if isinstance(answers, list) else []
755
+ if not self._record_tools._matches_view_selection(
756
+ context,
757
+ answer_list,
758
+ view_selection=resolved_view.view_selection,
759
+ dept_member_cache=dept_member_cache,
760
+ ):
761
+ continue
762
+ record_id = _coerce_int(item.get("applyId"))
763
+ if record_id is None:
764
+ record_id = _coerce_int(item.get("apply_id"))
765
+ if record_id is None:
766
+ record_id = _coerce_int(item.get("id"))
767
+ if record_id is None:
768
+ record_id = _coerce_int(item.get("record_id"))
769
+ if record_id is None or record_id in seen or record_id <= 0:
770
+ continue
771
+ seen.add(record_id)
772
+ selected_ids.append(record_id)
773
+ if len(selected_ids) > EXPORT_ROWS_LIMIT:
774
+ raise QingflowApiError.config_error(
775
+ f"record export exceeds the native row limit of {EXPORT_ROWS_LIMIT}",
776
+ details={
777
+ "error_code": "EXPORT_ROWS_LIMIT_EXCEEDED",
778
+ "result_amount": len(selected_ids),
779
+ "row_limit": EXPORT_ROWS_LIMIT,
780
+ },
781
+ )
782
+ if current_page == 1:
783
+ reported_total = _effective_total(page, page_size=DEFAULT_LIST_PAGE_SIZE)
784
+ if reported_total > EXPORT_ROWS_LIMIT:
785
+ raise QingflowApiError.config_error(
786
+ f"record export exceeds the native row limit of {EXPORT_ROWS_LIMIT}",
787
+ details={
788
+ "error_code": "EXPORT_ROWS_LIMIT_EXCEEDED",
789
+ "result_amount": reported_total,
790
+ "row_limit": EXPORT_ROWS_LIMIT,
791
+ },
792
+ )
793
+ if not _page_has_more(page, current_page=current_page, page_size=DEFAULT_LIST_PAGE_SIZE, returned_rows=len(items)):
794
+ break
795
+ current_page += 1
796
+ return selected_ids
797
+
798
+ def _build_export_filter_bean(
799
+ self,
800
+ resolved_view: AccessibleViewRoute,
801
+ *,
802
+ selected_record_ids: list[int],
803
+ order_by: list[JSONObject],
804
+ row_scope: str,
805
+ include_workflow_log: bool,
806
+ ) -> JSONObject:
807
+ filter_payload: JSONObject = {}
808
+ if resolved_view.kind == "system" and resolved_view.list_type is not None:
809
+ filter_payload["type"] = resolved_view.list_type
810
+ elif resolved_view.kind == "custom":
811
+ # Custom-view native export later flows through shared data-export code that
812
+ # expects a list semantics integer. Frontend export treats custom views as
813
+ # creator-all style export, so mirror LIST_CREATOR_ALL here.
814
+ filter_payload["type"] = DEFAULT_RECORD_LIST_TYPE
815
+ if selected_record_ids:
816
+ filter_payload["applyIds"] = selected_record_ids
817
+ if row_scope == "queried" and order_by:
818
+ normalized_sorts = [
819
+ {
820
+ "queId": field_id,
821
+ "isAscend": str(item.get("direction") or "asc").strip().lower() != "desc",
822
+ }
823
+ for item in order_by
824
+ if isinstance(item, dict) and (field_id := _coerce_int(item.get("field_id"))) is not None
825
+ ]
826
+ if normalized_sorts:
827
+ filter_payload["sorts"] = normalized_sorts
828
+ return {
829
+ "filter": filter_payload,
830
+ # Backend export code later auto-unboxes this field to primitive boolean.
831
+ # Always send an explicit boolean to avoid a null -> NPE -> 41100 failure path.
832
+ "auditRecordStatus": bool(include_workflow_log),
833
+ }
834
+
835
+ def _build_export_config(
836
+ self,
837
+ *,
838
+ profile: str,
839
+ context,
840
+ app_key: str,
841
+ resolved_view: AccessibleViewRoute,
842
+ column_selectors: list[int],
843
+ ) -> tuple[JSONObject, list[JSONObject]]: # type: ignore[no-untyped-def]
844
+ browse_scope = self._record_tools._build_browse_write_scope(
845
+ profile,
846
+ context,
847
+ app_key,
848
+ resolved_view,
849
+ force_refresh=False,
850
+ )
851
+ index = browse_scope["index"]
852
+ visible_question_ids = cast(set[int], browse_scope.get("visible_question_ids") or set())
853
+ ordered_visible_fields, warnings = self._resolve_exportable_fields(
854
+ profile=profile,
855
+ context=context,
856
+ app_key=app_key,
857
+ resolved_view=resolved_view,
858
+ index=index,
859
+ visible_question_ids=visible_question_ids,
860
+ )
861
+ if not ordered_visible_fields:
862
+ ordered_visible_fields = [
863
+ field
864
+ for field in cast(Any, index).by_id.values()
865
+ if field.que_type not in LAYOUT_ONLY_QUE_TYPES
866
+ ]
867
+ exportable_by_id = {field.que_id: field for field in ordered_visible_fields}
868
+ selected_fields: list[Any]
869
+ if column_selectors:
870
+ selected_fields = []
871
+ seen: set[int] = set()
872
+ for selector in column_selectors:
873
+ field = self._record_tools._resolve_field_selector(selector, index, location="export_columns")
874
+ if field.que_id in seen:
875
+ continue
876
+ exportable_field = exportable_by_id.get(field.que_id)
877
+ if exportable_field is None:
878
+ raise QingflowApiError.config_error(
879
+ f"field '{field.que_title}' is not exportable in the selected view",
880
+ details={
881
+ "error_code": "EXPORT_FIELD_NOT_VISIBLE",
882
+ "field_id": field.que_id,
883
+ "view_id": resolved_view.view_id,
884
+ },
885
+ )
886
+ selected_fields.append(exportable_field)
887
+ seen.add(field.que_id)
888
+ else:
889
+ selected_fields = ordered_visible_fields
890
+ question_export_config_list: list[JSONObject] = []
891
+ for field in selected_fields:
892
+ if field.que_type is None:
893
+ warnings.append(
894
+ {
895
+ "code": "EXPORT_FIELD_TYPE_UNAVAILABLE",
896
+ "message": f"Skipped field '{field.que_title}' because its field type could not be resolved.",
897
+ }
898
+ )
899
+ continue
900
+ question_export_config_list.append(
901
+ {
902
+ "queId": field.que_id,
903
+ "queTitle": field.que_title,
904
+ "queType": field.que_type,
905
+ "exportStyle": "default",
906
+ }
907
+ )
908
+ if not question_export_config_list:
909
+ raise QingflowApiError.config_error(
910
+ "record export could not determine exportable fields for the selected view",
911
+ details={"error_code": "EXPORT_CONFIG_UNAVAILABLE"},
912
+ )
913
+ return {"questionExportConfigList": question_export_config_list}, warnings
914
+
915
+ def _resolve_exportable_fields(
916
+ self,
917
+ *,
918
+ profile: str,
919
+ context,
920
+ app_key: str,
921
+ resolved_view: AccessibleViewRoute,
922
+ index,
923
+ visible_question_ids: set[int],
924
+ ) -> tuple[list[FormField], list[JSONObject]]: # type: ignore[no-untyped-def]
925
+ warnings: list[JSONObject] = []
926
+ if resolved_view.kind == "custom" and resolved_view.view_selection is not None:
927
+ custom_fields = self._resolve_custom_view_exportable_fields(
928
+ profile=profile,
929
+ context=context,
930
+ app_key=app_key,
931
+ view_key=resolved_view.view_selection.view_key,
932
+ index=index,
933
+ visible_question_ids=visible_question_ids,
934
+ )
935
+ if custom_fields is not None:
936
+ return custom_fields, warnings
937
+ warnings.append(
938
+ {
939
+ "code": "EXPORT_VIEW_CONFIG_PARTIAL",
940
+ "message": "custom view export fields fell back to schema order because viewConfig could not provide exportable field entries",
941
+ }
942
+ )
943
+ ordered_visible_fields = [
944
+ field
945
+ for field in self._record_tools._schema_fields_for_mode(
946
+ profile,
947
+ context,
948
+ app_key,
949
+ index,
950
+ schema_mode="browse",
951
+ resolved_view=resolved_view,
952
+ )
953
+ if field.que_id in visible_question_ids and field.que_type not in LAYOUT_ONLY_QUE_TYPES
954
+ ]
955
+ return ordered_visible_fields, warnings
956
+
957
+ def _resolve_custom_view_exportable_fields(
958
+ self,
959
+ *,
960
+ profile: str,
961
+ context,
962
+ app_key: str,
963
+ view_key: str,
964
+ index,
965
+ visible_question_ids: set[int],
966
+ ) -> list[FormField] | None: # type: ignore[no-untyped-def]
967
+ view_config = self._record_tools._get_view_config(profile, context, view_key)
968
+ if not isinstance(view_config, dict):
969
+ return None
970
+ entries = _extract_export_view_question_entries(view_config.get("viewgraphQuestions"))
971
+ if not entries:
972
+ return []
973
+ ordered_fields: list[FormField] = []
974
+ seen: set[int] = set()
975
+ for entry in entries:
976
+ if not bool(entry.get("downloadable", True)):
977
+ continue
978
+ field_id = _coerce_int(entry.get("field_id"))
979
+ if field_id is None or field_id in seen:
980
+ continue
981
+ field = cast(FormField | None, cast(Any, index).by_id.get(str(field_id)))
982
+ if field is None or field.que_type in LAYOUT_ONLY_QUE_TYPES:
983
+ continue
984
+ ordered_fields.append(field)
985
+ seen.add(field_id)
986
+ return ordered_fields
987
+
988
+ def _estimate_export_result_amount(
989
+ self,
990
+ context,
991
+ *,
992
+ app_key: str,
993
+ resolved_view: AccessibleViewRoute,
994
+ selected_record_ids: list[int],
995
+ ) -> int:
996
+ if selected_record_ids:
997
+ return len(selected_record_ids)
998
+ page = self._record_tools._search_page(
999
+ context,
1000
+ app_key=app_key,
1001
+ view_selection=resolved_view.view_selection,
1002
+ page_num=1,
1003
+ page_size=1,
1004
+ query_key=None,
1005
+ match_rules=[],
1006
+ sorts=[],
1007
+ search_que_ids=None,
1008
+ list_type=resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE,
1009
+ )
1010
+ result_amount = _effective_total(page, page_size=1)
1011
+ if result_amount < 0:
1012
+ result_amount = 0
1013
+ return result_amount
1014
+
1015
+ def _validate_export_limits(
1016
+ self,
1017
+ *,
1018
+ result_amount: int,
1019
+ selected_field_count: int,
1020
+ include_workflow_log: bool,
1021
+ ) -> None:
1022
+ if result_amount > EXPORT_ROWS_LIMIT:
1023
+ raise QingflowApiError.config_error(
1024
+ f"record export exceeds the native row limit of {EXPORT_ROWS_LIMIT}",
1025
+ details={
1026
+ "error_code": "EXPORT_ROWS_LIMIT_EXCEEDED",
1027
+ "result_amount": result_amount,
1028
+ "row_limit": EXPORT_ROWS_LIMIT,
1029
+ },
1030
+ )
1031
+ estimated_cells = max(result_amount, 0) * max(selected_field_count, 0)
1032
+ if estimated_cells > EXPORT_CELL_LIMIT:
1033
+ raise QingflowApiError.config_error(
1034
+ f"record export exceeds the native cell limit of {EXPORT_CELL_LIMIT}",
1035
+ details={
1036
+ "error_code": "EXPORT_CELLS_LIMIT_EXCEEDED",
1037
+ "estimated_cells": estimated_cells,
1038
+ "cell_limit": EXPORT_CELL_LIMIT,
1039
+ "include_workflow_log": include_workflow_log,
1040
+ },
1041
+ )
1042
+
1043
+ def _resolve_export_snapshot(
1044
+ self,
1045
+ context,
1046
+ local_job: dict[str, Any],
1047
+ ) -> dict[str, Any]: # type: ignore[no-untyped-def]
1048
+ app_key = str(local_job.get("app_key") or "").strip()
1049
+ process_payload = self._lookup_process_details(context, app_key=app_key)
1050
+ history_page = self.backend.request(
1051
+ "GET",
1052
+ context,
1053
+ "/app/apply/dataExport/record",
1054
+ params={"appKey": app_key, "pageNum": 1, "pageSize": 100},
1055
+ )
1056
+ history_records = _extract_export_records(history_page)
1057
+ matched_record, matched_by = _match_export_history_record(history_records, local_job=local_job)
1058
+ if process_payload is not None:
1059
+ normalized_status = _normalize_export_status(process_payload.get("processStatus") or process_payload.get("status"))
1060
+ if normalized_status in {"queued", "running"}:
1061
+ return {
1062
+ "status": normalized_status,
1063
+ "process_status": _coerce_int(process_payload.get("processStatus") or process_payload.get("status")),
1064
+ "num": _coerce_int(process_payload.get("num")),
1065
+ "error_code": process_payload.get("errorCode"),
1066
+ "audit_record_status": process_payload.get("auditRecordStatus"),
1067
+ "file_infos": _normalize_export_file_infos(process_payload.get("fileUrls")),
1068
+ "file_urls": _extract_export_file_urls(process_payload.get("fileUrls")),
1069
+ "file_names": _extract_export_file_names(process_payload.get("fileUrls")),
1070
+ "warnings": [],
1071
+ "verification": {
1072
+ "current_process_visible": True,
1073
+ "history_match_resolved": matched_record is not None,
1074
+ },
1075
+ "message": None,
1076
+ }
1077
+ if matched_record is None:
1078
+ warning_code = "EXPORT_HISTORY_PENDING"
1079
+ warning_message = "export has not appeared in export history yet"
1080
+ if matched_by == "ambiguous":
1081
+ warning_code = "EXPORT_HISTORY_AMBIGUOUS"
1082
+ warning_message = "export result could not be matched uniquely in export history"
1083
+ return {
1084
+ "status": "unknown",
1085
+ "process_status": None,
1086
+ "num": None,
1087
+ "error_code": None,
1088
+ "audit_record_status": None,
1089
+ "file_infos": [],
1090
+ "file_urls": [],
1091
+ "file_names": [],
1092
+ "warnings": [{"code": warning_code, "message": warning_message}],
1093
+ "verification": {
1094
+ "current_process_visible": process_payload is not None,
1095
+ "history_match_resolved": False,
1096
+ },
1097
+ "message": warning_message,
1098
+ }
1099
+ file_infos = _normalize_export_file_infos(matched_record.get("fileUrls"))
1100
+ raw_process_status = _coerce_int(matched_record.get("processStatus"))
1101
+ normalized_status = _normalize_export_status(raw_process_status)
1102
+ message = None
1103
+ if normalized_status == "failed":
1104
+ message = "export failed"
1105
+ return {
1106
+ "status": normalized_status,
1107
+ "process_status": raw_process_status,
1108
+ "num": _coerce_int(matched_record.get("num")),
1109
+ "error_code": matched_record.get("errorCode"),
1110
+ "audit_record_status": matched_record.get("auditRecordStatus"),
1111
+ "file_infos": file_infos,
1112
+ "file_urls": [item.get("url") for item in file_infos if isinstance(item.get("url"), str)],
1113
+ "file_names": [item.get("name") for item in file_infos if isinstance(item.get("name"), str)],
1114
+ "warnings": [],
1115
+ "verification": {
1116
+ "current_process_visible": process_payload is not None,
1117
+ "history_match_resolved": True,
1118
+ "matched_by": matched_by,
1119
+ },
1120
+ "message": message,
1121
+ }
1122
+
1123
+ def _lookup_process_details(self, context, *, app_key: str) -> JSONObject | None: # type: ignore[no-untyped-def]
1124
+ try:
1125
+ payload = self.backend.request(
1126
+ "GET",
1127
+ context,
1128
+ f"/process/{app_key}/details",
1129
+ params={"taskType": 2},
1130
+ )
1131
+ except QingflowApiError as exc:
1132
+ if exc.backend_code in {40002, 40027}:
1133
+ return None
1134
+ raise
1135
+ if isinstance(payload, dict):
1136
+ nested = payload.get("data")
1137
+ if isinstance(nested, dict):
1138
+ payload = nested
1139
+ if isinstance(payload.get("detail"), dict):
1140
+ return cast(JSONObject, payload.get("detail"))
1141
+ if any(key in payload for key in ("processStatus", "progress", "num", "fileUrls", "errorCode")):
1142
+ return cast(JSONObject, payload)
1143
+ return None
1144
+
1145
+ def _status_payload_from_snapshot(
1146
+ self,
1147
+ local_job: dict[str, Any],
1148
+ export_handle: str,
1149
+ snapshot: dict[str, Any],
1150
+ ) -> dict[str, Any]:
1151
+ normalized_status = str(snapshot.get("status") or "unknown")
1152
+ ok = normalized_status not in {"failed"} or snapshot.get("error_code") in (None, "", 0)
1153
+ if normalized_status == "failed":
1154
+ ok = False
1155
+ return {
1156
+ "ok": ok,
1157
+ "status": normalized_status,
1158
+ "export_handle": export_handle,
1159
+ "app_key": str(local_job.get("app_key") or ""),
1160
+ "view_id": str(local_job.get("view_id") or ""),
1161
+ "row_scope": str(local_job.get("row_scope") or "all"),
1162
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
1163
+ "field_scope": str(local_job.get("field_scope") or "all"),
1164
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
1165
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
1166
+ "num": snapshot.get("num"),
1167
+ "process_status": snapshot.get("process_status"),
1168
+ "error_code": snapshot.get("error_code"),
1169
+ "audit_record_status": snapshot.get("audit_record_status"),
1170
+ "file_urls": snapshot.get("file_urls") or [],
1171
+ "file_names": snapshot.get("file_names") or [],
1172
+ "downloaded_files": [],
1173
+ "warnings": snapshot.get("warnings") or [],
1174
+ "verification": snapshot.get("verification") or {},
1175
+ "message": snapshot.get("message"),
1176
+ }
1177
+
1178
+ def _download_export_files(
1179
+ self,
1180
+ *,
1181
+ file_infos: list[JSONObject],
1182
+ download_to_path: str | None,
1183
+ default_directory: str | None,
1184
+ app_key: str,
1185
+ view_id: str,
1186
+ ) -> list[JSONObject]:
1187
+ if download_to_path is None and default_directory is None:
1188
+ return []
1189
+ effective_hint = download_to_path or default_directory
1190
+ assert effective_hint is not None
1191
+ targets = _resolve_download_targets(
1192
+ effective_hint,
1193
+ file_infos=file_infos,
1194
+ app_key=app_key,
1195
+ view_id=view_id,
1196
+ )
1197
+ downloaded_files: list[JSONObject] = []
1198
+ for file_info, target in zip(file_infos, targets, strict=False):
1199
+ url = str(file_info.get("url") or "").strip()
1200
+ if not url:
1201
+ continue
1202
+ content = self.backend.download_binary(url)
1203
+ target.parent.mkdir(parents=True, exist_ok=True)
1204
+ target.write_bytes(content)
1205
+ downloaded_files.append(
1206
+ {
1207
+ "file_name": str(file_info.get("name") or target.name),
1208
+ "path": str(target),
1209
+ "url": url,
1210
+ }
1211
+ )
1212
+ return downloaded_files
1213
+
1214
+ def _normalize_timeout_seconds(self, wait_timeout_seconds: float | None) -> float:
1215
+ if wait_timeout_seconds is None:
1216
+ return DEFAULT_EXPORT_TIMEOUT_SECONDS
1217
+ try:
1218
+ value = float(wait_timeout_seconds)
1219
+ except (TypeError, ValueError):
1220
+ return DEFAULT_EXPORT_TIMEOUT_SECONDS
1221
+ return value if value > 0 else DEFAULT_EXPORT_TIMEOUT_SECONDS
1222
+
1223
+ def _failed_export_result(
1224
+ self,
1225
+ *,
1226
+ error_code: str,
1227
+ message: str,
1228
+ extra: dict[str, Any] | None = None,
1229
+ ) -> dict[str, Any]:
1230
+ payload = {
1231
+ "ok": False,
1232
+ "status": "failed",
1233
+ "error_code": error_code,
1234
+ "export_handle": None,
1235
+ "app_key": None,
1236
+ "view_id": None,
1237
+ "num": None,
1238
+ "process_status": None,
1239
+ "audit_record_status": None,
1240
+ "file_urls": [],
1241
+ "file_names": [],
1242
+ "downloaded_files": [],
1243
+ "warnings": [],
1244
+ "verification": {},
1245
+ "message": message,
1246
+ }
1247
+ if extra:
1248
+ payload.update(extra)
1249
+ return payload
1250
+
1251
+ def _runtime_error_as_result(
1252
+ self,
1253
+ error: RuntimeError,
1254
+ *,
1255
+ error_code: str,
1256
+ extra: dict[str, Any] | None = None,
1257
+ ) -> dict[str, Any]:
1258
+ try:
1259
+ payload = json.loads(str(error))
1260
+ except json.JSONDecodeError:
1261
+ payload = {"message": str(error)}
1262
+ response = self._failed_export_result(
1263
+ error_code=((payload.get("details") or {}) if isinstance(payload.get("details"), dict) else {}).get("error_code") or error_code,
1264
+ message=str(payload.get("message") or str(error)),
1265
+ )
1266
+ if extra:
1267
+ response.update(extra)
1268
+ return response
1269
+
1270
+
1271
+ def _extract_export_records(payload: Any) -> list[JSONObject]:
1272
+ if isinstance(payload, dict):
1273
+ for key in ("list", "records", "items"):
1274
+ value = payload.get(key)
1275
+ if isinstance(value, list):
1276
+ return [item for item in value if isinstance(item, dict)]
1277
+ if isinstance(payload, list):
1278
+ return [item for item in payload if isinstance(item, dict)]
1279
+ return []
1280
+
1281
+
1282
+ def _match_export_history_record(
1283
+ records: list[JSONObject],
1284
+ *,
1285
+ local_job: dict[str, Any],
1286
+ ) -> tuple[JSONObject | None, str | None]:
1287
+ uid = _extract_operate_user_uid(local_job)
1288
+ started_at = _parse_utc(local_job.get("started_at"))
1289
+ candidates = records
1290
+ if uid is not None:
1291
+ candidates = [item for item in candidates if _extract_operate_user_uid(item.get("operateUser")) == uid]
1292
+ if started_at is not None:
1293
+ started_at = started_at.replace(microsecond=0)
1294
+ parsed_candidates: list[tuple[JSONObject, datetime]] = []
1295
+ for item in candidates:
1296
+ operate_time = _parse_utc(item.get("operateTime")) or _parse_utc(item.get("operate_time"))
1297
+ if operate_time is None:
1298
+ continue
1299
+ parsed_candidates.append((item, operate_time))
1300
+ exact_or_after = [item for item, operate_time in parsed_candidates if operate_time >= started_at]
1301
+ if len(exact_or_after) == 1:
1302
+ return exact_or_after[0], "operate_user_started_at"
1303
+ if len(exact_or_after) > 1:
1304
+ return None, "ambiguous"
1305
+ skew_tolerant = [
1306
+ item
1307
+ for item, operate_time in parsed_candidates
1308
+ if operate_time >= (started_at - timedelta(seconds=5))
1309
+ ]
1310
+ if len(skew_tolerant) == 1:
1311
+ return skew_tolerant[0], "operate_user_started_at_skew"
1312
+ if len(skew_tolerant) > 1:
1313
+ return None, "ambiguous"
1314
+ candidates = []
1315
+ if len(candidates) == 1:
1316
+ return candidates[0], "operate_user_started_at"
1317
+ if len(candidates) > 1:
1318
+ return None, "ambiguous"
1319
+ return None, None
1320
+
1321
+
1322
+ def _normalize_export_status(value: Any) -> str:
1323
+ status_code = _coerce_int(value)
1324
+ if status_code is not None:
1325
+ return EXPORT_STATUS_BY_PROCESS_STATUS.get(status_code, "unknown")
1326
+ text = str(value or "").strip().lower()
1327
+ if text in {"queued", "running", "succeeded", "failed", "unknown"}:
1328
+ return text
1329
+ if text in {"line_up", "lineup"}:
1330
+ return "queued"
1331
+ if text in {"execute", "executing", "processing"}:
1332
+ return "running"
1333
+ if text in {"success", "completed"}:
1334
+ return "succeeded"
1335
+ if text in {"fail", "partly_fail", "partial_fail"}:
1336
+ return "failed"
1337
+ return "unknown"
1338
+
1339
+
1340
+ def _normalize_export_file_infos(value: Any) -> list[JSONObject]:
1341
+ if not isinstance(value, list):
1342
+ return []
1343
+ items: list[JSONObject] = []
1344
+ for item in value:
1345
+ if not isinstance(item, dict):
1346
+ continue
1347
+ url = str(item.get("url") or "").strip()
1348
+ name = str(item.get("name") or item.get("fileName") or "").strip()
1349
+ payload: JSONObject = {}
1350
+ if url:
1351
+ payload["url"] = url
1352
+ if name:
1353
+ payload["name"] = name
1354
+ if payload:
1355
+ items.append(payload)
1356
+ return items
1357
+
1358
+
1359
+ def _extract_export_file_urls(value: Any) -> list[str]:
1360
+ return [str(item.get("url") or "").strip() for item in _normalize_export_file_infos(value) if str(item.get("url") or "").strip()]
1361
+
1362
+
1363
+ def _extract_export_file_names(value: Any) -> list[str]:
1364
+ return [str(item.get("name") or "").strip() for item in _normalize_export_file_infos(value) if str(item.get("name") or "").strip()]
1365
+
1366
+
1367
+ def _extract_operate_user_uid(value: Any) -> int | None:
1368
+ if isinstance(value, dict):
1369
+ for key in ("uid", "userId", "id"):
1370
+ uid = _coerce_int(value.get(key))
1371
+ if uid is not None:
1372
+ return uid
1373
+ return _coerce_int(value)
1374
+
1375
+
1376
+ def _parse_utc(value: Any) -> datetime | None:
1377
+ text = str(value or "").strip()
1378
+ if not text:
1379
+ return None
1380
+ normalized = text.replace("Z", "+00:00")
1381
+ try:
1382
+ parsed = datetime.fromisoformat(normalized)
1383
+ except ValueError:
1384
+ return None
1385
+ if parsed.tzinfo is None:
1386
+ return parsed.replace(tzinfo=LOCAL_TIMEZONE).astimezone(timezone.utc)
1387
+ return parsed.astimezone(timezone.utc)
1388
+
1389
+
1390
+ def _coerce_int(value: Any) -> int | None:
1391
+ if value is None or value == "":
1392
+ return None
1393
+ try:
1394
+ return int(value)
1395
+ except (TypeError, ValueError):
1396
+ return None
1397
+
1398
+
1399
+ def _coerce_positive_int(value: Any) -> int | None:
1400
+ parsed = _coerce_int(value)
1401
+ if parsed is None or parsed <= 0:
1402
+ return None
1403
+ return parsed
1404
+
1405
+
1406
+ def _extract_export_view_question_entries(questions: Any) -> list[JSONObject]:
1407
+ if not isinstance(questions, list):
1408
+ return []
1409
+ entries: list[JSONObject] = []
1410
+ fallback_order = 0
1411
+
1412
+ def walk(nodes: Any) -> None:
1413
+ nonlocal fallback_order
1414
+ if not isinstance(nodes, list):
1415
+ return
1416
+ for item in nodes:
1417
+ if not isinstance(item, dict):
1418
+ continue
1419
+ children: list[Any] = []
1420
+ for child_key in ("innerQues", "subQues", "innerQuestions", "subQuestions"):
1421
+ child_value = item.get(child_key)
1422
+ if isinstance(child_value, list) and child_value:
1423
+ children.extend(child_value)
1424
+ if children:
1425
+ walk(children)
1426
+ continue
1427
+ field_id = _coerce_int(item.get("queId"))
1428
+ if field_id is None:
1429
+ continue
1430
+ fallback_order += 1
1431
+ downloadable_raw = item.get("beingDownload")
1432
+ entries.append(
1433
+ {
1434
+ "field_id": field_id,
1435
+ "name": str(item.get("queTitle") or "").strip(),
1436
+ "display_order": _coerce_positive_int(item.get("displayOrdinal")) or fallback_order,
1437
+ "downloadable": bool(downloadable_raw) if downloadable_raw is not None else True,
1438
+ }
1439
+ )
1440
+
1441
+ walk(questions)
1442
+ return sorted(
1443
+ entries,
1444
+ key=lambda entry: (
1445
+ _coerce_positive_int(entry.get("display_order")) if _coerce_positive_int(entry.get("display_order")) is not None else 10**9,
1446
+ str(entry.get("name") or ""),
1447
+ ),
1448
+ )
1449
+
1450
+
1451
+ def _normalize_export_columns(columns: list[JSONObject | int]) -> list[int]:
1452
+ normalized: list[int] = []
1453
+ for field_id in _normalize_public_column_selectors(columns):
1454
+ if field_id not in normalized:
1455
+ normalized.append(field_id)
1456
+ return normalized
1457
+
1458
+
1459
+ def _normalize_export_record_ids(record_ids: list[str | int]) -> list[int]:
1460
+ normalized: list[int] = []
1461
+ seen: set[int] = set()
1462
+ for item in record_ids:
1463
+ record_id = _coerce_int(item)
1464
+ if record_id is None or record_id <= 0 or record_id in seen:
1465
+ continue
1466
+ normalized.append(record_id)
1467
+ seen.add(record_id)
1468
+ return normalized
1469
+
1470
+
1471
+ def _effective_total(page: JSONObject, *, page_size: int) -> int:
1472
+ rows = page.get("list")
1473
+ returned_rows = len(rows) if isinstance(rows, list) else 0
1474
+ reported = _coerce_int(page.get("total"))
1475
+ if reported is None:
1476
+ reported = _coerce_int(page.get("count"))
1477
+ if reported is not None:
1478
+ return max(reported, returned_rows)
1479
+ page_amount = _coerce_int(page.get("pageAmount"))
1480
+ if page_amount is not None:
1481
+ return page_amount * page_size
1482
+ return returned_rows
1483
+
1484
+
1485
+ def _page_has_more(page: JSONObject, *, current_page: int, page_size: int, returned_rows: int) -> bool:
1486
+ page_amount = _coerce_int(page.get("pageAmount"))
1487
+ if page_amount is not None:
1488
+ return current_page < page_amount
1489
+ return returned_rows >= page_size
1490
+
1491
+
1492
+ def _resolve_download_targets(
1493
+ destination_hint: str,
1494
+ *,
1495
+ file_infos: list[JSONObject],
1496
+ app_key: str,
1497
+ view_id: str,
1498
+ ) -> list[Path]:
1499
+ path = Path(destination_hint).expanduser()
1500
+ timestamp = _utc_now().strftime("%Y%m%dT%H%M%SZ")
1501
+ if len(file_infos) > 1:
1502
+ if path.exists() and not path.is_dir():
1503
+ raise QingflowApiError.config_error("download_to_path must be a directory when multiple export files are returned")
1504
+ if not path.exists() and path.suffix:
1505
+ raise QingflowApiError.config_error("download_to_path must be a directory when multiple export files are returned")
1506
+ path.mkdir(parents=True, exist_ok=True)
1507
+ return [
1508
+ path / _choose_export_file_name(
1509
+ file_info,
1510
+ app_key=app_key,
1511
+ view_id=view_id,
1512
+ timestamp=timestamp,
1513
+ index=index,
1514
+ )
1515
+ for index, file_info in enumerate(file_infos, start=1)
1516
+ ]
1517
+ if path.exists() and path.is_dir():
1518
+ return [
1519
+ path / _choose_export_file_name(
1520
+ file_infos[0],
1521
+ app_key=app_key,
1522
+ view_id=view_id,
1523
+ timestamp=timestamp,
1524
+ index=1,
1525
+ )
1526
+ ]
1527
+ if path.suffix or path.exists():
1528
+ return [path]
1529
+ path.mkdir(parents=True, exist_ok=True)
1530
+ return [
1531
+ path / _choose_export_file_name(
1532
+ file_infos[0],
1533
+ app_key=app_key,
1534
+ view_id=view_id,
1535
+ timestamp=timestamp,
1536
+ index=1,
1537
+ )
1538
+ ]
1539
+
1540
+
1541
+ def _choose_export_file_name(
1542
+ file_info: JSONObject,
1543
+ *,
1544
+ app_key: str,
1545
+ view_id: str,
1546
+ timestamp: str,
1547
+ index: int,
1548
+ ) -> str:
1549
+ remote_name = str(file_info.get("name") or "").strip()
1550
+ if remote_name:
1551
+ sanitized = _sanitize_filename(remote_name)
1552
+ if sanitized:
1553
+ return sanitized
1554
+ suffix = ".xlsx"
1555
+ safe_view = _sanitize_filename(view_id.replace(":", "_")) or "view"
1556
+ return f"{_sanitize_filename(app_key) or 'app'}_{safe_view}_{timestamp}_{index}{suffix}"
1557
+
1558
+
1559
+ def _sanitize_filename(value: str) -> str:
1560
+ base = _SAFE_FILE_CHARS.sub("_", value).strip("._")
1561
+ return base or ""
1562
+
1563
+
1564
+ def _utc_now() -> datetime:
1565
+ return datetime.now(timezone.utc)