@josephyan/qingflow-app-builder-mcp 0.2.0-beta.4 → 0.2.0-beta.41

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 (39) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +3 -1
  4. package/skills/qingflow-app-builder/SKILL.md +154 -22
  5. package/skills/qingflow-app-builder/references/create-app.md +51 -21
  6. package/skills/qingflow-app-builder/references/environments.md +1 -1
  7. package/skills/qingflow-app-builder/references/flow-actors-and-permissions.md +123 -0
  8. package/skills/qingflow-app-builder/references/gotchas.md +28 -1
  9. package/skills/qingflow-app-builder/references/solution-playbooks.md +14 -12
  10. package/skills/qingflow-app-builder/references/tool-selection.md +45 -17
  11. package/skills/qingflow-app-builder/references/update-flow.md +112 -25
  12. package/skills/qingflow-app-builder/references/update-layout.md +11 -24
  13. package/skills/qingflow-app-builder/references/update-schema.md +1 -23
  14. package/skills/qingflow-app-builder/references/update-views.md +87 -21
  15. package/src/qingflow_mcp/__init__.py +1 -1
  16. package/src/qingflow_mcp/backend_client.py +189 -0
  17. package/src/qingflow_mcp/builder_facade/models.py +584 -1
  18. package/src/qingflow_mcp/builder_facade/service.py +4698 -262
  19. package/src/qingflow_mcp/config.py +39 -0
  20. package/src/qingflow_mcp/import_store.py +121 -0
  21. package/src/qingflow_mcp/list_type_labels.py +24 -0
  22. package/src/qingflow_mcp/server.py +131 -16
  23. package/src/qingflow_mcp/server_app_builder.py +132 -72
  24. package/src/qingflow_mcp/server_app_user.py +143 -187
  25. package/src/qingflow_mcp/solution/compiler/form_compiler.py +14 -4
  26. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +41 -2
  27. package/src/qingflow_mcp/solution/executor.py +44 -7
  28. package/src/qingflow_mcp/tools/ai_builder_tools.py +1567 -144
  29. package/src/qingflow_mcp/tools/app_tools.py +243 -14
  30. package/src/qingflow_mcp/tools/approval_tools.py +411 -76
  31. package/src/qingflow_mcp/tools/directory_tools.py +203 -31
  32. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  33. package/src/qingflow_mcp/tools/file_tools.py +1 -0
  34. package/src/qingflow_mcp/tools/import_tools.py +1164 -0
  35. package/src/qingflow_mcp/tools/portal_tools.py +31 -0
  36. package/src/qingflow_mcp/tools/record_tools.py +4943 -1025
  37. package/src/qingflow_mcp/tools/task_context_tools.py +1335 -0
  38. package/src/qingflow_mcp/tools/task_tools.py +376 -225
  39. package/src/qingflow_mcp/tools/workflow_tools.py +78 -4
