@josephyan/qingflow-cli 0.2.0-beta.1007 → 0.2.0-beta.1010

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.
@@ -0,0 +1,960 @@
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 ..config import DEFAULT_PROFILE
15
+ from ..errors import QingflowApiError
16
+ from ..export_store import ExportJobStore
17
+ from ..json_types import JSONObject
18
+ from .base import ToolBase, tool_cn_name
19
+ from .record_tools import AccessibleViewRoute, LAYOUT_ONLY_QUE_TYPES, RecordTools
20
+
21
+
22
+ EXPORT_STATUS_BY_PROCESS_STATUS = {
23
+ 1: "queued",
24
+ 2: "running",
25
+ 3: "succeeded",
26
+ 4: "failed",
27
+ 5: "failed",
28
+ }
29
+ DEFAULT_EXPORT_TIMEOUT_SECONDS = 60.0
30
+ EXPORT_POLL_INTERVAL_SECONDS = 1.5
31
+ _SAFE_FILE_CHARS = re.compile(r"[^0-9A-Za-z._-]+")
32
+ LOCAL_TIMEZONE = datetime.now().astimezone().tzinfo or timezone.utc
33
+
34
+
35
+ class ExportTools(ToolBase):
36
+ def __init__(
37
+ self,
38
+ sessions,
39
+ backend,
40
+ *,
41
+ job_store: ExportJobStore | None = None,
42
+ ) -> None:
43
+ super().__init__(sessions, backend)
44
+ self._record_tools = RecordTools(sessions, backend)
45
+ self._job_store = job_store or ExportJobStore()
46
+
47
+ def register(self, mcp: FastMCP) -> None:
48
+ @mcp.tool(description="Start an asynchronous xlsx export for a Qingflow record view and return an opaque export_handle.")
49
+ def record_export_start(
50
+ profile: str = DEFAULT_PROFILE,
51
+ app_key: str = "",
52
+ view_id: str = "system:all",
53
+ ) -> dict[str, Any]:
54
+ return self.record_export_start(
55
+ profile=profile,
56
+ app_key=app_key,
57
+ view_id=view_id,
58
+ )
59
+
60
+ @mcp.tool(description="Get asynchronous record export status by opaque export_handle.")
61
+ def record_export_status_get(
62
+ profile: str = DEFAULT_PROFILE,
63
+ export_handle: str = "",
64
+ ) -> dict[str, Any]:
65
+ return self.record_export_status_get(
66
+ profile=profile,
67
+ export_handle=export_handle,
68
+ )
69
+
70
+ @mcp.tool(description="Get completed record export result, returning remote file links and optionally downloading files locally.")
71
+ def record_export_get(
72
+ profile: str = DEFAULT_PROFILE,
73
+ export_handle: str = "",
74
+ download_to_path: str | None = None,
75
+ ) -> dict[str, Any]:
76
+ return self.record_export_get(
77
+ profile=profile,
78
+ export_handle=export_handle,
79
+ download_to_path=download_to_path,
80
+ )
81
+
82
+ @mcp.tool(description="Start a record export, wait for completion, and download the xlsx locally while also returning remote download links.")
83
+ def record_export_direct(
84
+ profile: str = DEFAULT_PROFILE,
85
+ app_key: str = "",
86
+ view_id: str = "system:all",
87
+ download_to_path: str | None = None,
88
+ wait_timeout_seconds: float | None = None,
89
+ ) -> dict[str, Any]:
90
+ return self.record_export_direct(
91
+ profile=profile,
92
+ app_key=app_key,
93
+ view_id=view_id,
94
+ download_to_path=download_to_path,
95
+ wait_timeout_seconds=wait_timeout_seconds,
96
+ )
97
+
98
+ @tool_cn_name("记录导出启动")
99
+ def record_export_start(
100
+ self,
101
+ *,
102
+ profile: str = DEFAULT_PROFILE,
103
+ app_key: str,
104
+ view_id: str = "system:all",
105
+ ) -> dict[str, Any]:
106
+ normalized_app_key = str(app_key or "").strip()
107
+ normalized_view_id = str(view_id or "").strip() or "system:all"
108
+ if not normalized_app_key:
109
+ return self._failed_export_result(
110
+ error_code="EXPORT_START_FAILED",
111
+ message="app_key is required",
112
+ extra={"view_id": normalized_view_id, "status": "failed"},
113
+ )
114
+
115
+ def runner(session_profile, context):
116
+ resolved_view, compatibility_warnings = self._record_tools._resolve_accessible_view_route(
117
+ profile,
118
+ context,
119
+ normalized_app_key,
120
+ view_id=normalized_view_id,
121
+ list_type=None,
122
+ view_key=None,
123
+ view_name=None,
124
+ allow_default=True,
125
+ )
126
+ export_config, export_config_warnings = self._build_export_config(
127
+ profile=profile,
128
+ context=context,
129
+ app_key=normalized_app_key,
130
+ resolved_view=resolved_view,
131
+ )
132
+ filter_bean = self._build_export_filter_bean(resolved_view)
133
+ started_at = _utc_now().replace(microsecond=0).isoformat()
134
+ socket_result = self.backend.start_socket_record_export(
135
+ context,
136
+ app_key=normalized_app_key,
137
+ view_id=resolved_view.view_id,
138
+ view_key=resolved_view.view_selection.view_key if resolved_view.view_selection is not None else None,
139
+ filter_bean=filter_bean,
140
+ export_config=export_config,
141
+ )
142
+ export_handle = uuid4().hex
143
+ self._job_store.put(
144
+ export_handle,
145
+ {
146
+ "created_at": started_at,
147
+ "profile": profile,
148
+ "app_key": normalized_app_key,
149
+ "view_id": resolved_view.view_id,
150
+ "backend_export_id": str(socket_result.get("backend_export_id") or ""),
151
+ "started_at": started_at,
152
+ "uid": session_profile.uid,
153
+ },
154
+ )
155
+ warnings = [
156
+ *deepcopy(compatibility_warnings),
157
+ *deepcopy(export_config_warnings),
158
+ *deepcopy(cast(list[JSONObject], socket_result.get("warnings") or [])),
159
+ ]
160
+ return {
161
+ "ok": True,
162
+ "status": "accepted",
163
+ "app_key": normalized_app_key,
164
+ "view_id": resolved_view.view_id,
165
+ "export_handle": export_handle,
166
+ "file_urls": [],
167
+ "file_names": [],
168
+ "downloaded_files": [],
169
+ "warnings": warnings,
170
+ "verification": {
171
+ "export_acknowledged": bool(socket_result.get("backend_export_id")),
172
+ "view_route_supported": True,
173
+ },
174
+ "request_route": self.backend.describe_route(context),
175
+ }
176
+
177
+ try:
178
+ return self._run(profile, runner)
179
+ except RuntimeError as exc:
180
+ return self._runtime_error_as_result(
181
+ exc,
182
+ error_code="EXPORT_START_FAILED",
183
+ extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
184
+ )
185
+
186
+ @tool_cn_name("记录导出状态")
187
+ def record_export_status_get(
188
+ self,
189
+ *,
190
+ profile: str = DEFAULT_PROFILE,
191
+ export_handle: str,
192
+ ) -> dict[str, Any]:
193
+ normalized_handle = str(export_handle or "").strip()
194
+ if not normalized_handle:
195
+ return self._failed_export_result(
196
+ error_code="CONFIG_ERROR",
197
+ message="export_handle is required",
198
+ extra={"status": "failed"},
199
+ )
200
+
201
+ def runner(_session_profile, context):
202
+ local_job = self._job_store.get(normalized_handle)
203
+ if local_job is None:
204
+ return self._failed_export_result(
205
+ error_code="EXPORT_HANDLE_UNKNOWN",
206
+ message="export_handle is missing or expired",
207
+ extra={"export_handle": normalized_handle, "status": "failed"},
208
+ )
209
+ snapshot = self._resolve_export_snapshot(context, local_job)
210
+ return self._status_payload_from_snapshot(local_job, normalized_handle, snapshot)
211
+
212
+ try:
213
+ return self._run(profile, runner)
214
+ except RuntimeError as exc:
215
+ return self._runtime_error_as_result(
216
+ exc,
217
+ error_code="EXPORT_STATUS_FAILED",
218
+ extra={"export_handle": normalized_handle},
219
+ )
220
+
221
+ @tool_cn_name("记录导出结果")
222
+ def record_export_get(
223
+ self,
224
+ *,
225
+ profile: str = DEFAULT_PROFILE,
226
+ export_handle: str,
227
+ download_to_path: str | None = None,
228
+ ) -> dict[str, Any]:
229
+ normalized_handle = str(export_handle or "").strip()
230
+ if not normalized_handle:
231
+ return self._failed_export_result(
232
+ error_code="CONFIG_ERROR",
233
+ message="export_handle is required",
234
+ extra={"status": "failed"},
235
+ )
236
+
237
+ def runner(_session_profile, context):
238
+ local_job = self._job_store.get(normalized_handle)
239
+ if local_job is None:
240
+ return self._failed_export_result(
241
+ error_code="EXPORT_HANDLE_UNKNOWN",
242
+ message="export_handle is missing or expired",
243
+ extra={"export_handle": normalized_handle, "status": "failed"},
244
+ )
245
+ snapshot = self._resolve_export_snapshot(context, local_job)
246
+ normalized_status = str(snapshot.get("status") or "unknown")
247
+ if normalized_status not in {"succeeded", "failed"}:
248
+ return self._failed_export_result(
249
+ error_code="EXPORT_NOT_READY",
250
+ message="export is not ready yet",
251
+ extra={
252
+ "status": "blocked",
253
+ "export_handle": normalized_handle,
254
+ "app_key": str(local_job.get("app_key") or ""),
255
+ "view_id": str(local_job.get("view_id") or ""),
256
+ "file_urls": snapshot.get("file_urls") or [],
257
+ "file_names": snapshot.get("file_names") or [],
258
+ "downloaded_files": [],
259
+ "warnings": snapshot.get("warnings") or [],
260
+ "verification": snapshot.get("verification") or {},
261
+ },
262
+ )
263
+ if normalized_status == "failed":
264
+ return {
265
+ **self._failed_export_result(
266
+ error_code=str(snapshot.get("error_code") or "EXPORT_FAILED"),
267
+ message=str(snapshot.get("message") or "export failed"),
268
+ extra={
269
+ "status": "failed",
270
+ "export_handle": normalized_handle,
271
+ "app_key": str(local_job.get("app_key") or ""),
272
+ "view_id": str(local_job.get("view_id") or ""),
273
+ "file_urls": snapshot.get("file_urls") or [],
274
+ "file_names": snapshot.get("file_names") or [],
275
+ "downloaded_files": [],
276
+ "warnings": snapshot.get("warnings") or [],
277
+ "verification": snapshot.get("verification") or {},
278
+ "num": snapshot.get("num"),
279
+ "process_status": snapshot.get("process_status"),
280
+ "audit_record_status": snapshot.get("audit_record_status"),
281
+ },
282
+ ),
283
+ }
284
+ file_infos = cast(list[JSONObject], snapshot.get("file_infos") or [])
285
+ if not file_infos:
286
+ return self._failed_export_result(
287
+ error_code="EXPORT_FILE_UNAVAILABLE",
288
+ message="export completed but did not return downloadable files",
289
+ extra={
290
+ "status": "failed",
291
+ "export_handle": normalized_handle,
292
+ "app_key": str(local_job.get("app_key") or ""),
293
+ "view_id": str(local_job.get("view_id") or ""),
294
+ "warnings": snapshot.get("warnings") or [],
295
+ "verification": snapshot.get("verification") or {},
296
+ },
297
+ )
298
+ downloaded_files = self._download_export_files(
299
+ file_infos=file_infos,
300
+ download_to_path=download_to_path,
301
+ default_directory=None,
302
+ app_key=str(local_job.get("app_key") or ""),
303
+ view_id=str(local_job.get("view_id") or ""),
304
+ )
305
+ return {
306
+ "ok": True,
307
+ "status": "succeeded",
308
+ "export_handle": normalized_handle,
309
+ "app_key": str(local_job.get("app_key") or ""),
310
+ "view_id": str(local_job.get("view_id") or ""),
311
+ "num": snapshot.get("num"),
312
+ "process_status": snapshot.get("process_status"),
313
+ "error_code": snapshot.get("error_code"),
314
+ "audit_record_status": snapshot.get("audit_record_status"),
315
+ "file_urls": snapshot.get("file_urls") or [],
316
+ "file_names": snapshot.get("file_names") or [],
317
+ "downloaded_files": downloaded_files,
318
+ "warnings": snapshot.get("warnings") or [],
319
+ "verification": snapshot.get("verification") or {},
320
+ "request_route": self.backend.describe_route(context),
321
+ }
322
+
323
+ try:
324
+ return self._run(profile, runner)
325
+ except RuntimeError as exc:
326
+ return self._runtime_error_as_result(
327
+ exc,
328
+ error_code="EXPORT_GET_FAILED",
329
+ extra={"export_handle": normalized_handle},
330
+ )
331
+
332
+ @tool_cn_name("记录直接导出")
333
+ def record_export_direct(
334
+ self,
335
+ *,
336
+ profile: str = DEFAULT_PROFILE,
337
+ app_key: str,
338
+ view_id: str = "system:all",
339
+ download_to_path: str | None = None,
340
+ wait_timeout_seconds: float | None = None,
341
+ ) -> dict[str, Any]:
342
+ normalized_app_key = str(app_key or "").strip()
343
+ normalized_view_id = str(view_id or "").strip() or "system:all"
344
+ if not normalized_app_key:
345
+ return self._failed_export_result(
346
+ error_code="EXPORT_START_FAILED",
347
+ message="app_key is required",
348
+ extra={"status": "failed", "view_id": normalized_view_id},
349
+ )
350
+ timeout_seconds = self._normalize_timeout_seconds(wait_timeout_seconds)
351
+
352
+ def runner(_session_profile, context):
353
+ start_result = self.record_export_start(
354
+ profile=profile,
355
+ app_key=normalized_app_key,
356
+ view_id=normalized_view_id,
357
+ )
358
+ if not bool(start_result.get("ok")):
359
+ return start_result
360
+ export_handle = str(start_result.get("export_handle") or "")
361
+ deadline = monotonic() + timeout_seconds
362
+ last_snapshot: dict[str, Any] | None = None
363
+ while monotonic() < deadline:
364
+ local_job = self._job_store.get(export_handle)
365
+ if local_job is None:
366
+ return self._failed_export_result(
367
+ error_code="EXPORT_HANDLE_UNKNOWN",
368
+ message="export_handle is missing or expired",
369
+ extra={"status": "failed", "export_handle": export_handle},
370
+ )
371
+ snapshot = self._resolve_export_snapshot(context, local_job)
372
+ last_snapshot = snapshot
373
+ normalized_status = str(snapshot.get("status") or "unknown")
374
+ if normalized_status == "succeeded":
375
+ effective_download_path = download_to_path or str(Path.cwd())
376
+ get_result = self.record_export_get(
377
+ profile=profile,
378
+ export_handle=export_handle,
379
+ download_to_path=effective_download_path,
380
+ )
381
+ if bool(get_result.get("ok")):
382
+ return get_result
383
+ return {
384
+ **get_result,
385
+ "export_handle": export_handle,
386
+ "file_urls": snapshot.get("file_urls") or [],
387
+ "file_names": snapshot.get("file_names") or [],
388
+ }
389
+ if normalized_status == "failed":
390
+ return {
391
+ "ok": False,
392
+ "status": "failed",
393
+ "error_code": str(snapshot.get("error_code") or "EXPORT_FAILED"),
394
+ "message": str(snapshot.get("message") or "export failed"),
395
+ "export_handle": export_handle,
396
+ "app_key": str(local_job.get("app_key") or ""),
397
+ "view_id": str(local_job.get("view_id") or ""),
398
+ "num": snapshot.get("num"),
399
+ "process_status": snapshot.get("process_status"),
400
+ "audit_record_status": snapshot.get("audit_record_status"),
401
+ "file_urls": snapshot.get("file_urls") or [],
402
+ "file_names": snapshot.get("file_names") or [],
403
+ "downloaded_files": [],
404
+ "warnings": snapshot.get("warnings") or [],
405
+ "verification": snapshot.get("verification") or {},
406
+ }
407
+ if normalized_status == "unknown":
408
+ warning_codes = {
409
+ str(item.get("code") or "")
410
+ for item in cast(list[JSONObject], snapshot.get("warnings") or [])
411
+ if isinstance(item, dict)
412
+ }
413
+ if "EXPORT_HISTORY_AMBIGUOUS" in warning_codes:
414
+ return {
415
+ "ok": True,
416
+ "status": "unknown",
417
+ "export_handle": export_handle,
418
+ "app_key": str(local_job.get("app_key") or ""),
419
+ "view_id": str(local_job.get("view_id") or ""),
420
+ "file_urls": snapshot.get("file_urls") or [],
421
+ "file_names": snapshot.get("file_names") or [],
422
+ "downloaded_files": [],
423
+ "warnings": snapshot.get("warnings") or [],
424
+ "verification": snapshot.get("verification") or {},
425
+ "message": "export result could not be matched uniquely",
426
+ }
427
+ remaining = deadline - monotonic()
428
+ if remaining <= 0:
429
+ break
430
+ sleep(min(EXPORT_POLL_INTERVAL_SECONDS, remaining))
431
+ timeout_status = "running"
432
+ if isinstance(last_snapshot, dict) and str(last_snapshot.get("status") or "").strip():
433
+ timeout_status = str(last_snapshot.get("status") or timeout_status)
434
+ warnings = list(cast(list[JSONObject], start_result.get("warnings") or []))
435
+ warnings.append(
436
+ {
437
+ "code": "EXPORT_WAIT_TIMEOUT",
438
+ "message": "export is still running; reuse export_handle with record_export_status_get or record_export_get",
439
+ }
440
+ )
441
+ return {
442
+ "ok": True,
443
+ "status": timeout_status,
444
+ "export_handle": str(start_result.get("export_handle") or ""),
445
+ "app_key": normalized_app_key,
446
+ "view_id": str(start_result.get("view_id") or normalized_view_id),
447
+ "file_urls": (last_snapshot or {}).get("file_urls") or [],
448
+ "file_names": (last_snapshot or {}).get("file_names") or [],
449
+ "downloaded_files": [],
450
+ "warnings": warnings,
451
+ "verification": (last_snapshot or {}).get("verification") or start_result.get("verification") or {},
452
+ "message": "export did not finish before wait_timeout_seconds",
453
+ }
454
+
455
+ try:
456
+ return self._run(profile, runner)
457
+ except RuntimeError as exc:
458
+ return self._runtime_error_as_result(
459
+ exc,
460
+ error_code="EXPORT_DIRECT_FAILED",
461
+ extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
462
+ )
463
+
464
+ def _build_export_filter_bean(self, resolved_view: AccessibleViewRoute) -> JSONObject:
465
+ filter_payload: JSONObject = {}
466
+ if resolved_view.kind == "system" and resolved_view.list_type is not None:
467
+ filter_payload["type"] = resolved_view.list_type
468
+ return {
469
+ "filter": filter_payload,
470
+ # Backend export code later auto-unboxes this field to primitive boolean.
471
+ # Sending explicit false avoids a null -> NPE -> 41100 failure path.
472
+ "auditRecordStatus": False,
473
+ }
474
+
475
+ def _build_export_config(
476
+ self,
477
+ *,
478
+ profile: str,
479
+ context,
480
+ app_key: str,
481
+ resolved_view: AccessibleViewRoute,
482
+ ) -> tuple[JSONObject, list[JSONObject]]: # type: ignore[no-untyped-def]
483
+ browse_scope = self._record_tools._build_browse_write_scope(
484
+ profile,
485
+ context,
486
+ app_key,
487
+ resolved_view,
488
+ force_refresh=False,
489
+ )
490
+ index = browse_scope["index"]
491
+ visible_question_ids = cast(set[int], browse_scope.get("visible_question_ids") or set())
492
+ ordered_visible_fields = [
493
+ field
494
+ for field in self._record_tools._schema_fields_for_mode(
495
+ profile,
496
+ context,
497
+ app_key,
498
+ index,
499
+ schema_mode="browse",
500
+ resolved_view=resolved_view,
501
+ )
502
+ if field.que_id in visible_question_ids and field.que_type not in LAYOUT_ONLY_QUE_TYPES
503
+ ]
504
+ if not ordered_visible_fields:
505
+ ordered_visible_fields = [
506
+ field
507
+ for field in cast(Any, index).by_id.values()
508
+ if field.que_type not in LAYOUT_ONLY_QUE_TYPES
509
+ ]
510
+ question_export_config_list: list[JSONObject] = []
511
+ warnings: list[JSONObject] = []
512
+ for field in ordered_visible_fields:
513
+ if field.que_type is None:
514
+ warnings.append(
515
+ {
516
+ "code": "EXPORT_FIELD_TYPE_UNAVAILABLE",
517
+ "message": f"Skipped field '{field.que_title}' because its field type could not be resolved.",
518
+ }
519
+ )
520
+ continue
521
+ question_export_config_list.append(
522
+ {
523
+ "queId": field.que_id,
524
+ "queTitle": field.que_title,
525
+ "queType": field.que_type,
526
+ "exportStyle": "default",
527
+ }
528
+ )
529
+ if not question_export_config_list:
530
+ raise QingflowApiError.config_error(
531
+ "record export could not determine exportable fields for the selected view",
532
+ details={"error_code": "EXPORT_CONFIG_UNAVAILABLE"},
533
+ )
534
+ return {"questionExportConfigList": question_export_config_list}, warnings
535
+
536
+ def _resolve_export_snapshot(
537
+ self,
538
+ context,
539
+ local_job: dict[str, Any],
540
+ ) -> dict[str, Any]: # type: ignore[no-untyped-def]
541
+ app_key = str(local_job.get("app_key") or "").strip()
542
+ process_payload = self._lookup_process_details(context, app_key=app_key)
543
+ history_page = self.backend.request(
544
+ "GET",
545
+ context,
546
+ "/app/apply/dataExport/record",
547
+ params={"appKey": app_key, "pageNum": 1, "pageSize": 100},
548
+ )
549
+ history_records = _extract_export_records(history_page)
550
+ matched_record, matched_by = _match_export_history_record(history_records, local_job=local_job)
551
+ if process_payload is not None:
552
+ normalized_status = _normalize_export_status(process_payload.get("processStatus") or process_payload.get("status"))
553
+ if normalized_status in {"queued", "running"}:
554
+ return {
555
+ "status": normalized_status,
556
+ "process_status": _coerce_int(process_payload.get("processStatus") or process_payload.get("status")),
557
+ "num": _coerce_int(process_payload.get("num")),
558
+ "error_code": process_payload.get("errorCode"),
559
+ "audit_record_status": process_payload.get("auditRecordStatus"),
560
+ "file_infos": _normalize_export_file_infos(process_payload.get("fileUrls")),
561
+ "file_urls": _extract_export_file_urls(process_payload.get("fileUrls")),
562
+ "file_names": _extract_export_file_names(process_payload.get("fileUrls")),
563
+ "warnings": [],
564
+ "verification": {
565
+ "current_process_visible": True,
566
+ "history_match_resolved": matched_record is not None,
567
+ },
568
+ "message": None,
569
+ }
570
+ if matched_record is None:
571
+ warning_code = "EXPORT_HISTORY_PENDING"
572
+ warning_message = "export has not appeared in export history yet"
573
+ if matched_by == "ambiguous":
574
+ warning_code = "EXPORT_HISTORY_AMBIGUOUS"
575
+ warning_message = "export result could not be matched uniquely in export history"
576
+ return {
577
+ "status": "unknown",
578
+ "process_status": None,
579
+ "num": None,
580
+ "error_code": None,
581
+ "audit_record_status": None,
582
+ "file_infos": [],
583
+ "file_urls": [],
584
+ "file_names": [],
585
+ "warnings": [{"code": warning_code, "message": warning_message}],
586
+ "verification": {
587
+ "current_process_visible": process_payload is not None,
588
+ "history_match_resolved": False,
589
+ },
590
+ "message": warning_message,
591
+ }
592
+ file_infos = _normalize_export_file_infos(matched_record.get("fileUrls"))
593
+ raw_process_status = _coerce_int(matched_record.get("processStatus"))
594
+ normalized_status = _normalize_export_status(raw_process_status)
595
+ message = None
596
+ if normalized_status == "failed":
597
+ message = "export failed"
598
+ return {
599
+ "status": normalized_status,
600
+ "process_status": raw_process_status,
601
+ "num": _coerce_int(matched_record.get("num")),
602
+ "error_code": matched_record.get("errorCode"),
603
+ "audit_record_status": matched_record.get("auditRecordStatus"),
604
+ "file_infos": file_infos,
605
+ "file_urls": [item.get("url") for item in file_infos if isinstance(item.get("url"), str)],
606
+ "file_names": [item.get("name") for item in file_infos if isinstance(item.get("name"), str)],
607
+ "warnings": [],
608
+ "verification": {
609
+ "current_process_visible": process_payload is not None,
610
+ "history_match_resolved": True,
611
+ "matched_by": matched_by,
612
+ },
613
+ "message": message,
614
+ }
615
+
616
+ def _lookup_process_details(self, context, *, app_key: str) -> JSONObject | None: # type: ignore[no-untyped-def]
617
+ try:
618
+ payload = self.backend.request(
619
+ "GET",
620
+ context,
621
+ f"/process/{app_key}/details",
622
+ params={"taskType": 2},
623
+ )
624
+ except QingflowApiError as exc:
625
+ if exc.backend_code in {40002, 40027}:
626
+ return None
627
+ raise
628
+ if isinstance(payload, dict):
629
+ nested = payload.get("data")
630
+ if isinstance(nested, dict):
631
+ payload = nested
632
+ if isinstance(payload.get("detail"), dict):
633
+ return cast(JSONObject, payload.get("detail"))
634
+ if any(key in payload for key in ("processStatus", "progress", "num", "fileUrls", "errorCode")):
635
+ return cast(JSONObject, payload)
636
+ return None
637
+
638
+ def _status_payload_from_snapshot(
639
+ self,
640
+ local_job: dict[str, Any],
641
+ export_handle: str,
642
+ snapshot: dict[str, Any],
643
+ ) -> dict[str, Any]:
644
+ normalized_status = str(snapshot.get("status") or "unknown")
645
+ ok = normalized_status not in {"failed"} or snapshot.get("error_code") in (None, "", 0)
646
+ if normalized_status == "failed":
647
+ ok = False
648
+ return {
649
+ "ok": ok,
650
+ "status": normalized_status,
651
+ "export_handle": export_handle,
652
+ "app_key": str(local_job.get("app_key") or ""),
653
+ "view_id": str(local_job.get("view_id") or ""),
654
+ "num": snapshot.get("num"),
655
+ "process_status": snapshot.get("process_status"),
656
+ "error_code": snapshot.get("error_code"),
657
+ "audit_record_status": snapshot.get("audit_record_status"),
658
+ "file_urls": snapshot.get("file_urls") or [],
659
+ "file_names": snapshot.get("file_names") or [],
660
+ "downloaded_files": [],
661
+ "warnings": snapshot.get("warnings") or [],
662
+ "verification": snapshot.get("verification") or {},
663
+ "message": snapshot.get("message"),
664
+ }
665
+
666
+ def _download_export_files(
667
+ self,
668
+ *,
669
+ file_infos: list[JSONObject],
670
+ download_to_path: str | None,
671
+ default_directory: str | None,
672
+ app_key: str,
673
+ view_id: str,
674
+ ) -> list[JSONObject]:
675
+ if download_to_path is None and default_directory is None:
676
+ return []
677
+ effective_hint = download_to_path or default_directory
678
+ assert effective_hint is not None
679
+ targets = _resolve_download_targets(
680
+ effective_hint,
681
+ file_infos=file_infos,
682
+ app_key=app_key,
683
+ view_id=view_id,
684
+ )
685
+ downloaded_files: list[JSONObject] = []
686
+ for file_info, target in zip(file_infos, targets, strict=False):
687
+ url = str(file_info.get("url") or "").strip()
688
+ if not url:
689
+ continue
690
+ content = self.backend.download_binary(url)
691
+ target.parent.mkdir(parents=True, exist_ok=True)
692
+ target.write_bytes(content)
693
+ downloaded_files.append(
694
+ {
695
+ "file_name": str(file_info.get("name") or target.name),
696
+ "path": str(target),
697
+ "url": url,
698
+ }
699
+ )
700
+ return downloaded_files
701
+
702
+ def _normalize_timeout_seconds(self, wait_timeout_seconds: float | None) -> float:
703
+ if wait_timeout_seconds is None:
704
+ return DEFAULT_EXPORT_TIMEOUT_SECONDS
705
+ try:
706
+ value = float(wait_timeout_seconds)
707
+ except (TypeError, ValueError):
708
+ return DEFAULT_EXPORT_TIMEOUT_SECONDS
709
+ return value if value > 0 else DEFAULT_EXPORT_TIMEOUT_SECONDS
710
+
711
+ def _failed_export_result(
712
+ self,
713
+ *,
714
+ error_code: str,
715
+ message: str,
716
+ extra: dict[str, Any] | None = None,
717
+ ) -> dict[str, Any]:
718
+ payload = {
719
+ "ok": False,
720
+ "status": "failed",
721
+ "error_code": error_code,
722
+ "export_handle": None,
723
+ "app_key": None,
724
+ "view_id": None,
725
+ "num": None,
726
+ "process_status": None,
727
+ "audit_record_status": None,
728
+ "file_urls": [],
729
+ "file_names": [],
730
+ "downloaded_files": [],
731
+ "warnings": [],
732
+ "verification": {},
733
+ "message": message,
734
+ }
735
+ if extra:
736
+ payload.update(extra)
737
+ return payload
738
+
739
+ def _runtime_error_as_result(
740
+ self,
741
+ error: RuntimeError,
742
+ *,
743
+ error_code: str,
744
+ extra: dict[str, Any] | None = None,
745
+ ) -> dict[str, Any]:
746
+ try:
747
+ payload = json.loads(str(error))
748
+ except json.JSONDecodeError:
749
+ payload = {"message": str(error)}
750
+ response = self._failed_export_result(
751
+ error_code=((payload.get("details") or {}) if isinstance(payload.get("details"), dict) else {}).get("error_code") or error_code,
752
+ message=str(payload.get("message") or str(error)),
753
+ )
754
+ if extra:
755
+ response.update(extra)
756
+ return response
757
+
758
+
759
+ def _extract_export_records(payload: Any) -> list[JSONObject]:
760
+ if isinstance(payload, dict):
761
+ for key in ("list", "records", "items"):
762
+ value = payload.get(key)
763
+ if isinstance(value, list):
764
+ return [item for item in value if isinstance(item, dict)]
765
+ if isinstance(payload, list):
766
+ return [item for item in payload if isinstance(item, dict)]
767
+ return []
768
+
769
+
770
+ def _match_export_history_record(
771
+ records: list[JSONObject],
772
+ *,
773
+ local_job: dict[str, Any],
774
+ ) -> tuple[JSONObject | None, str | None]:
775
+ uid = _extract_operate_user_uid(local_job)
776
+ started_at = _parse_utc(local_job.get("started_at"))
777
+ candidates = records
778
+ if uid is not None:
779
+ candidates = [item for item in candidates if _extract_operate_user_uid(item.get("operateUser")) == uid]
780
+ if started_at is not None:
781
+ started_at = started_at.replace(microsecond=0)
782
+ parsed_candidates: list[tuple[JSONObject, datetime]] = []
783
+ for item in candidates:
784
+ operate_time = _parse_utc(item.get("operateTime")) or _parse_utc(item.get("operate_time"))
785
+ if operate_time is None:
786
+ continue
787
+ parsed_candidates.append((item, operate_time))
788
+ exact_or_after = [item for item, operate_time in parsed_candidates if operate_time >= started_at]
789
+ if len(exact_or_after) == 1:
790
+ return exact_or_after[0], "operate_user_started_at"
791
+ if len(exact_or_after) > 1:
792
+ return None, "ambiguous"
793
+ skew_tolerant = [
794
+ item
795
+ for item, operate_time in parsed_candidates
796
+ if operate_time >= (started_at - timedelta(seconds=5))
797
+ ]
798
+ if len(skew_tolerant) == 1:
799
+ return skew_tolerant[0], "operate_user_started_at_skew"
800
+ if len(skew_tolerant) > 1:
801
+ return None, "ambiguous"
802
+ candidates = []
803
+ if len(candidates) == 1:
804
+ return candidates[0], "operate_user_started_at"
805
+ if len(candidates) > 1:
806
+ return None, "ambiguous"
807
+ return None, None
808
+
809
+
810
+ def _normalize_export_status(value: Any) -> str:
811
+ status_code = _coerce_int(value)
812
+ if status_code is not None:
813
+ return EXPORT_STATUS_BY_PROCESS_STATUS.get(status_code, "unknown")
814
+ text = str(value or "").strip().lower()
815
+ if text in {"queued", "running", "succeeded", "failed", "unknown"}:
816
+ return text
817
+ if text in {"line_up", "lineup"}:
818
+ return "queued"
819
+ if text in {"execute", "executing", "processing"}:
820
+ return "running"
821
+ if text in {"success", "completed"}:
822
+ return "succeeded"
823
+ if text in {"fail", "partly_fail", "partial_fail"}:
824
+ return "failed"
825
+ return "unknown"
826
+
827
+
828
+ def _normalize_export_file_infos(value: Any) -> list[JSONObject]:
829
+ if not isinstance(value, list):
830
+ return []
831
+ items: list[JSONObject] = []
832
+ for item in value:
833
+ if not isinstance(item, dict):
834
+ continue
835
+ url = str(item.get("url") or "").strip()
836
+ name = str(item.get("name") or item.get("fileName") or "").strip()
837
+ payload: JSONObject = {}
838
+ if url:
839
+ payload["url"] = url
840
+ if name:
841
+ payload["name"] = name
842
+ if payload:
843
+ items.append(payload)
844
+ return items
845
+
846
+
847
+ def _extract_export_file_urls(value: Any) -> list[str]:
848
+ return [str(item.get("url") or "").strip() for item in _normalize_export_file_infos(value) if str(item.get("url") or "").strip()]
849
+
850
+
851
+ def _extract_export_file_names(value: Any) -> list[str]:
852
+ return [str(item.get("name") or "").strip() for item in _normalize_export_file_infos(value) if str(item.get("name") or "").strip()]
853
+
854
+
855
+ def _extract_operate_user_uid(value: Any) -> int | None:
856
+ if isinstance(value, dict):
857
+ for key in ("uid", "userId", "id"):
858
+ uid = _coerce_int(value.get(key))
859
+ if uid is not None:
860
+ return uid
861
+ return _coerce_int(value)
862
+
863
+
864
+ def _parse_utc(value: Any) -> datetime | None:
865
+ text = str(value or "").strip()
866
+ if not text:
867
+ return None
868
+ normalized = text.replace("Z", "+00:00")
869
+ try:
870
+ parsed = datetime.fromisoformat(normalized)
871
+ except ValueError:
872
+ return None
873
+ if parsed.tzinfo is None:
874
+ return parsed.replace(tzinfo=LOCAL_TIMEZONE).astimezone(timezone.utc)
875
+ return parsed.astimezone(timezone.utc)
876
+
877
+
878
+ def _coerce_int(value: Any) -> int | None:
879
+ if value is None or value == "":
880
+ return None
881
+ try:
882
+ return int(value)
883
+ except (TypeError, ValueError):
884
+ return None
885
+
886
+
887
+ def _resolve_download_targets(
888
+ destination_hint: str,
889
+ *,
890
+ file_infos: list[JSONObject],
891
+ app_key: str,
892
+ view_id: str,
893
+ ) -> list[Path]:
894
+ path = Path(destination_hint).expanduser()
895
+ timestamp = _utc_now().strftime("%Y%m%dT%H%M%SZ")
896
+ if len(file_infos) > 1:
897
+ if path.exists() and not path.is_dir():
898
+ raise QingflowApiError.config_error("download_to_path must be a directory when multiple export files are returned")
899
+ if not path.exists() and path.suffix:
900
+ raise QingflowApiError.config_error("download_to_path must be a directory when multiple export files are returned")
901
+ path.mkdir(parents=True, exist_ok=True)
902
+ return [
903
+ path / _choose_export_file_name(
904
+ file_info,
905
+ app_key=app_key,
906
+ view_id=view_id,
907
+ timestamp=timestamp,
908
+ index=index,
909
+ )
910
+ for index, file_info in enumerate(file_infos, start=1)
911
+ ]
912
+ if path.exists() and path.is_dir():
913
+ return [
914
+ path / _choose_export_file_name(
915
+ file_infos[0],
916
+ app_key=app_key,
917
+ view_id=view_id,
918
+ timestamp=timestamp,
919
+ index=1,
920
+ )
921
+ ]
922
+ if path.suffix or path.exists():
923
+ return [path]
924
+ path.mkdir(parents=True, exist_ok=True)
925
+ return [
926
+ path / _choose_export_file_name(
927
+ file_infos[0],
928
+ app_key=app_key,
929
+ view_id=view_id,
930
+ timestamp=timestamp,
931
+ index=1,
932
+ )
933
+ ]
934
+
935
+
936
+ def _choose_export_file_name(
937
+ file_info: JSONObject,
938
+ *,
939
+ app_key: str,
940
+ view_id: str,
941
+ timestamp: str,
942
+ index: int,
943
+ ) -> str:
944
+ remote_name = str(file_info.get("name") or "").strip()
945
+ if remote_name:
946
+ sanitized = _sanitize_filename(remote_name)
947
+ if sanitized:
948
+ return sanitized
949
+ suffix = ".xlsx"
950
+ safe_view = _sanitize_filename(view_id.replace(":", "_")) or "view"
951
+ return f"{_sanitize_filename(app_key) or 'app'}_{safe_view}_{timestamp}_{index}{suffix}"
952
+
953
+
954
+ def _sanitize_filename(value: str) -> str:
955
+ base = _SAFE_FILE_CHARS.sub("_", value).strip("._")
956
+ return base or ""
957
+
958
+
959
+ def _utc_now() -> datetime:
960
+ return datetime.now(timezone.utc)