@@ -0,0 +1,1164 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import mimetypes
6
+ import shutil
7
+ from copy import deepcopy
8
+ from datetime import datetime, timedelta, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+ from mcp.server.fastmcp import FastMCP
14
+ from openpyxl import load_workbook
15
+
16
+ from ..config import DEFAULT_PROFILE
17
+ from ..errors import QingflowApiError
18
+ from ..import_store import ImportJobStore, ImportVerificationStore
19
+ from ..json_types import JSONObject
20
+ from .base import ToolBase
21
+ from .file_tools import FileTools
22
+ from .record_tools import RecordTools
23
+
24
+
25
+ SUPPORTED_IMPORT_EXTENSIONS = {".xlsx", ".xls"}
26
+ REPAIRABLE_IMPORT_EXTENSIONS = {".xlsx"}
27
+ SAFE_REPAIRS = {
28
+ "normalize_headers",
29
+ "trim_trailing_blank_rows",
30
+ "normalize_enum_values",
31
+ "normalize_date_formats",
32
+ "normalize_number_formats",
33
+ "normalize_url_cells",
34
+ }
35
+
36
+
37
+ class ImportTools(ToolBase):
38
+ def __init__(
39
+ self,
40
+ sessions,
41
+ backend,
42
+ *,
43
+ verification_store: ImportVerificationStore | None = None,
44
+ job_store: ImportJobStore | None = None,
45
+ ) -> None:
46
+ super().__init__(sessions, backend)
47
+ self._record_tools = RecordTools(sessions, backend)
48
+ self._file_tools = FileTools(sessions, backend)
49
+ self._verification_store = verification_store or ImportVerificationStore()
50
+ self._job_store = job_store or ImportJobStore()
51
+
52
+ def register(self, mcp: FastMCP) -> None:
53
+ @mcp.tool(description="Get the official app import template and the expected applicant import columns.")
54
+ def record_import_template_get(
55
+ profile: str = DEFAULT_PROFILE,
56
+ app_key: str = "",
57
+ download_to_path: str | None = None,
58
+ ) -> dict[str, Any]:
59
+ return self.record_import_template_get(
60
+ profile=profile,
61
+ app_key=app_key,
62
+ download_to_path=download_to_path,
63
+ )
64
+
65
+ @mcp.tool(description="Verify a local Excel import file and produce the only verification_id allowed for import start.")
66
+ def record_import_verify(
67
+ profile: str = DEFAULT_PROFILE,
68
+ app_key: str = "",
69
+ file_path: str = "",
70
+ ) -> dict[str, Any]:
71
+ return self.record_import_verify(
72
+ profile=profile,
73
+ app_key=app_key,
74
+ file_path=file_path,
75
+ )
76
+
77
+ @mcp.tool(description="Repair a local .xlsx import file after explicit user authorization, then re-verify it.")
78
+ def record_import_repair_local(
79
+ profile: str = DEFAULT_PROFILE,
80
+ verification_id: str = "",
81
+ authorized_file_modification: bool = False,
82
+ output_path: str | None = None,
83
+ selected_repairs: list[str] | None = None,
84
+ ) -> dict[str, Any]:
85
+ return self.record_import_repair_local(
86
+ profile=profile,
87
+ verification_id=verification_id,
88
+ authorized_file_modification=authorized_file_modification,
89
+ output_path=output_path,
90
+ selected_repairs=selected_repairs,
91
+ )
92
+
93
+ @mcp.tool(description="Start import from a successful verification_id. being_enter_auditing must be passed explicitly.")
94
+ def record_import_start(
95
+ profile: str = DEFAULT_PROFILE,
96
+ app_key: str = "",
97
+ verification_id: str = "",
98
+ being_enter_auditing: bool | None = None,
99
+ view_key: str | None = None,
100
+ ) -> dict[str, Any]:
101
+ return self.record_import_start(
102
+ profile=profile,
103
+ app_key=app_key,
104
+ verification_id=verification_id,
105
+ being_enter_auditing=being_enter_auditing,
106
+ view_key=view_key,
107
+ )
108
+
109
+ @mcp.tool(description="Get import status by process_id_str, import_id, or the latest remembered import in the current app.")
110
+ def record_import_status_get(
111
+ profile: str = DEFAULT_PROFILE,
112
+ app_key: str = "",
113
+ import_id: str | None = None,
114
+ process_id_str: str | None = None,
115
+ ) -> dict[str, Any]:
116
+ return self.record_import_status_get(
117
+ profile=profile,
118
+ app_key=app_key,
119
+ import_id=import_id,
120
+ process_id_str=process_id_str,
121
+ )
122
+
123
+ def record_import_template_get(
124
+ self,
125
+ *,
126
+ profile: str,
127
+ app_key: str,
128
+ download_to_path: str | None = None,
129
+ ) -> dict[str, Any]:
130
+ if not app_key.strip():
131
+ return self._failed_template_result(app_key=app_key, error_code="IMPORT_TEMPLATE_UNAUTHORIZED", message="app_key is required")
132
+
133
+ def runner(session_profile, context):
134
+ expected_columns, schema_fingerprint = self._expected_import_columns(profile, context, app_key)
135
+ try:
136
+ payload = self.backend.request("GET", context, f"/app/{app_key}/apply/excelTemplate")
137
+ except QingflowApiError as exc:
138
+ return self._failed_template_result(
139
+ app_key=app_key,
140
+ error_code="IMPORT_TEMPLATE_UNAUTHORIZED",
141
+ message=exc.message,
142
+ request_route=self.backend.describe_route(context),
143
+ )
144
+ template_url = _pick_template_url(payload)
145
+ if not template_url:
146
+ return self._failed_template_result(
147
+ app_key=app_key,
148
+ error_code="IMPORT_TEMPLATE_UNAUTHORIZED",
149
+ message="template endpoint did not return excelUrl",
150
+ request_route=self.backend.describe_route(context),
151
+ )
152
+ downloaded_to_path = None
153
+ warnings: list[JSONObject] = []
154
+ verification = {
155
+ "schema_fingerprint": schema_fingerprint,
156
+ "template_url_resolved": True,
157
+ "template_downloaded": False,
158
+ }
159
+ if download_to_path:
160
+ destination = _resolve_template_download_path(download_to_path, app_key=app_key)
161
+ destination.parent.mkdir(parents=True, exist_ok=True)
162
+ content = self.backend.download_binary(template_url)
163
+ destination.write_bytes(content)
164
+ downloaded_to_path = str(destination)
165
+ verification["template_downloaded"] = True
166
+ return {
167
+ "ok": True,
168
+ "status": "success",
169
+ "app_key": app_key,
170
+ "ws_id": session_profile.selected_ws_id,
171
+ "request_route": self.backend.describe_route(context),
172
+ "template_url": template_url,
173
+ "downloaded_to_path": downloaded_to_path,
174
+ "expected_columns": expected_columns,
175
+ "schema_fingerprint": schema_fingerprint,
176
+ "warnings": warnings,
177
+ "verification": verification,
178
+ }
179
+
180
+ try:
181
+ return self._run(profile, runner)
182
+ except RuntimeError as exc:
183
+ return self._runtime_error_as_result(exc, error_code="IMPORT_TEMPLATE_UNAUTHORIZED")
184
+
185
+ def record_import_verify(
186
+ self,
187
+ *,
188
+ profile: str,
189
+ app_key: str,
190
+ file_path: str,
191
+ ) -> dict[str, Any]:
192
+ if not app_key.strip():
193
+ return self._failed_verify_result(app_key=app_key, file_path=file_path, error_code="IMPORT_VERIFICATION_FAILED", message="app_key is required")
194
+ path = Path(file_path).expanduser()
195
+ if not path.is_file():
196
+ return self._failed_verify_result(app_key=app_key, file_path=file_path, error_code="IMPORT_VERIFICATION_FAILED", message="file_path must point to an existing file")
197
+
198
+ def runner(session_profile, context):
199
+ expected_columns, schema_fingerprint = self._expected_import_columns(profile, context, app_key)
200
+ local_check = self._local_verify(
201
+ path=path,
202
+ app_key=app_key,
203
+ expected_columns=expected_columns,
204
+ schema_fingerprint=schema_fingerprint,
205
+ )
206
+ warnings = deepcopy(local_check["warnings"])
207
+ issues = deepcopy(local_check["issues"])
208
+ can_import = bool(local_check["can_import"])
209
+ backend_verification = None
210
+ if can_import:
211
+ try:
212
+ payload = self.backend.request_multipart(
213
+ "POST",
214
+ context,
215
+ f"/app/{app_key}/upload/verification",
216
+ files={
217
+ "file": (
218
+ path.name,
219
+ path.read_bytes(),
220
+ mimetypes.guess_type(path.name)[0] or "application/octet-stream",
221
+ )
222
+ },
223
+ )
224
+ if isinstance(payload, dict):
225
+ backend_verification = payload
226
+ else:
227
+ backend_verification = {}
228
+ if not bool(backend_verification.get("beingValidated", True)):
229
+ can_import = False
230
+ issues.append(
231
+ _issue(
232
+ "BACKEND_IMPORT_VERIFICATION_REJECTED",
233
+ "Backend verification rejected the file for import.",
234
+ severity="error",
235
+ )
236
+ )
237
+ except QingflowApiError as exc:
238
+ can_import = False
239
+ issues.append(
240
+ _issue(
241
+ "BACKEND_IMPORT_VERIFICATION_FAILED",
242
+ exc.message or "Backend import verification failed.",
243
+ severity="error",
244
+ )
245
+ )
246
+ warnings.append(
247
+ {
248
+ "code": "IMPORT_VERIFICATION_FAILED",
249
+ "message": "Backend verification failed; the file cannot be imported until verification succeeds.",
250
+ }
251
+ )
252
+ verification_id = str(uuid4())
253
+ verification_payload = {
254
+ "id": verification_id,
255
+ "created_at": _utc_now().isoformat(),
256
+ "profile": profile,
257
+ "app_key": app_key,
258
+ "file_path": str(path.resolve()),
259
+ "file_name": path.name,
260
+ "file_sha256": local_check["file_sha256"],
261
+ "file_size": local_check["file_size"],
262
+ "schema_fingerprint": schema_fingerprint,
263
+ "can_import": can_import,
264
+ "issues": issues,
265
+ "warnings": warnings,
266
+ "apply_rows": backend_verification.get("applyRows") if isinstance(backend_verification, dict) else None,
267
+ "backend_verification": backend_verification,
268
+ "local_precheck": local_check,
269
+ }
270
+ self._verification_store.put(verification_id, verification_payload)
271
+ return {
272
+ "ok": True,
273
+ "status": "success" if can_import else "failed",
274
+ "error_code": None if can_import else (local_check.get("error_code") or "IMPORT_VERIFICATION_FAILED"),
275
+ "can_import": can_import,
276
+ "verification_id": verification_id,
277
+ "file_path": str(path.resolve()),
278
+ "file_name": path.name,
279
+ "file_sha256": local_check["file_sha256"],
280
+ "file_size": local_check["file_size"],
281
+ "schema_fingerprint": schema_fingerprint,
282
+ "apply_rows": backend_verification.get("applyRows") if isinstance(backend_verification, dict) else None,
283
+ "issues": issues,
284
+ "repair_suggestions": local_check["repair_suggestions"],
285
+ "warnings": warnings,
286
+ "verification": {
287
+ "local_precheck_passed": bool(local_check["local_precheck_passed"]),
288
+ "backend_verification_passed": can_import and isinstance(backend_verification, dict),
289
+ "schema_fingerprint": schema_fingerprint,
290
+ "file_sha256": local_check["file_sha256"],
291
+ "file_format": local_check["extension"],
292
+ "local_precheck_limited": bool(local_check["local_precheck_limited"]),
293
+ },
294
+ }
295
+
296
+ try:
297
+ return self._run(profile, runner)
298
+ except RuntimeError as exc:
299
+ return self._runtime_error_as_result(exc, error_code="IMPORT_VERIFICATION_FAILED", extra={"can_import": False})
300
+
301
+ def record_import_repair_local(
302
+ self,
303
+ *,
304
+ profile: str,
305
+ verification_id: str,
306
+ authorized_file_modification: bool,
307
+ output_path: str | None = None,
308
+ selected_repairs: list[str] | None = None,
309
+ ) -> dict[str, Any]:
310
+ if not verification_id.strip():
311
+ return self._failed_repair_result(error_code="IMPORT_VERIFICATION_FAILED", message="verification_id is required")
312
+ if not authorized_file_modification:
313
+ return self._failed_repair_result(
314
+ error_code="IMPORT_REPAIR_NOT_AUTHORIZED",
315
+ message="record_import_repair_local requires authorized_file_modification=true",
316
+ )
317
+ unknown_repairs = sorted({item for item in (selected_repairs or []) if item not in SAFE_REPAIRS})
318
+ if unknown_repairs:
319
+ return self._failed_repair_result(
320
+ error_code="IMPORT_REPAIR_FORMAT_UNSUPPORTED",
321
+ message=f"unknown selected_repairs: {', '.join(unknown_repairs)}",
322
+ )
323
+
324
+ def runner(_session_profile, context):
325
+ stored = self._verification_store.get(verification_id)
326
+ if stored is None:
327
+ return self._failed_repair_result(error_code="IMPORT_VERIFICATION_STALE", message="verification_id is missing or expired")
328
+ source_path = Path(str(stored["file_path"]))
329
+ extension = source_path.suffix.lower()
330
+ if extension not in REPAIRABLE_IMPORT_EXTENSIONS:
331
+ return self._failed_repair_result(
332
+ error_code="IMPORT_REPAIR_FORMAT_UNSUPPORTED",
333
+ message="record_import_repair_local v1 only supports .xlsx files",
334
+ extra={"source_file_path": str(source_path)},
335
+ )
336
+ expected_columns, _ = self._expected_import_columns(profile, context, str(stored["app_key"]))
337
+ normalized_repairs = set(selected_repairs or SAFE_REPAIRS)
338
+ destination = _resolve_repaired_output_path(source_path, output_path=output_path)
339
+ destination.parent.mkdir(parents=True, exist_ok=True)
340
+ shutil.copy2(source_path, destination)
341
+
342
+ workbook = load_workbook(destination)
343
+ sheet = workbook[workbook.sheetnames[0]]
344
+ applied_repairs: list[str] = []
345
+ skipped_repairs: list[str] = []
346
+ if "normalize_headers" in normalized_repairs:
347
+ if _repair_headers(sheet, expected_columns):
348
+ applied_repairs.append("normalize_headers")
349
+ else:
350
+ skipped_repairs.append("normalize_headers")
351
+ if "trim_trailing_blank_rows" in normalized_repairs:
352
+ if _trim_trailing_blank_rows(sheet):
353
+ applied_repairs.append("trim_trailing_blank_rows")
354
+ else:
355
+ skipped_repairs.append("trim_trailing_blank_rows")
356
+ if "normalize_enum_values" in normalized_repairs:
357
+ if _normalize_enum_values(sheet, expected_columns):
358
+ applied_repairs.append("normalize_enum_values")
359
+ else:
360
+ skipped_repairs.append("normalize_enum_values")
361
+ if "normalize_date_formats" in normalized_repairs:
362
+ if _normalize_date_formats(sheet):
363
+ applied_repairs.append("normalize_date_formats")
364
+ else:
365
+ skipped_repairs.append("normalize_date_formats")
366
+ if "normalize_number_formats" in normalized_repairs:
367
+ if _normalize_number_formats(sheet):
368
+ applied_repairs.append("normalize_number_formats")
369
+ else:
370
+ skipped_repairs.append("normalize_number_formats")
371
+ if "normalize_url_cells" in normalized_repairs:
372
+ if _normalize_url_cells(sheet):
373
+ applied_repairs.append("normalize_url_cells")
374
+ else:
375
+ skipped_repairs.append("normalize_url_cells")
376
+ workbook.save(destination)
377
+
378
+ verification_result = self.record_import_verify(
379
+ profile=profile,
380
+ app_key=str(stored["app_key"]),
381
+ file_path=str(destination),
382
+ )
383
+ new_verification_id = verification_result.get("verification_id")
384
+ return {
385
+ "ok": bool(verification_result.get("ok")),
386
+ "status": verification_result.get("status"),
387
+ "error_code": verification_result.get("error_code"),
388
+ "source_file_path": str(source_path),
389
+ "repaired_file_path": str(destination),
390
+ "applied_repairs": applied_repairs,
391
+ "skipped_repairs": skipped_repairs,
392
+ "new_verification_id": new_verification_id,
393
+ "can_import_after_repair": bool(verification_result.get("can_import")),
394
+ "post_repair_issues": verification_result.get("issues", []),
395
+ "warnings": verification_result.get("warnings", []),
396
+ "verification": {
397
+ "source_preserved": True,
398
+ "repair_authorized": True,
399
+ "reverified": True,
400
+ "selected_repairs": sorted(normalized_repairs),
401
+ },
402
+ }
403
+
404
+ try:
405
+ return self._run(profile, runner)
406
+ except RuntimeError as exc:
407
+ return self._runtime_error_as_result(exc, error_code="IMPORT_REPAIR_FORMAT_UNSUPPORTED")
408
+
409
+ def record_import_start(
410
+ self,
411
+ *,
412
+ profile: str,
413
+ app_key: str,
414
+ verification_id: str,
415
+ being_enter_auditing: bool | None,
416
+ view_key: str | None = None,
417
+ ) -> dict[str, Any]:
418
+ if being_enter_auditing is None:
419
+ return self._failed_start_result(error_code="IMPORT_VERIFICATION_FAILED", message="being_enter_auditing must be passed explicitly")
420
+
421
+ def runner(session_profile, context):
422
+ stored = self._verification_store.get(verification_id)
423
+ if stored is None:
424
+ return self._failed_start_result(error_code="IMPORT_VERIFICATION_STALE", message="verification_id is missing or expired")
425
+ if str(stored.get("app_key")) != app_key:
426
+ return self._failed_start_result(error_code="IMPORT_VERIFICATION_STALE", message="verification_id does not belong to the requested app")
427
+ if not bool(stored.get("can_import")):
428
+ return self._failed_start_result(error_code="IMPORT_VERIFICATION_FAILED", message="verification_id is not importable", extra={"accepted": False})
429
+ current_path = Path(str(stored["file_path"]))
430
+ if not current_path.is_file():
431
+ return self._failed_start_result(error_code="IMPORT_VERIFICATION_STALE", message="verified file no longer exists")
432
+ current_sha256 = _sha256_file(current_path)
433
+ if current_sha256 != stored.get("file_sha256"):
434
+ return self._failed_start_result(
435
+ error_code="IMPORT_FILE_CHANGED_AFTER_VERIFY",
436
+ message="the file changed after verification; run record_import_verify again",
437
+ extra={"accepted": False},
438
+ )
439
+ _, current_schema_fingerprint = self._expected_import_columns(profile, context, app_key)
440
+ if current_schema_fingerprint != stored.get("schema_fingerprint"):
441
+ return self._failed_start_result(
442
+ error_code="IMPORT_SCHEMA_CHANGED_AFTER_VERIFY",
443
+ message="the applicant schema changed after verification; run record_import_verify again",
444
+ extra={"accepted": False},
445
+ )
446
+ upload_result = self._file_tools.file_upload_local(
447
+ profile=profile,
448
+ upload_kind="login",
449
+ file_path=str(current_path),
450
+ )
451
+ file_url = upload_result.get("download_url")
452
+ if not isinstance(file_url, str) or not file_url.strip():
453
+ return self._failed_start_result(error_code="IMPORT_VERIFICATION_FAILED", message="file upload did not return download_url")
454
+ try:
455
+ socket_result = self.backend.start_socket_data_import(
456
+ context,
457
+ app_key=app_key,
458
+ being_enter_auditing=bool(being_enter_auditing),
459
+ view_key=view_key,
460
+ excel_url=file_url,
461
+ excel_name=current_path.name,
462
+ )
463
+ except QingflowApiError as exc:
464
+ error_code = "IMPORT_SOCKET_ACK_TIMEOUT" if exc.details and exc.details.get("error_code") == "IMPORT_SOCKET_ACK_TIMEOUT" else "IMPORT_VERIFICATION_FAILED"
465
+ return self._failed_start_result(error_code=error_code, message=exc.message, extra={"accepted": False, "file_url": file_url})
466
+ import_id = str(socket_result.get("import_id") or "")
467
+ process_id_str = _normalize_optional_text(socket_result.get("process_id_str"))
468
+ started_at = _utc_now().isoformat()
469
+ self._job_store.put(
470
+ import_id,
471
+ {
472
+ "created_at": started_at,
473
+ "profile": profile,
474
+ "app_key": app_key,
475
+ "import_id": import_id,
476
+ "process_id_str": process_id_str,
477
+ "source_file_name": current_path.name,
478
+ "started_at": started_at,
479
+ "file_url": file_url,
480
+ "verification_id": verification_id,
481
+ },
482
+ )
483
+ warnings = deepcopy(socket_result.get("warnings", []))
484
+ return {
485
+ "ok": True,
486
+ "status": "accepted",
487
+ "accepted": True,
488
+ "import_id": import_id,
489
+ "process_id_str": process_id_str,
490
+ "source_file_name": current_path.name,
491
+ "file_url": file_url,
492
+ "warnings": warnings,
493
+ "verification": {
494
+ "verification_id_valid": True,
495
+ "file_hash_verified": True,
496
+ "schema_fingerprint_verified": True,
497
+ "upload_staged": True,
498
+ "import_acknowledged": bool(import_id),
499
+ },
500
+ }
501
+
502
+ try:
503
+ return self._run(profile, runner)
504
+ except RuntimeError as exc:
505
+ return self._runtime_error_as_result(exc, error_code="IMPORT_VERIFICATION_FAILED", extra={"accepted": False})
506
+
507
+ def record_import_status_get(
508
+ self,
509
+ *,
510
+ profile: str,
511
+ app_key: str,
512
+ import_id: str | None = None,
513
+ process_id_str: str | None = None,
514
+ ) -> dict[str, Any]:
515
+ if not app_key.strip():
516
+ return self._failed_status_result(error_code="IMPORT_STATUS_AMBIGUOUS", message="app_key is required")
517
+
518
+ def runner(_session_profile, context):
519
+ local_job = None
520
+ normalized_import_id = _normalize_optional_text(import_id)
521
+ normalized_process_id = _normalize_optional_text(process_id_str)
522
+ if normalized_import_id:
523
+ local_job = self._job_store.get(normalized_import_id)
524
+ if local_job is None and normalized_process_id:
525
+ matches = [item for item in self._job_store.list() if _normalize_optional_text(item.get("process_id_str")) == normalized_process_id]
526
+ local_job = matches[0] if len(matches) == 1 else None
527
+ if local_job is None and not normalized_import_id and not normalized_process_id:
528
+ recent = [item for item in self._job_store.list() if str(item.get("app_key")) == app_key]
529
+ local_job = recent[0] if recent else None
530
+ page = self.backend.request(
531
+ "GET",
532
+ context,
533
+ "/app/apply/dataImport/record",
534
+ params={"appKey": app_key, "pageNum": 1, "pageSize": 100},
535
+ )
536
+ records = _extract_import_records(page)
537
+ matched_record, matched_by = _match_import_record(
538
+ records,
539
+ local_job=local_job,
540
+ process_id_str=normalized_process_id,
541
+ )
542
+ if matched_record is None:
543
+ return self._failed_status_result(
544
+ error_code="IMPORT_STATUS_AMBIGUOUS",
545
+ message="could not uniquely resolve an import record from the provided identifiers",
546
+ extra={"matched_by": matched_by},
547
+ )
548
+ normalized_process = _normalize_optional_text(
549
+ matched_record.get("processIdStr") or matched_record.get("processId") or matched_record.get("process_id_str")
550
+ )
551
+ if local_job is not None and normalized_import_id:
552
+ self._job_store.put(
553
+ normalized_import_id,
554
+ {
555
+ **local_job,
556
+ "created_at": local_job.get("created_at") or _utc_now().isoformat(),
557
+ "process_id_str": normalized_process,
558
+ },
559
+ )
560
+ total_rows = _coerce_int(matched_record.get("totalNumber") or matched_record.get("total_rows"))
561
+ success_rows = _coerce_int(matched_record.get("successNum") or matched_record.get("success_rows"))
562
+ failed_rows = _coerce_int(matched_record.get("errorNum") or matched_record.get("failed_rows"))
563
+ progress = _coerce_int(matched_record.get("importPercentage") or matched_record.get("progress"))
564
+ return {
565
+ "ok": True,
566
+ "status": _normalize_optional_text(matched_record.get("processStatus")) or "unknown",
567
+ "import_id": normalized_import_id or (local_job.get("import_id") if isinstance(local_job, dict) else None),
568
+ "process_id_str": normalized_process,
569
+ "matched_by": matched_by,
570
+ "source_file_name": matched_record.get("sourceFileName") or matched_record.get("source_file_name"),
571
+ "total_rows": total_rows,
572
+ "success_rows": success_rows,
573
+ "failed_rows": failed_rows,
574
+ "progress": progress,
575
+ "error_file_urls": _normalize_error_file_urls(matched_record.get("errorFileUrls")),
576
+ "operate_time": matched_record.get("operateTime"),
577
+ "operate_user": matched_record.get("operateUser"),
578
+ "warnings": [],
579
+ "verification": {
580
+ "status_lookup_completed": True,
581
+ "matched_by": matched_by,
582
+ "process_id_verified": bool(normalized_process),
583
+ },
584
+ }
585
+
586
+ try:
587
+ return self._run(profile, runner)
588
+ except RuntimeError as exc:
589
+ return self._runtime_error_as_result(exc, error_code="IMPORT_STATUS_AMBIGUOUS")
590
+
591
+ def _expected_import_columns(self, profile: str, context, app_key: str) -> tuple[list[JSONObject], str]: # type: ignore[no-untyped-def]
592
+ index = self._record_tools._get_field_index(profile, context, app_key, force_refresh=False)
593
+ ws_id = self.sessions.get_profile(profile).selected_ws_id
594
+ expected_columns: list[JSONObject] = []
595
+ for field in index.by_id.values():
596
+ payload = self._record_tools._schema_field_payload(
597
+ profile,
598
+ context,
599
+ field,
600
+ workflow_node_id=None,
601
+ ws_id=ws_id,
602
+ schema_mode="applicant",
603
+ )
604
+ if not bool(payload.get("writable")):
605
+ continue
606
+ expected_columns.append(
607
+ {
608
+ "field_id": payload["field_id"],
609
+ "title": payload["title"],
610
+ "que_type": payload["que_type"],
611
+ "required": bool(field.required),
612
+ "write_kind": payload["write_kind"],
613
+ "options": payload.get("options", []),
614
+ "requires_lookup": bool(payload.get("requires_lookup")),
615
+ "requires_upload": bool(payload.get("requires_upload")),
616
+ "target_app_key": payload.get("target_app_key"),
617
+ "target_app_name": payload.get("target_app_name"),
618
+ }
619
+ )
620
+ expected_columns.sort(key=lambda item: int(item["field_id"]))
621
+ schema_fingerprint = hashlib.sha256(
622
+ json.dumps(expected_columns, ensure_ascii=False, sort_keys=True).encode("utf-8")
623
+ ).hexdigest()
624
+ return expected_columns, schema_fingerprint
625
+
626
+ def _local_verify(
627
+ self,
628
+ *,
629
+ path: Path,
630
+ app_key: str,
631
+ expected_columns: list[JSONObject],
632
+ schema_fingerprint: str,
633
+ ) -> dict[str, Any]:
634
+ extension = path.suffix.lower()
635
+ file_sha256 = _sha256_file(path)
636
+ base_result = {
637
+ "app_key": app_key,
638
+ "file_path": str(path.resolve()),
639
+ "file_size": path.stat().st_size,
640
+ "file_sha256": file_sha256,
641
+ "schema_fingerprint": schema_fingerprint,
642
+ "issues": [],
643
+ "warnings": [],
644
+ "repair_suggestions": [],
645
+ "local_precheck_passed": True,
646
+ "local_precheck_limited": False,
647
+ "can_import": True,
648
+ "extension": extension,
649
+ "error_code": None,
650
+ }
651
+ if extension not in SUPPORTED_IMPORT_EXTENSIONS:
652
+ base_result["issues"].append(_issue("UNSUPPORTED_FILE_FORMAT", "Only .xlsx and .xls files are supported in import v1.", severity="error"))
653
+ base_result["local_precheck_passed"] = False
654
+ base_result["can_import"] = False
655
+ base_result["error_code"] = "IMPORT_FILE_FORMAT_UNSUPPORTED"
656
+ return base_result
657
+ if extension == ".xls":
658
+ base_result["warnings"].append(
659
+ {
660
+ "code": "IMPORT_LOCAL_PRECHECK_LIMITED",
661
+ "message": ".xls files are allowed for verify/start, but v1 local precheck is limited and repair is unsupported.",
662
+ }
663
+ )
664
+ base_result["local_precheck_limited"] = True
665
+ return base_result
666
+
667
+ try:
668
+ workbook = load_workbook(path, read_only=True, data_only=False)
669
+ except Exception as exc:
670
+ base_result["issues"].append(_issue("WORKBOOK_OPEN_FAILED", f"Workbook could not be opened: {exc}", severity="error"))
671
+ base_result["local_precheck_passed"] = False
672
+ base_result["can_import"] = False
673
+ base_result["error_code"] = "IMPORT_VERIFICATION_FAILED"
674
+ return base_result
675
+
676
+ if not workbook.sheetnames:
677
+ base_result["issues"].append(_issue("SHEET_MISSING", "Workbook does not contain any sheets.", severity="error"))
678
+ base_result["local_precheck_passed"] = False
679
+ base_result["can_import"] = False
680
+ base_result["error_code"] = "IMPORT_VERIFICATION_FAILED"
681
+ return base_result
682
+ sheet = workbook[workbook.sheetnames[0]]
683
+ header_row = [cell.value for cell in next(sheet.iter_rows(min_row=1, max_row=1), [])]
684
+ header_analysis = _analyze_headers(header_row, expected_columns)
685
+ base_result["issues"].extend(header_analysis["issues"])
686
+ base_result["repair_suggestions"].extend(header_analysis["repair_suggestions"])
687
+ trailing_blank_rows = _count_trailing_blank_rows(sheet)
688
+ if trailing_blank_rows > 0:
689
+ base_result["warnings"].append(
690
+ {
691
+ "code": "TRAILING_BLANK_ROWS",
692
+ "message": f"Workbook contains {trailing_blank_rows} trailing blank rows that can be safely removed.",
693
+ }
694
+ )
695
+ base_result["repair_suggestions"].append("trim_trailing_blank_rows")
696
+ enum_suggestions = _find_enum_repairs(sheet, expected_columns)
697
+ if enum_suggestions:
698
+ base_result["warnings"].append(
699
+ {
700
+ "code": "ENUM_VALUE_NORMALIZATION_AVAILABLE",
701
+ "message": "Some enum-like cells can be normalized to exact template values without changing meaning.",
702
+ }
703
+ )
704
+ base_result["repair_suggestions"].append("normalize_enum_values")
705
+ base_result["repair_suggestions"] = sorted(set(base_result["repair_suggestions"]))
706
+ if any(issue.get("severity") == "error" for issue in base_result["issues"]):
707
+ base_result["local_precheck_passed"] = False
708
+ base_result["can_import"] = False
709
+ base_result["error_code"] = "IMPORT_VERIFICATION_FAILED"
710
+ return base_result
711
+
712
+ def _failed_template_result(
713
+ self,
714
+ *,
715
+ app_key: str,
716
+ error_code: str,
717
+ message: str,
718
+ request_route: JSONObject | None = None,
719
+ ) -> dict[str, Any]:
720
+ return {
721
+ "ok": False,
722
+ "status": "failed",
723
+ "error_code": error_code,
724
+ "app_key": app_key,
725
+ "template_url": None,
726
+ "downloaded_to_path": None,
727
+ "expected_columns": [],
728
+ "schema_fingerprint": None,
729
+ "request_route": request_route,
730
+ "warnings": [],
731
+ "verification": {"template_url_resolved": False},
732
+ "message": message,
733
+ }
734
+
735
+ def _failed_verify_result(self, *, app_key: str, file_path: str, error_code: str, message: str) -> dict[str, Any]:
736
+ return {
737
+ "ok": True,
738
+ "status": "failed",
739
+ "error_code": error_code,
740
+ "app_key": app_key,
741
+ "can_import": False,
742
+ "verification_id": None,
743
+ "file_path": str(Path(file_path).expanduser()) if file_path else file_path,
744
+ "file_name": Path(file_path).name if file_path else None,
745
+ "file_sha256": None,
746
+ "file_size": None,
747
+ "schema_fingerprint": None,
748
+ "apply_rows": None,
749
+ "issues": [_issue(error_code, message, severity="error")],
750
+ "repair_suggestions": [],
751
+ "warnings": [],
752
+ "verification": {
753
+ "local_precheck_passed": False,
754
+ "backend_verification_passed": False,
755
+ },
756
+ "message": message,
757
+ }
758
+
759
+ def _failed_repair_result(self, *, error_code: str, message: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
760
+ payload = {
761
+ "ok": False,
762
+ "status": "failed",
763
+ "error_code": error_code,
764
+ "source_file_path": None,
765
+ "repaired_file_path": None,
766
+ "applied_repairs": [],
767
+ "skipped_repairs": [],
768
+ "new_verification_id": None,
769
+ "can_import_after_repair": False,
770
+ "post_repair_issues": [_issue(error_code, message, severity="error")],
771
+ "warnings": [],
772
+ "verification": {
773
+ "repair_authorized": False,
774
+ "reverified": False,
775
+ },
776
+ "message": message,
777
+ }
778
+ if extra:
779
+ payload.update(extra)
780
+ return payload
781
+
782
+ def _failed_start_result(self, *, error_code: str, message: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
783
+ payload = {
784
+ "ok": False,
785
+ "status": "failed",
786
+ "error_code": error_code,
787
+ "accepted": False,
788
+ "import_id": None,
789
+ "process_id_str": None,
790
+ "source_file_name": None,
791
+ "file_url": None,
792
+ "warnings": [],
793
+ "verification": {
794
+ "verification_id_valid": False,
795
+ "file_hash_verified": False,
796
+ "schema_fingerprint_verified": False,
797
+ "upload_staged": False,
798
+ "import_acknowledged": False,
799
+ },
800
+ "message": message,
801
+ }
802
+ if extra:
803
+ payload.update(extra)
804
+ return payload
805
+
806
+ def _failed_status_result(self, *, error_code: str, message: str, extra: dict[str, Any] | None = None) -> dict[str, Any]:
807
+ payload = {
808
+ "ok": False,
809
+ "status": "failed",
810
+ "error_code": error_code,
811
+ "import_id": None,
812
+ "process_id_str": None,
813
+ "matched_by": None,
814
+ "source_file_name": None,
815
+ "total_rows": None,
816
+ "success_rows": None,
817
+ "failed_rows": None,
818
+ "progress": None,
819
+ "error_file_urls": [],
820
+ "operate_time": None,
821
+ "operate_user": None,
822
+ "warnings": [],
823
+ "verification": {
824
+ "status_lookup_completed": False,
825
+ "process_id_verified": False,
826
+ },
827
+ "message": message,
828
+ }
829
+ if extra:
830
+ payload.update(extra)
831
+ return payload
832
+
833
+ def _runtime_error_as_result(
834
+ self,
835
+ error: RuntimeError,
836
+ *,
837
+ error_code: str,
838
+ extra: dict[str, Any] | None = None,
839
+ ) -> dict[str, Any]:
840
+ try:
841
+ payload = json.loads(str(error))
842
+ except json.JSONDecodeError:
843
+ payload = {"message": str(error)}
844
+ response = {
845
+ "ok": False,
846
+ "status": "failed",
847
+ "error_code": payload.get("details", {}).get("error_code") or error_code,
848
+ "warnings": [],
849
+ "verification": {},
850
+ "message": payload.get("message") or str(error),
851
+ }
852
+ if extra:
853
+ response.update(extra)
854
+ return response
855
+
856
+
857
+ def _pick_template_url(payload: Any) -> str | None:
858
+ if isinstance(payload, dict):
859
+ for key in ("excelUrl", "url", "downloadUrl"):
860
+ value = payload.get(key)
861
+ if isinstance(value, str) and value.strip():
862
+ return value.strip()
863
+ return None
864
+
865
+
866
+ def _resolve_template_download_path(raw_path: str, *, app_key: str) -> Path:
867
+ path = Path(raw_path).expanduser()
868
+ if path.exists() and path.is_dir():
869
+ return path / f"{app_key}_import_template.xlsx"
870
+ if path.suffix:
871
+ return path
872
+ return path / f"{app_key}_import_template.xlsx"
873
+
874
+
875
+ def _resolve_repaired_output_path(source_path: Path, *, output_path: str | None) -> Path:
876
+ if output_path:
877
+ path = Path(output_path).expanduser()
878
+ if path.exists() and path.is_dir():
879
+ return path / f"{source_path.stem}.repaired{source_path.suffix}"
880
+ if path.suffix:
881
+ return path
882
+ return path / f"{source_path.stem}.repaired{source_path.suffix}"
883
+ return source_path.with_name(f"{source_path.stem}.repaired{source_path.suffix}")
884
+
885
+
886
+ def _utc_now() -> datetime:
887
+ return datetime.now(timezone.utc)
888
+
889
+
890
+ def _sha256_file(path: Path) -> str:
891
+ digest = hashlib.sha256()
892
+ with path.open("rb") as handle:
893
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
894
+ digest.update(chunk)
895
+ return digest.hexdigest()
896
+
897
+
898
+ def _normalize_optional_text(value: Any) -> str | None:
899
+ if value is None:
900
+ return None
901
+ normalized = str(value).strip()
902
+ return normalized or None
903
+
904
+
905
+ def _normalize_header_key(value: Any) -> str:
906
+ text = _normalize_optional_text(value)
907
+ return (text or "").casefold()
908
+
909
+
910
+ def _issue(code: str, message: str, *, severity: str, repairable: bool = False, repair_code: str | None = None) -> JSONObject:
911
+ payload: JSONObject = {
912
+ "code": code,
913
+ "message": message,
914
+ "severity": severity,
915
+ "repairable": repairable,
916
+ }
917
+ if repair_code:
918
+ payload["repair_code"] = repair_code
919
+ return payload
920
+
921
+
922
+ def _analyze_headers(header_row: list[Any], expected_columns: list[JSONObject]) -> dict[str, Any]:
923
+ expected_by_key = {_normalize_header_key(item["title"]): item for item in expected_columns}
924
+ seen: dict[str, int] = {}
925
+ actual_headers: list[str] = []
926
+ for item in header_row:
927
+ text = _normalize_optional_text(item)
928
+ if text is None:
929
+ actual_headers.append("")
930
+ continue
931
+ actual_headers.append(text)
932
+ key = _normalize_header_key(text)
933
+ seen[key] = seen.get(key, 0) + 1
934
+ actual_keys = {key for key, count in seen.items() if key and count > 0}
935
+ missing = [item["title"] for key, item in expected_by_key.items() if key not in actual_keys]
936
+ extra = [text for text in actual_headers if text and _normalize_header_key(text) not in expected_by_key]
937
+ duplicates = [text for text in actual_headers if text and seen.get(_normalize_header_key(text), 0) > 1]
938
+ issues: list[JSONObject] = []
939
+ repair_suggestions: list[str] = []
940
+ if missing:
941
+ issues.append(_issue("MISSING_COLUMNS", f"Missing expected columns: {', '.join(missing)}", severity="error"))
942
+ if extra:
943
+ issues.append(_issue("EXTRA_COLUMNS", f"Unexpected columns: {', '.join(extra)}", severity="error"))
944
+ if duplicates:
945
+ issues.append(_issue("DUPLICATE_COLUMNS", f"Duplicate columns: {', '.join(sorted(set(duplicates)))}", severity="error"))
946
+ normalized_changes = []
947
+ for text in actual_headers:
948
+ if not text:
949
+ continue
950
+ matched = expected_by_key.get(_normalize_header_key(text))
951
+ if matched and matched["title"] != text:
952
+ normalized_changes.append((text, matched["title"]))
953
+ if normalized_changes:
954
+ repair_suggestions.append("normalize_headers")
955
+ return {"issues": issues, "repair_suggestions": repair_suggestions}
956
+
957
+
958
+ def _count_trailing_blank_rows(sheet) -> int: # type: ignore[no-untyped-def]
959
+ count = 0
960
+ for row_index in range(sheet.max_row, 1, -1):
961
+ values = [cell.value for cell in sheet[row_index]]
962
+ if any(value not in (None, "") for value in values):
963
+ break
964
+ count += 1
965
+ return count
966
+
967
+
968
+ def _find_enum_repairs(sheet, expected_columns: list[JSONObject]) -> list[str]: # type: ignore[no-untyped-def]
969
+ header_map = _sheet_header_map(sheet)
970
+ found: list[str] = []
971
+ for column in expected_columns:
972
+ options = [str(item).strip() for item in column.get("options", []) if str(item).strip()]
973
+ if not options:
974
+ continue
975
+ column_index = header_map.get(_normalize_header_key(column["title"]))
976
+ if column_index is None:
977
+ continue
978
+ option_map = {_normalize_header_key(item): item for item in options}
979
+ for row in range(2, min(sheet.max_row, 50) + 1):
980
+ value = sheet.cell(row=row, column=column_index).value
981
+ text = _normalize_optional_text(value)
982
+ if text is None:
983
+ continue
984
+ exact = option_map.get(_normalize_header_key(text))
985
+ if exact and exact != text:
986
+ found.append(column["title"])
987
+ break
988
+ return found
989
+
990
+
991
+ def _sheet_header_map(sheet) -> dict[str, int]: # type: ignore[no-untyped-def]
992
+ mapping: dict[str, int] = {}
993
+ for index, cell in enumerate(next(sheet.iter_rows(min_row=1, max_row=1), []), start=1):
994
+ key = _normalize_header_key(cell.value)
995
+ if key and key not in mapping:
996
+ mapping[key] = index
997
+ return mapping
998
+
999
+
1000
+ def _repair_headers(sheet, expected_columns: list[JSONObject]) -> bool: # type: ignore[no-untyped-def]
1001
+ changed = False
1002
+ expected_by_key = {_normalize_header_key(item["title"]): item["title"] for item in expected_columns}
1003
+ for cell in next(sheet.iter_rows(min_row=1, max_row=1), []):
1004
+ text = _normalize_optional_text(cell.value)
1005
+ if text is None:
1006
+ continue
1007
+ canonical = expected_by_key.get(_normalize_header_key(text))
1008
+ if canonical and canonical != text:
1009
+ cell.value = canonical
1010
+ changed = True
1011
+ return changed
1012
+
1013
+
1014
+ def _trim_trailing_blank_rows(sheet) -> bool: # type: ignore[no-untyped-def]
1015
+ removed = 0
1016
+ while sheet.max_row > 1:
1017
+ values = [cell.value for cell in sheet[sheet.max_row]]
1018
+ if any(value not in (None, "") for value in values):
1019
+ break
1020
+ sheet.delete_rows(sheet.max_row, 1)
1021
+ removed += 1
1022
+ return removed > 0
1023
+
1024
+
1025
+ def _normalize_enum_values(sheet, expected_columns: list[JSONObject]) -> bool: # type: ignore[no-untyped-def]
1026
+ changed = False
1027
+ header_map = _sheet_header_map(sheet)
1028
+ for column in expected_columns:
1029
+ options = [str(item).strip() for item in column.get("options", []) if str(item).strip()]
1030
+ if not options:
1031
+ continue
1032
+ column_index = header_map.get(_normalize_header_key(column["title"]))
1033
+ if column_index is None:
1034
+ continue
1035
+ option_map = {_normalize_header_key(item): item for item in options}
1036
+ for row in range(2, sheet.max_row + 1):
1037
+ cell = sheet.cell(row=row, column=column_index)
1038
+ text = _normalize_optional_text(cell.value)
1039
+ if text is None:
1040
+ continue
1041
+ canonical = option_map.get(_normalize_header_key(text))
1042
+ if canonical and canonical != text:
1043
+ cell.value = canonical
1044
+ changed = True
1045
+ return changed
1046
+
1047
+
1048
+ def _normalize_date_formats(sheet) -> bool: # type: ignore[no-untyped-def]
1049
+ changed = False
1050
+ for row in sheet.iter_rows(min_row=2):
1051
+ for cell in row:
1052
+ if getattr(cell, "is_date", False):
1053
+ if cell.number_format != "yyyy-mm-dd hh:mm:ss":
1054
+ cell.number_format = "yyyy-mm-dd hh:mm:ss"
1055
+ changed = True
1056
+ return changed
1057
+
1058
+
1059
+ def _normalize_number_formats(sheet) -> bool: # type: ignore[no-untyped-def]
1060
+ changed = False
1061
+ for row in sheet.iter_rows(min_row=2):
1062
+ for cell in row:
1063
+ if isinstance(cell.value, (int, float)) and not getattr(cell, "is_date", False):
1064
+ if cell.number_format == "General":
1065
+ cell.number_format = "0.00" if isinstance(cell.value, float) else "0"
1066
+ changed = True
1067
+ return changed
1068
+
1069
+
1070
+ def _normalize_url_cells(sheet) -> bool: # type: ignore[no-untyped-def]
1071
+ changed = False
1072
+ for row in sheet.iter_rows(min_row=2):
1073
+ for cell in row:
1074
+ text = _normalize_optional_text(cell.value)
1075
+ if text and (text.startswith("http://") or text.startswith("https://")) and text != cell.value:
1076
+ cell.value = text
1077
+ changed = True
1078
+ return changed
1079
+
1080
+
1081
+ def _extract_import_records(payload: Any) -> list[JSONObject]:
1082
+ if isinstance(payload, dict):
1083
+ for key in ("list", "records", "items"):
1084
+ value = payload.get(key)
1085
+ if isinstance(value, list):
1086
+ return [item for item in value if isinstance(item, dict)]
1087
+ if isinstance(payload, list):
1088
+ return [item for item in payload if isinstance(item, dict)]
1089
+ return []
1090
+
1091
+
1092
+ def _match_import_record(
1093
+ records: list[JSONObject],
1094
+ *,
1095
+ local_job: dict[str, Any] | None,
1096
+ process_id_str: str | None,
1097
+ ) -> tuple[JSONObject | None, str | None]:
1098
+ if process_id_str:
1099
+ exact = [
1100
+ item
1101
+ for item in records
1102
+ if _normalize_optional_text(item.get("processIdStr") or item.get("processId") or item.get("process_id_str")) == process_id_str
1103
+ ]
1104
+ if len(exact) == 1:
1105
+ return exact[0], "process_id_str"
1106
+ if len(exact) > 1:
1107
+ return None, "process_id_str"
1108
+ if isinstance(local_job, dict):
1109
+ source_file_name = _normalize_optional_text(local_job.get("source_file_name"))
1110
+ started_at = _parse_utc(local_job.get("started_at"))
1111
+ candidates = records
1112
+ if source_file_name:
1113
+ candidates = [
1114
+ item
1115
+ for item in candidates
1116
+ if _normalize_optional_text(item.get("sourceFileName") or item.get("source_file_name")) == source_file_name
1117
+ ]
1118
+ if started_at is not None:
1119
+ window_end = started_at + timedelta(minutes=10)
1120
+ timed = []
1121
+ for item in candidates:
1122
+ operate_time = _parse_utc(item.get("operateTime"))
1123
+ if operate_time is None:
1124
+ continue
1125
+ if started_at - timedelta(minutes=1) <= operate_time <= window_end:
1126
+ timed.append(item)
1127
+ if len(timed) == 1:
1128
+ return timed[0], "local_job_window"
1129
+ if len(timed) > 1:
1130
+ return None, "local_job_window"
1131
+ if len(candidates) == 1:
1132
+ return candidates[0], "source_file_name"
1133
+ if len(candidates) > 1:
1134
+ return None, "source_file_name"
1135
+ return None, None
1136
+
1137
+
1138
+ def _parse_utc(value: Any) -> datetime | None:
1139
+ text = _normalize_optional_text(value)
1140
+ if text is None:
1141
+ return None
1142
+ normalized = text.replace("Z", "+00:00")
1143
+ try:
1144
+ parsed = datetime.fromisoformat(normalized)
1145
+ except ValueError:
1146
+ return None
1147
+ if parsed.tzinfo is None:
1148
+ return parsed.replace(tzinfo=timezone.utc)
1149
+ return parsed.astimezone(timezone.utc)
1150
+
1151
+
1152
+ def _coerce_int(value: Any) -> int | None:
1153
+ if value is None or value == "":
1154
+ return None
1155
+ try:
1156
+ return int(value)
1157
+ except (TypeError, ValueError):
1158
+ return None
1159
+
1160
+
1161
+ def _normalize_error_file_urls(value: Any) -> list[str]:
1162
+ if isinstance(value, list):
1163
+ return [str(item).strip() for item in value if str(item).strip()]
1164
+ return []