@qingflow-tech/qingflow-cli 1.1.43 → 1.1.44-beta.1

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 (77) hide show
  1. package/README.md +13 -6
  2. package/entry_point.py +1 -1
  3. package/npm/bin/qingflow.mjs +1 -2
  4. package/npm/lib/runtime.mjs +3 -7
  5. package/package.json +2 -2
  6. package/pyproject.toml +3 -4
  7. package/skills/qingflow-app-form/SKILL.md +66 -0
  8. package/skills/qingflow-app-form/agents/openai.yaml +4 -0
  9. package/skills/qingflow-cli/SKILL.md +8 -6
  10. package/skills/qingflow-cli/manifest.yaml +1 -1
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_APP_DELIVERY_WORKFLOW.md +4 -4
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +1 -1
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +1 -1
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_SCHEMA_APPLY_FIELD_TYPES_AND_SCENARIOS.md +4 -4
  15. package/skills/qingflow-cli/reference/builder/10-build-single-app.md +5 -5
  16. package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +14 -14
  17. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +106 -103
  18. package/skills/qingflow-cli/reference/builder/40-layout.md +39 -31
  19. package/skills/qingflow-cli/reference/builder/README.md +9 -9
  20. package/skills/qingflow-cli/reference/builder/code-integrations/README.md +4 -2
  21. package/skills/qingflow-cli/reference/builder/code-integrations/code-block.md +2 -0
  22. package/skills/qingflow-cli/reference/builder/code-integrations/q-linker.md +2 -0
  23. package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +45 -38
  24. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +1 -1
  25. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +1 -1
  26. package/skills/qingflow-cli/reference/examples/README.md +3 -3
  27. package/skills/qingflow-common/SKILL.md +47 -0
  28. package/skills/qingflow-common/agents/openai.yaml +4 -0
  29. package/src/qingflow_mcp/__main__.py +2 -3
  30. package/src/qingflow_mcp/app_form_compat.py +104 -0
  31. package/src/qingflow_mcp/app_form_validator.py +462 -0
  32. package/src/qingflow_mcp/builder_facade/app_form.py +4196 -0
  33. package/src/qingflow_mcp/builder_facade/app_form_schema.py +425 -0
  34. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +42 -0
  35. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +2969 -0
  36. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +392 -0
  37. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +236 -0
  38. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +425 -0
  39. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +318 -0
  40. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +580 -0
  41. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +150 -0
  42. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +150 -0
  43. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +345 -0
  44. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +190 -0
  45. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +190 -0
  46. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +190 -0
  47. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +484 -0
  48. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +214 -0
  49. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +236 -0
  50. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +199 -0
  51. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +412 -0
  52. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +454 -0
  53. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +214 -0
  54. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +214 -0
  55. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +1921 -0
  56. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +190 -0
  57. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +1594 -0
  58. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +718 -0
  59. package/src/qingflow_mcp/builder_facade/service.py +8 -4
  60. package/src/qingflow_mcp/cli/commands/builder.py +93 -28
  61. package/src/qingflow_mcp/cli/json_io.py +21 -2
  62. package/src/qingflow_mcp/cli/main.py +3 -39
  63. package/src/qingflow_mcp/public_surface.py +5 -2
  64. package/src/qingflow_mcp/response_trim.py +50 -0
  65. package/src/qingflow_mcp/server.py +24 -3
  66. package/src/qingflow_mcp/server_app_builder.py +44 -87
  67. package/src/qingflow_mcp/tools/ai_builder_tools.py +103 -100
  68. package/src/qingflow_mcp/tools/app_tools.py +46 -65
  69. package/src/qingflow_mcp/tools/record_tools.py +3 -3
  70. package/src/qingflow_mcp/version.py +2 -4
  71. package/docs/local-agent-install.md +0 -370
  72. package/skills/qingflow-mcp-setup/SKILL.md +0 -115
  73. package/skills/qingflow-mcp-setup/agents/openai.yaml +0 -4
  74. package/skills/qingflow-mcp-setup/references/claude-desktop.md +0 -34
  75. package/skills/qingflow-mcp-setup/references/environments.md +0 -62
  76. package/skills/qingflow-mcp-setup/references/generic-stdio.md +0 -32
  77. package/skills/qingflow-mcp-setup/scripts/check_local_server.sh +0 -38
@@ -0,0 +1,4196 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass
6
+ import json
7
+ import re
8
+ from typing import Any, Iterable
9
+ from urllib.parse import unquote_plus
10
+
11
+ from ..app_form_compat import normalize_app_form_declaration
12
+ from ..errors import QingflowApiError, is_auth_like_error
13
+ from ..json_types import JSONObject
14
+ from ..solution.compiler.form_compiler import build_options, build_question, default_member_auth
15
+ from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults
16
+ from ..solution.spec_models import FieldType
17
+ from ..tools.app_tools import AppTools
18
+ from ..tools.package_tools import PackageTools
19
+ from .app_form_schema import AppFormContractError, AppFormSchemaContext, AppFormSchemaRegistry
20
+ from .service import (
21
+ _coerce_any_int,
22
+ _compile_code_block_binding_fields,
23
+ _compile_q_linker_binding_fields,
24
+ _encrypt_question_id,
25
+ _field_to_question,
26
+ _looks_like_backend_encoded_formula,
27
+ _normalize_formula_defaults_for_save,
28
+ _normalize_and_validate_code_block_fields_for_write,
29
+ )
30
+
31
+
32
+ API_VERSION = "builder.qingflow.com/v1alpha1"
33
+ QUE_TYPE_TO_CANONICAL = {
34
+ 2: "text",
35
+ 3: "long_text",
36
+ 5: "member",
37
+ 6: "email",
38
+ 7: "phone",
39
+ 9: "link",
40
+ 10: "single_choice",
41
+ 11: "single_select",
42
+ 12: "multi_select",
43
+ 13: "attachment",
44
+ 18: "subtable",
45
+ 19: "data_relation",
46
+ 20: "q_linker",
47
+ 21: "address",
48
+ 22: "department",
49
+ 25: "relation",
50
+ 26: "code_block",
51
+ }
52
+ CANONICAL_TO_QUE_TYPE = {
53
+ "text": 2,
54
+ "long_text": 3,
55
+ "number": 8,
56
+ "amount": 8,
57
+ "link": 9,
58
+ "date": 4,
59
+ "datetime": 4,
60
+ "member": 5,
61
+ "department": 22,
62
+ "single_choice": 10,
63
+ "single_select": 11,
64
+ "multi_select": 12,
65
+ "phone": 7,
66
+ "email": 6,
67
+ "address": 21,
68
+ "attachment": 13,
69
+ "q_linker": 20,
70
+ "code_block": 26,
71
+ "data_relation": 19,
72
+ "relation": 25,
73
+ "subtable": 18,
74
+ }
75
+ SUBFIELD_TYPES = {
76
+ "text",
77
+ "long_text",
78
+ "number",
79
+ "link",
80
+ "date",
81
+ "phone",
82
+ "email",
83
+ "single_choice",
84
+ "single_select",
85
+ "multi_select",
86
+ "attachment",
87
+ "data_relation",
88
+ "relation",
89
+ "member",
90
+ "department",
91
+ }
92
+ CHOICE_TYPES = {"single_choice", "single_select", "multi_select"}
93
+ INTEGRATION_TARGET_TYPES = {
94
+ "text",
95
+ "long_text",
96
+ "number",
97
+ "amount",
98
+ "date",
99
+ "datetime",
100
+ "single_choice",
101
+ "single_select",
102
+ "multi_select",
103
+ }
104
+ AGGREGATION_TO_BACKEND = {
105
+ "sum": 0,
106
+ "average": 1,
107
+ "max": 2,
108
+ "min": 3,
109
+ "first": 4,
110
+ "last": 5,
111
+ "filled_count": 6,
112
+ "empty_count": 7,
113
+ "count": 8,
114
+ }
115
+ REPEAT_PERIOD_TO_BACKEND = {"none": 0, "day": 1, "week": 2, "month": 3, "year": 4}
116
+ DATE_FORMAT_TO_BACKEND = {"year": 1, "year_month": 2, "year_month_day": 3}
117
+ JUDGE_TYPE_TO_BACKEND = {
118
+ "equal": 0,
119
+ "not_equal": 1,
120
+ "contains": 2,
121
+ "not_contains": 3,
122
+ "greater_than": 4,
123
+ "greater_or_equal": 5,
124
+ "less_than": 6,
125
+ "less_or_equal": 7,
126
+ "dynamic_range": 8,
127
+ "equal_any": 9,
128
+ "not_equal_any": 10,
129
+ "is_empty": 11,
130
+ "is_not_empty": 12,
131
+ "belongs_to": 13,
132
+ "not_belongs_to": 14,
133
+ "subordinate_to": 15,
134
+ "not_subordinate_to": 16,
135
+ "subordinate_included": 17,
136
+ "subordinate_not_included": 18,
137
+ "fuzzy_match": 19,
138
+ "contains_any": 20,
139
+ "contains_all": 21,
140
+ "decimal_range": 22,
141
+ }
142
+ BACKEND_TO_JUDGE_TYPE = {value: key for key, value in JUDGE_TYPE_TO_BACKEND.items()}
143
+ EMPTY_OPERATORS = {"is_empty", "is_not_empty"}
144
+ APP_FORM_MAX_BYTES = 2 * 1024 * 1024
145
+ APP_FORM_MAX_DEPTH = 64
146
+ Q_LINKER_VALUE_REFERENCE_RE = re.compile(r"\{(?P<name>[^{}$]+)\$\$(?P<token>[^{}$]+)\$\$\}")
147
+
148
+
149
+ @dataclass(slots=True)
150
+ class AppFormFailure(Exception):
151
+ error_code: str
152
+ message: str
153
+ phase: str
154
+ status: str = "failed"
155
+ details: JSONObject | None = None
156
+
157
+
158
+ def _positive_int(value: Any) -> int | None:
159
+ if isinstance(value, bool):
160
+ return None
161
+ try:
162
+ result = int(value)
163
+ except (TypeError, ValueError):
164
+ return None
165
+ return result if result > 0 else None
166
+
167
+
168
+ def _decode_formula_default(value: Any) -> Any:
169
+ if not isinstance(value, str) or not _looks_like_backend_encoded_formula(value):
170
+ return value
171
+ encoded = value[16:-16]
172
+ try:
173
+ decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
174
+ return unquote_plus(decoded)
175
+ except Exception:
176
+ return value
177
+
178
+
179
+ def _without_none(value: Any) -> Any:
180
+ if isinstance(value, dict):
181
+ return {key: _without_none(item) for key, item in value.items() if item is not None}
182
+ if isinstance(value, list):
183
+ return [_without_none(item) for item in value]
184
+ return value
185
+
186
+
187
+ def _canonical_unlimited(value: Any) -> Any:
188
+ """Expose the backend -1 sentinel as the declarative null/omitted form."""
189
+ return None if value == -1 else value
190
+
191
+
192
+ def _backend_unlimited(value: Any) -> Any:
193
+ """Translate an explicit declarative null back to the backend -1 sentinel."""
194
+ return -1 if value is None else value
195
+
196
+
197
+ def _scope_items(scope: Any, key: str) -> list[JSONObject]:
198
+ value = scope.get(key) if isinstance(scope, dict) else None
199
+ return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
200
+
201
+
202
+ def _backend_member_scope(scope: Any) -> JSONObject | None:
203
+ """Translate the public member/depart scope to AuthMembersIncludeExternalBean JSON."""
204
+ if not isinstance(scope, dict):
205
+ return None
206
+ result: JSONObject = {
207
+ "member": [{"uid": item.get("uid"), "nickName": item.get("nickName")} for item in _scope_items(scope, "members")],
208
+ "depart": [{"deptId": item.get("deptId"), "deptName": item.get("deptName")} for item in _scope_items(scope, "departments")],
209
+ "role": [{"roleId": item.get("roleId"), "roleName": item.get("roleName")} for item in _scope_items(scope, "roles")],
210
+ "dynamic": deepcopy(_scope_items(scope, "dynamic")),
211
+ "externalMemberList": deepcopy(_scope_items(scope, "externalMembers")),
212
+ "externalDepartList": deepcopy(_scope_items(scope, "externalDepartments")),
213
+ }
214
+ if "includeSubDeparts" in scope:
215
+ result["includeSubDeparts"] = bool(scope.get("includeSubDeparts"))
216
+ return result
217
+
218
+
219
+ def _canonical_member_scope(scope: Any, scope_type: Any, *, department: bool = False) -> JSONObject:
220
+ normalized_type = _positive_int(scope_type) or 1
221
+ if normalized_type == 1:
222
+ return {"mode": "all"}
223
+ raw = scope if isinstance(scope, dict) else {}
224
+ result: JSONObject = {
225
+ "mode": "custom" if normalized_type == 2 else "relation",
226
+ "includeSubDeparts": bool(raw.get("includeSubDeparts")),
227
+ }
228
+ if department:
229
+ result["departments"] = [
230
+ _without_none(
231
+ {
232
+ "deptId": _positive_int(item.get("deptId") or item.get("id")),
233
+ "deptName": _compat_label(item.get("deptName") or item.get("name"), "Department", item.get("deptId") or item.get("id")),
234
+ }
235
+ )
236
+ for item in _scope_items(raw, "depart")
237
+ if _positive_int(item.get("deptId") or item.get("id")) is not None
238
+ ]
239
+ else:
240
+ result["members"] = [
241
+ _without_none({"uid": _positive_int(item.get("uid")), "nickName": _compat_label(item.get("nickName"), "Member", item.get("uid"))})
242
+ for item in _scope_items(raw, "member")
243
+ if _positive_int(item.get("uid")) is not None
244
+ ]
245
+ result["departments"] = [
246
+ _without_none(
247
+ {
248
+ "deptId": _positive_int(item.get("deptId")),
249
+ "deptName": _compat_label(item.get("deptName"), "Department", item.get("deptId")),
250
+ }
251
+ )
252
+ for item in _scope_items(raw, "depart")
253
+ if _positive_int(item.get("deptId")) is not None
254
+ ]
255
+ result["roles"] = [
256
+ _without_none({"roleId": _positive_int(item.get("roleId")), "roleName": _compat_label(item.get("roleName"), "Role", item.get("roleId"))})
257
+ for item in _scope_items(raw, "role")
258
+ if _positive_int(item.get("roleId")) is not None
259
+ ]
260
+ result["dynamic"] = [
261
+ _without_none({
262
+ "type": _positive_int(item.get("type")),
263
+ "queId": item.get("queId"),
264
+ "queTitle": item.get("queTitle"),
265
+ })
266
+ for item in _scope_items(raw, "dynamic")
267
+ if _positive_int(item.get("type")) is not None
268
+ ]
269
+ result["externalMembers"] = [
270
+ _without_none({
271
+ "uid": _positive_int(item.get("uid")),
272
+ "nickName": item.get("nickName"),
273
+ "email": item.get("email"),
274
+ })
275
+ for item in _scope_items(raw, "externalMemberList")
276
+ if _positive_int(item.get("uid")) is not None
277
+ ]
278
+ result["externalDepartments"] = [
279
+ _without_none({
280
+ "deptId": _positive_int(item.get("deptId")),
281
+ "deptName": item.get("deptName"),
282
+ })
283
+ for item in _scope_items(raw, "externalDepartList")
284
+ if _positive_int(item.get("deptId")) is not None
285
+ ]
286
+ return _without_none(result)
287
+
288
+
289
+ def _canonical_scope_relation(relation: Any) -> JSONObject | None:
290
+ if not isinstance(relation, dict):
291
+ return None
292
+ displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
293
+ displayed_id = _positive_int(relation.get("displayedQueId") or displayed.get("queId"))
294
+ target_app_key = str(displayed.get("appKey") or "")
295
+ if displayed_id is None or not target_app_key.strip():
296
+ return None
297
+ result: JSONObject = {
298
+ "displayedField": _selector(displayed_id, displayed.get("queTitle")),
299
+ "targetAppKey": target_app_key,
300
+ "targetAppTitle": displayed.get("appTitle"),
301
+ "displayedFieldType": displayed.get("queType"),
302
+ "matchRuleType": relation.get("matchRuleType"),
303
+ "matchRules": relation.get("matchRules"),
304
+ }
305
+ formula = str(relation.get("matchRuleFormula") or "").strip()
306
+ if formula:
307
+ result["matchRuleFormula"] = formula
308
+ return _without_none(result)
309
+
310
+
311
+ def _canonical_attachment_default_values(value: Any) -> JSONObject | None:
312
+ if not isinstance(value, dict):
313
+ return None
314
+ values = []
315
+ for item in value.get("values") or []:
316
+ if not isinstance(item, dict):
317
+ continue
318
+ url = str(item.get("value") or "").strip()
319
+ if not url:
320
+ data_value = str(item.get("dataValue") or "").strip()
321
+ url = data_value if "://" in data_value else ""
322
+ if not url:
323
+ continue
324
+ file_name = str(item.get("otherInfo") or item.get("valueStr") or "").strip()
325
+ values.append(
326
+ _without_none(
327
+ {
328
+ "value": url,
329
+ "otherInfo": file_name or None,
330
+ "pluginValue": item.get("pluginValue"),
331
+ }
332
+ )
333
+ )
334
+ return {"values": values} if values else None
335
+
336
+
337
+ def _selector(que_id: Any, name: Any) -> JSONObject:
338
+ result: JSONObject = {}
339
+ normalized_id = _positive_int(que_id)
340
+ normalized_name = str(name or "").strip()
341
+ if normalized_id is not None:
342
+ result["queId"] = normalized_id
343
+ if normalized_name:
344
+ result["name"] = normalized_name
345
+ return result
346
+
347
+
348
+ def _compat_label(value: Any, kind: str, identity: Any) -> str:
349
+ """Give legacy records with an empty display label a stable, non-empty name."""
350
+ label = str(value or "")
351
+ if label.strip():
352
+ return label
353
+ normalized_identity = str(identity or "").strip()
354
+ return f"{kind} {normalized_identity}" if normalized_identity else f"Unnamed {kind}"
355
+
356
+
357
+ def _required_text(value: Any, *, message: str, details: JSONObject | None = None) -> str:
358
+ text = str(value or "")
359
+ if not text.strip():
360
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", message, "NORMALIZE", details=details)
361
+ return text
362
+
363
+
364
+ def _require_selector(que_id: Any, name: Any, *, message: str, details: JSONObject | None = None) -> JSONObject:
365
+ result = _selector(que_id, name)
366
+ if not result:
367
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", message, "NORMALIZE", details=details)
368
+ return result
369
+
370
+
371
+ def _iter_fields(body: Iterable[Any]) -> Iterable[JSONObject]:
372
+ for block in body:
373
+ if not isinstance(block, dict):
374
+ continue
375
+ rows = [block] if block.get("kind") == "row" else block.get("rows") or []
376
+ for row in rows:
377
+ if not isinstance(row, dict):
378
+ continue
379
+ for field in row.get("fields") or []:
380
+ if isinstance(field, dict):
381
+ yield field
382
+
383
+
384
+ def _iter_field_tree(fields: Iterable[Any]) -> Iterable[JSONObject]:
385
+ for field in fields:
386
+ if not isinstance(field, dict):
387
+ continue
388
+ yield field
389
+ yield from _iter_field_tree(field.get("subfields") or [])
390
+
391
+
392
+ def _iter_field_pairs(fields: Iterable[Any], internal_fields: Iterable[Any]) -> Iterable[tuple[JSONObject, JSONObject]]:
393
+ for field, internal in zip(fields, internal_fields):
394
+ if not isinstance(field, dict) or not isinstance(internal, dict):
395
+ continue
396
+ yield field, internal
397
+ yield from _iter_field_pairs(field.get("subfields") or [], internal.get("subfields") or [])
398
+
399
+
400
+ def _semantic(value: Any) -> Any:
401
+ if isinstance(value, dict):
402
+ identity_keys = {"packageId", "queId", "sectionId", "attachId", "aliasId", "id"}
403
+ return {
404
+ key: _semantic(item)
405
+ for key, item in sorted(value.items())
406
+ if key not in identity_keys
407
+ and not key.startswith("_")
408
+ and not (key == "sort" and isinstance(item, dict) and item.get("enabled") is False)
409
+ }
410
+ if isinstance(value, list):
411
+ if value and all(isinstance(item, dict) and {"fileName", "fileUrl"}.issubset(item) for item in value):
412
+ return sorted((_semantic(item) for item in value), key=lambda item: (item["fileName"], item["fileUrl"]))
413
+ return [_semantic(item) for item in value]
414
+ return value
415
+
416
+
417
+ def _canonical_equal(left: JSONObject, right: JSONObject) -> bool:
418
+ return _semantic(left) == _semantic(right)
419
+
420
+
421
+ def _iter_backend_questions(value: Any) -> Iterable[JSONObject]:
422
+ if isinstance(value, list):
423
+ for item in value:
424
+ yield from _iter_backend_questions(item)
425
+ elif isinstance(value, dict):
426
+ if "queType" in value:
427
+ yield value
428
+ yield from _iter_backend_questions(value.get("innerQuestions"))
429
+ yield from _iter_backend_questions(value.get("subQuestions"))
430
+
431
+
432
+ def _normalize_published_scope_omissions(form: JSONObject) -> JSONObject:
433
+ """Allow published GET responses that omit custom member/department scopes."""
434
+ result = deepcopy(form)
435
+ for question in _iter_backend_questions(result.get("formQues")):
436
+ if (
437
+ _positive_int(question.get("memberSelectScopeType")) == 2
438
+ and not isinstance(question.get("memberSelectScope"), dict)
439
+ ):
440
+ question["memberSelectScopeType"] = 1
441
+ question["memberSelectScope"] = {}
442
+ if (
443
+ _positive_int(question.get("deptSelectScopeType")) == 2
444
+ and not isinstance(question.get("deptSelectScope"), dict)
445
+ ):
446
+ question["deptSelectScopeType"] = 1
447
+ question["deptSelectScope"] = {}
448
+ return result
449
+
450
+
451
+ def _without_selectable_scopes(resource: JSONObject) -> JSONObject:
452
+ """Remove only selectable scope values from a published-state comparison."""
453
+ result = deepcopy(resource)
454
+ fields = _iter_fields(result.get("spec", {}).get("body") or [])
455
+ for field in _iter_field_tree(fields):
456
+ config = field.get("config") if isinstance(field.get("config"), dict) else None
457
+ if config is None:
458
+ continue
459
+ config.pop("memberScope", None)
460
+ config.pop("departmentScope", None)
461
+ return result
462
+
463
+
464
+ def _published_field_map(resource: JSONObject) -> dict[tuple[str, Any], JSONObject]:
465
+ fields = _iter_field_tree(_iter_fields(resource.get("spec", {}).get("body") or []))
466
+ result: dict[tuple[str, Any], JSONObject] = {}
467
+ for field in fields:
468
+ que_id = _positive_int(field.get("queId"))
469
+ key = ("id", que_id) if que_id is not None else ("name", (_field_type(field), field.get("name")))
470
+ result[key] = field
471
+ return result
472
+
473
+
474
+ def _matching_published_field(field: JSONObject, published_fields: dict[tuple[str, Any], JSONObject]) -> JSONObject | None:
475
+ que_id = _positive_int(field.get("queId"))
476
+ if que_id is not None and ("id", que_id) in published_fields:
477
+ return published_fields[("id", que_id)]
478
+ return published_fields.get(("name", (_field_type(field), field.get("name"))))
479
+
480
+
481
+ def _normalize_published_get_equivalents(draft: JSONObject, published: JSONObject) -> tuple[JSONObject, JSONObject]:
482
+ """Normalize values that the published GET endpoint omits or materializes."""
483
+ draft_result = _without_selectable_scopes(draft)
484
+ published_result = _without_selectable_scopes(published)
485
+ published_fields = _published_field_map(published_result)
486
+ for draft_field in _published_field_map(draft_result).values():
487
+ published_field = _matching_published_field(draft_field, published_fields)
488
+ if published_field is None:
489
+ continue
490
+ field_type = _field_type(draft_field)
491
+ draft_config = draft_field.get("config") if isinstance(draft_field.get("config"), dict) else {}
492
+ published_config = published_field.get("config") if isinstance(published_field.get("config"), dict) else {}
493
+
494
+ if field_type == "member":
495
+ draft_default = draft_config.get("defaultMembers")
496
+ published_default = published_config.get("defaultMembers")
497
+ if isinstance(draft_default, dict) and isinstance(published_default, dict):
498
+ # Published GET resolves department defaults into runtime members.
499
+ for key in ("departments", "includeSubDeparts"):
500
+ if key in draft_default:
501
+ published_default[key] = deepcopy(draft_default[key])
502
+
503
+ if field_type == "data_relation":
504
+ draft_filters = draft_config.get("filters") if isinstance(draft_config.get("filters"), dict) else {}
505
+ published_filters = published_config.get("filters") if isinstance(published_config.get("filters"), dict) else {}
506
+ draft_groups = draft_filters.get("groups") if isinstance(draft_filters.get("groups"), list) else []
507
+ published_groups = published_filters.get("groups") if isinstance(published_filters.get("groups"), list) else []
508
+ for draft_group, published_group in zip(draft_groups, published_groups):
509
+ if not isinstance(draft_group, list) or not isinstance(published_group, list):
510
+ continue
511
+ for draft_rule, published_rule in zip(draft_group, published_group):
512
+ if not isinstance(draft_rule, dict) or not isinstance(published_rule, dict):
513
+ continue
514
+ draft_source = draft_rule.get("sourceField") if isinstance(draft_rule.get("sourceField"), dict) else {}
515
+ published_source = published_rule.get("sourceField") if isinstance(published_rule.get("sourceField"), dict) else {}
516
+ same_source = _positive_int(draft_source.get("queId")) == _positive_int(published_source.get("queId"))
517
+ if same_source and draft_source.get("name") and not published_source.get("name"):
518
+ published_source["name"] = draft_source["name"]
519
+ draft_operand = draft_rule.get("operand") if isinstance(draft_rule.get("operand"), dict) else {}
520
+ published_operand = published_rule.get("operand") if isinstance(published_rule.get("operand"), dict) else {}
521
+ if (
522
+ same_source
523
+ and draft_rule.get("operator") == published_rule.get("operator")
524
+ and draft_operand.get("kind") == published_operand.get("kind") == "literal"
525
+ and draft_operand.get("values")
526
+ and published_operand.get("values") == []
527
+ ):
528
+ published_operand["values"] = deepcopy(draft_operand["values"])
529
+ # The backend intentionally emits sortConfig only for beingDraft=true.
530
+ if "sort" in draft_config and "sort" not in published_config:
531
+ published_config["sort"] = deepcopy(draft_config["sort"])
532
+
533
+ if field_type == "subtable":
534
+ if draft_config.get("tableRowsLimit") in {None, 200} and published_config.get("tableRowsLimit") in {None, 200}:
535
+ draft_config.pop("tableRowsLimit", None)
536
+ published_config.pop("tableRowsLimit", None)
537
+
538
+ if field_type in {"number", "amount"}:
539
+ if draft_config.get("currencyStatus") in {None, False} and published_config.get("currencyStatus") in {None, False}:
540
+ draft_config.pop("currencyStatus", None)
541
+ published_config.pop("currencyStatus", None)
542
+ return draft_result, published_result
543
+
544
+
545
+ def _published_equal(draft: JSONObject, published: JSONObject) -> bool:
546
+ comparable_draft, comparable_published = _normalize_published_get_equivalents(draft, published)
547
+ return _canonical_equal(comparable_draft, comparable_published)
548
+
549
+
550
+ def _layout_semantic(body: list[Any]) -> list[JSONObject]:
551
+ def row_layout(row: Any) -> JSONObject:
552
+ fields = row.get("fields") if isinstance(row, dict) else []
553
+ return {
554
+ "fields": [
555
+ {
556
+ "name": field.get("name"),
557
+ "questionType": _field_type(field),
558
+ "width": field.get("width"),
559
+ }
560
+ for field in fields or []
561
+ if isinstance(field, dict)
562
+ ]
563
+ }
564
+
565
+ result = []
566
+ for block in body:
567
+ if not isinstance(block, dict):
568
+ continue
569
+ if block.get("kind") == "section":
570
+ result.append(
571
+ {
572
+ "kind": "section",
573
+ "title": block.get("title"),
574
+ "rows": [row_layout(row) for row in block.get("rows") or []],
575
+ }
576
+ )
577
+ else:
578
+ result.append({"kind": "row", **row_layout(block)})
579
+ return result
580
+
581
+
582
+ def _deep_overlay(current: JSONObject, declared: JSONObject) -> JSONObject:
583
+ result = deepcopy(current)
584
+ for key, value in declared.items():
585
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
586
+ result[key] = _deep_overlay(result[key], value)
587
+ else:
588
+ result[key] = deepcopy(value)
589
+ return result
590
+
591
+
592
+ def _field_semantic(value: JSONObject) -> Any:
593
+ return _semantic({key: item for key, item in value.items() if key != "width"})
594
+
595
+
596
+ def _field_type(field: JSONObject) -> str:
597
+ """Read the AppForm field type while accepting pre-rename declarations."""
598
+ return str(field.get("questionType") or field.get("type") or "")
599
+
600
+
601
+ def _field_required(field: JSONObject) -> bool:
602
+ """Read the AppForm required flag while accepting pre-rename declarations."""
603
+ return bool(field.get("beingRequired") if "beingRequired" in field else field.get("required"))
604
+
605
+
606
+ def _validate_payload_limits(value: Any) -> None:
607
+ stack = [(value, 1)]
608
+ while stack:
609
+ item, depth = stack.pop()
610
+ if depth > APP_FORM_MAX_DEPTH:
611
+ raise AppFormContractError(
612
+ "INVALID_SPEC",
613
+ f"AppForm payload exceeds maximum nesting depth {APP_FORM_MAX_DEPTH}",
614
+ path="/",
615
+ details={"maxDepth": APP_FORM_MAX_DEPTH, "actualDepth": depth},
616
+ )
617
+ if isinstance(item, dict):
618
+ stack.extend((child, depth + 1) for child in item.values())
619
+ elif isinstance(item, list):
620
+ stack.extend((child, depth + 1) for child in item)
621
+ encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
622
+ if len(encoded) > APP_FORM_MAX_BYTES:
623
+ raise AppFormContractError(
624
+ "INVALID_SPEC",
625
+ f"AppForm payload exceeds {APP_FORM_MAX_BYTES} bytes",
626
+ path="/",
627
+ details={"maxBytes": APP_FORM_MAX_BYTES, "actualBytes": len(encoded)},
628
+ )
629
+
630
+
631
+ class AppFormNormalizer:
632
+ def from_backend(self, *, context: AppFormSchemaContext, app_key: str, form: JSONObject) -> JSONObject:
633
+ relations = [item for item in form.get("questionRelations") or [] if isinstance(item, dict)]
634
+ select_scope_relations = [item for item in form.get("selectScopeRelations") or [] if isinstance(item, dict)]
635
+ field_names = self._field_index(form)
636
+ definitions = context.complete_schema.get("$defs") if isinstance(context.complete_schema.get("$defs"), dict) else {}
637
+ relation_properties = definitions.get("RelationConfig", {}).get("properties", {}) if isinstance(definitions.get("RelationConfig"), dict) else {}
638
+ supports_reference_default_formula = "defaultValueFormula" in relation_properties
639
+ title_id = _positive_int(form.get("dataTitleQueId"))
640
+ cover_id = _positive_int(form.get("dataCoverQueId"))
641
+ body: list[JSONObject] = []
642
+ for row in form.get("formQues") or []:
643
+ if not isinstance(row, list) or not row:
644
+ continue
645
+ if len(row) == 1 and isinstance(row[0], dict) and _positive_int(row[0].get("queType")) == 24:
646
+ section = row[0]
647
+ section_id = _positive_int(section.get("queId")) or _positive_int(section.get("sectionId"))
648
+ if section_id is None:
649
+ raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section has no stable ID", "NORMALIZE")
650
+ rows = []
651
+ for inner in section.get("innerQuestions") or []:
652
+ if not isinstance(inner, list) or not inner:
653
+ continue
654
+ fields = [
655
+ self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula)
656
+ for question in inner
657
+ if isinstance(question, dict)
658
+ ]
659
+ if fields:
660
+ rows.append({"fields": fields})
661
+ if not rows:
662
+ raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section is empty", "NORMALIZE")
663
+ body.append(
664
+ {
665
+ "kind": "section",
666
+ "sectionId": str(section_id),
667
+ "title": _compat_label(section.get("queTitle"), "Section", section_id),
668
+ "rows": rows,
669
+ }
670
+ )
671
+ else:
672
+ fields = [
673
+ self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula)
674
+ for question in row
675
+ if isinstance(question, dict)
676
+ ]
677
+ if fields:
678
+ body.append({"kind": "row", "fields": fields})
679
+ resource = {
680
+ "apiVersion": context.api_version,
681
+ "appKey": app_key,
682
+ "spec": {
683
+ "appName": _compat_label(form.get("formTitle"), "App", app_key),
684
+ "appDescription": form.get("formDesc") or None,
685
+ "dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None else None,
686
+ "dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None else None,
687
+ "attachments": self._attachments(form.get("formAttach")),
688
+ "serialNumber": self._serial_number(form.get("serialNumType"), form.get("serialNumConfig")),
689
+ "body": body,
690
+ },
691
+ }
692
+ return resource
693
+
694
+ def _field(
695
+ self,
696
+ question: JSONObject,
697
+ relations: list[JSONObject],
698
+ select_scope_relations: list[JSONObject],
699
+ field_names: dict[int, str],
700
+ supports_reference_default_formula: bool,
701
+ ) -> JSONObject:
702
+ que_id = _positive_int(question.get("queId"))
703
+ if que_id is None:
704
+ raise AppFormFailure(
705
+ "UNSUPPORTED_FIELD_CONFIG",
706
+ "existing business field has no stable queId",
707
+ "NORMALIZE",
708
+ details={"name": question.get("queTitle"), "queType": question.get("queType")},
709
+ )
710
+ que_type = _positive_int(question.get("queType"))
711
+ if que_type == 4:
712
+ field_type = "datetime" if question.get("dateType") not in {None, 0} else "date"
713
+ elif que_type == 8:
714
+ field_type = "amount" if question.get("canDecimal") is True else "number"
715
+ else:
716
+ field_type = QUE_TYPE_TO_CANONICAL.get(que_type or -1)
717
+ if field_type is None:
718
+ raise AppFormFailure(
719
+ "UNSUPPORTED_FIELD_TYPE",
720
+ f"unsupported backend field type: {que_type}",
721
+ "NORMALIZE",
722
+ details={"queId": que_id, "queType": que_type},
723
+ )
724
+ field: JSONObject = {
725
+ "name": _compat_label(question.get("queTitle"), "Field", que_id),
726
+ "questionType": field_type,
727
+ "beingRequired": bool(question.get("required")),
728
+ "questionDescription": question.get("queHint") or None,
729
+ }
730
+ default_type = _positive_int(question.get("queDefaultType"))
731
+ raw_default_value = question.get("queDefaultValue")
732
+ decoded_default_value = (
733
+ _decode_formula_default(raw_default_value)
734
+ if isinstance(raw_default_value, (str, int, float, bool))
735
+ else raw_default_value
736
+ )
737
+ empty_formula_default = default_type == 3 and (
738
+ decoded_default_value is None
739
+ or (isinstance(decoded_default_value, str) and not decoded_default_value.strip())
740
+ )
741
+ if default_type is not None and not empty_formula_default and (
742
+ question.get("queDefaultValue") is not None
743
+ or (default_type != 1 and field_type != "relation")
744
+ ):
745
+ field["defaultType"] = default_type
746
+ if isinstance(raw_default_value, (dict, list)) and field_type not in {"address", "attachment", "relation", "subtable"}:
747
+ raise AppFormFailure(
748
+ "UNSUPPORTED_FIELD_CONFIG",
749
+ "structured default value must be declared through the field-specific config",
750
+ "NORMALIZE",
751
+ details={"queId": que_id, "type": field_type},
752
+ )
753
+ if (
754
+ "queDefaultValue" in question
755
+ and raw_default_value is not None
756
+ and not isinstance(raw_default_value, (dict, list))
757
+ and not empty_formula_default
758
+ and not (default_type == 2 and isinstance(decoded_default_value, str) and not decoded_default_value.strip())
759
+ ):
760
+ field["defaultValue"] = decoded_default_value
761
+ if que_id is not None:
762
+ field["queId"] = que_id
763
+ width = _positive_int(question.get("queWidth"))
764
+ if width is not None:
765
+ field["width"] = width
766
+ if field_type in {"text", "long_text", "link", "phone", "email"}:
767
+ config = _without_none({
768
+ "minWordCount": _canonical_unlimited(question.get("minWordCount")),
769
+ "maxWordCount": _canonical_unlimited(question.get("maxWordCount")),
770
+ "noRepeat": question.get("noRepeat"),
771
+ "canScan": question.get("canScan"),
772
+ "scanType": question.get("scanType"),
773
+ })
774
+ if field_type == "phone":
775
+ config.update(_without_none({
776
+ "msgValidateStatus": question.get("msgValidateStatus"),
777
+ "fixedPhone": question.get("fixedPhone"),
778
+ }))
779
+ if config:
780
+ field["config"] = config
781
+ elif field_type in {"number", "amount"}:
782
+ config = _without_none({
783
+ "numberLimitStatus": question.get("numberLimitStatus"),
784
+ "minValue": question.get("minValue"),
785
+ "maxValue": question.get("maxValue"),
786
+ "decimalDigit": question.get("decimalDigit"),
787
+ "numberFormat": question.get("numberFormat"),
788
+ "currencyStatus": question.get("currencyStatus"),
789
+ "currencyType": question.get("currencyType"),
790
+ "canDecimal": question.get("canDecimal"),
791
+ "noRepeat": question.get("noRepeat"),
792
+ "canScan": question.get("canScan"),
793
+ "scanType": question.get("scanType"),
794
+ })
795
+ if config:
796
+ field["config"] = config
797
+ elif field_type in CHOICE_TYPES:
798
+ options = [
799
+ str(item.get("optValue"))
800
+ for item in question.get("options") or []
801
+ if isinstance(item, dict) and item.get("optValue") is not None and str(item.get("optValue")).strip()
802
+ ]
803
+ if not options and field_type == "single_choice":
804
+ options = ["是", "否"]
805
+ if not options:
806
+ raise AppFormFailure(
807
+ "UNSUPPORTED_FIELD_CONFIG",
808
+ "choice field has no options",
809
+ "NORMALIZE",
810
+ details={"queId": que_id, "type": field_type, "path": "/config/options"},
811
+ )
812
+ field["config"] = _without_none({
813
+ "options": options,
814
+ "minOpts": _canonical_unlimited(question.get("minOpts")),
815
+ "maxOpts": _canonical_unlimited(question.get("maxOpts")),
816
+ "optDirection": question.get("optDirection") if question.get("optDirection") not in {None, 0} else None,
817
+ "optSelectMode": question.get("optSelectMode"),
818
+ "beingShowColor": question.get("beingShowColor"),
819
+ "defaultOptions": [
820
+ str(item.get("optValue"))
821
+ for item in question.get("options") or []
822
+ if isinstance(item, dict)
823
+ and item.get("beingDefault") is True
824
+ and item.get("optValue") is not None
825
+ and str(item.get("optValue")).strip()
826
+ ],
827
+ })
828
+ if not field["config"].get("defaultOptions"):
829
+ field["config"].pop("defaultOptions", None)
830
+ elif field_type == "member":
831
+ field["config"] = {
832
+ "memberScope": _canonical_member_scope(question.get("memberSelectScope"), question.get("memberSelectScopeType")),
833
+ }
834
+ if question.get("memberDftValue") is not None:
835
+ field["config"]["defaultMembers"] = _canonical_member_scope(question.get("memberDftValue"), 2)
836
+ relation = next((item for item in select_scope_relations if _positive_int(item.get("queId")) == que_id), None)
837
+ scope_relation = _canonical_scope_relation(relation)
838
+ if scope_relation is not None:
839
+ field["config"]["memberScopeRelation"] = scope_relation
840
+ for key in ("noRepeat", "canScan", "scanType"):
841
+ if question.get(key) is not None:
842
+ field["config"][key] = question[key]
843
+ elif field_type == "department":
844
+ scope = question.get("deptSelectScope") if isinstance(question.get("deptSelectScope"), dict) else {}
845
+ departments = scope.get("depart") if isinstance(scope.get("depart"), list) else []
846
+ canonical_departments = [
847
+ _without_none(
848
+ {
849
+ "deptId": _positive_int(item.get("deptId") or item.get("id")),
850
+ "deptName": _compat_label(item.get("deptName") or item.get("name"), "Department", item.get("deptId") or item.get("id")),
851
+ }
852
+ )
853
+ for item in departments
854
+ if isinstance(item, dict) and _positive_int(item.get("deptId") or item.get("id")) is not None
855
+ ]
856
+ scope_type = _positive_int(question.get("deptSelectScopeType")) or 1
857
+ if scope_type == 3:
858
+ field["config"] = {"departmentScope": {"mode": "relation", "includeSubDeparts": bool(scope.get("includeSubDeparts"))}}
859
+ elif scope_type == 2 or (question.get("deptSelectScopeType") is None and canonical_departments):
860
+ if not canonical_departments:
861
+ raise AppFormFailure(
862
+ "UNSUPPORTED_FIELD_CONFIG",
863
+ "department custom scope has no departments",
864
+ "NORMALIZE",
865
+ details={"queId": que_id},
866
+ )
867
+ field["config"] = {
868
+ "departmentScope": {
869
+ "mode": "custom",
870
+ "departments": canonical_departments,
871
+ "includeSubDeparts": bool(scope.get("includeSubDeparts")),
872
+ }
873
+ }
874
+ else:
875
+ field["config"] = {"departmentScope": {"mode": "all", "includeSubDeparts": bool(scope.get("includeSubDeparts"))}}
876
+ if question.get("deptDftValue") is not None:
877
+ default_scope = _canonical_member_scope(question.get("deptDftValue"), 2, department=True)
878
+ # Older backend responses may include an empty default-value container when no default department is set.
879
+ if default_scope.get("departments"):
880
+ field["config"]["defaultDepartments"] = default_scope
881
+ relation = next((item for item in select_scope_relations if _positive_int(item.get("queId")) == que_id), None)
882
+ scope_relation = _canonical_scope_relation(relation)
883
+ if scope_relation is not None:
884
+ field["config"]["departmentScopeRelation"] = scope_relation
885
+ for key in ("noRepeat", "canScan", "scanType", "beingShowParentPath"):
886
+ if question.get(key) is not None:
887
+ field["config"][key] = question[key]
888
+ elif field_type == "attachment":
889
+ file_types = question.get("fileType")
890
+ if isinstance(file_types, list):
891
+ file_types = [item for item in file_types if str(item or "").strip()]
892
+ elif isinstance(file_types, str) and not file_types.strip():
893
+ file_types = None
894
+ field["config"] = _without_none({
895
+ "fileSize": question.get("fileSize"),
896
+ "fileType": file_types,
897
+ "fileBeingCaptureOnly": question.get("fileBeingCaptureOnly"),
898
+ "imageCompress": question.get("imageCompress"),
899
+ "pluginStatus": question.get("pluginStatus"),
900
+ "maxFileLimitStatus": question.get("maxFileLimitStatus"),
901
+ "maxFileLimit": question.get("maxFileLimit"),
902
+ "canScan": question.get("canScan"),
903
+ "scanType": question.get("scanType"),
904
+ "defaultValues": _canonical_attachment_default_values(question.get("queDefaultValues")),
905
+ })
906
+ elif field_type == "address":
907
+ field["config"] = _without_none({
908
+ "addressPrecision": question.get("addressPrecision"),
909
+ "pickPosition": question.get("pickPosition"),
910
+ "canScan": question.get("canScan"),
911
+ "scanType": question.get("scanType"),
912
+ "defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) and (question.get("queDefaultValues", {}).get("values") or question.get("queDefaultValues", {}).get("tableValues")) else None,
913
+ })
914
+ elif field_type == "relation":
915
+ reference = question.get("referenceConfig") if isinstance(question.get("referenceConfig"), dict) else {}
916
+ refer_questions = [item for item in reference.get("referQuestions") or [] if isinstance(item, dict)]
917
+ display_id = _positive_int(reference.get("referQueId"))
918
+ display = next((item for item in refer_questions if _positive_int(item.get("queId")) == display_id), None)
919
+ visible = [item for item in refer_questions if _positive_int(item.get("queAuth")) != 2]
920
+ target_app_key = str(reference.get("referAppKey") or "")
921
+ if not target_app_key.strip():
922
+ raise AppFormFailure(
923
+ "UNSUPPORTED_FIELD_CONFIG",
924
+ "relation target app cannot be normalized",
925
+ "NORMALIZE",
926
+ details={"queId": que_id, "path": "/config/targetAppKey"},
927
+ )
928
+ relation_config: JSONObject = {
929
+ "targetAppKey": target_app_key,
930
+ "relationMode": "multiple" if _positive_int(reference.get("optionalDataNum")) not in {None, 1} else "single",
931
+ "displayField": _require_selector(
932
+ display_id,
933
+ (display or {}).get("queTitle"),
934
+ message="relation display field cannot be normalized",
935
+ details={"queId": que_id, "path": "/config/displayField"},
936
+ ),
937
+ "visibleFields": [
938
+ _require_selector(
939
+ item.get("queId"),
940
+ item.get("queTitle"),
941
+ message="relation visible field cannot be normalized",
942
+ details={"queId": que_id},
943
+ )
944
+ for item in visible
945
+ ],
946
+ "beingDefaultFormulaAutoFillEnabled": reference.get("beingDefaultFormulaAutoFillEnabled"),
947
+ "defaultValueMatchRules": reference.get("defaultValueMatchRules"),
948
+ "defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
949
+ }
950
+ if supports_reference_default_formula:
951
+ formula = reference.get("defaultValueFormula")
952
+ relation_config["defaultValueFormula"] = formula if not isinstance(formula, str) or formula.strip() else None
953
+ field["config"] = _without_none(relation_config)
954
+ elif field_type == "q_linker":
955
+ field["config"] = {"qLinkerBinding": self._q_linker_binding(question, relations, field_names)}
956
+ elif field_type == "code_block":
957
+ field["config"] = {"codeBlockBinding": self._code_block_binding(question, relations, field_names)}
958
+ elif field_type == "data_relation":
959
+ field["config"] = self._data_relation_config(question, relations)
960
+ elif field_type == "subtable":
961
+ subfields = [
962
+ self._field(item, relations, select_scope_relations, field_names, supports_reference_default_formula)
963
+ for item in question.get("subQuestions") or []
964
+ if isinstance(item, dict)
965
+ ]
966
+ if not subfields:
967
+ raise AppFormFailure(
968
+ "UNSUPPORTED_FIELD_CONFIG",
969
+ "subtable has no subfields",
970
+ "NORMALIZE",
971
+ details={"queId": que_id, "path": "/subfields"},
972
+ )
973
+ field["subfields"] = subfields
974
+ for subfield in field["subfields"]:
975
+ subfield.pop("width", None)
976
+ row_height = question.get("tableRowHeight")
977
+ if isinstance(row_height, str) and not row_height.strip():
978
+ row_height = None
979
+ field["config"] = _without_none({
980
+ "tableRowsLimit": question.get("tableRowsLimit"),
981
+ "tableNoRepeatStatus": question.get("tableNoRepeatStatus"),
982
+ "tableNoRepeatQuestion": [_selector(item, field_names.get(_positive_int(item))) for item in question.get("tableNoRepeatQuestion") or [] if _positive_int(item) is not None],
983
+ "tableRowHeight": row_height,
984
+ "defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) and (question.get("queDefaultValues", {}).get("values") or question.get("queDefaultValues", {}).get("tableValues")) else None,
985
+ })
986
+ return field
987
+
988
+ @staticmethod
989
+ def _attachments(value: Any) -> list[JSONObject]:
990
+ def attachment_text(item: JSONObject, *keys: str) -> Any:
991
+ for key in keys:
992
+ candidate = item.get(key)
993
+ if candidate:
994
+ return candidate
995
+ return None
996
+
997
+ result = [
998
+ _without_none(
999
+ {
1000
+ "attachId": _positive_int(item.get("attachId") or item.get("id")),
1001
+ "fileName": attachment_text(item, "fileName", "name"),
1002
+ "fileUrl": attachment_text(item, "fileUrl", "url"),
1003
+ }
1004
+ )
1005
+ for item in value or []
1006
+ if isinstance(item, dict)
1007
+ and str(attachment_text(item, "fileName", "name") or "").strip()
1008
+ and str(attachment_text(item, "fileUrl", "url") or "").strip()
1009
+ ]
1010
+ return sorted(result, key=lambda item: (item.get("attachId", 0), item["fileName"], item["fileUrl"]))
1011
+
1012
+ @staticmethod
1013
+ def _serial_number(serial_type: Any, value: Any) -> JSONObject:
1014
+ if _positive_int(serial_type) != 2:
1015
+ return {"mode": "default"}
1016
+ type_names = {1: "auto_count", 2: "submit_datetime", 3: "fixed_text", 4: "field"}
1017
+ repeat_names = {item: name for name, item in REPEAT_PERIOD_TO_BACKEND.items()}
1018
+ date_names = {item: name for name, item in DATE_FORMAT_TO_BACKEND.items()}
1019
+ components = []
1020
+ for item in value or []:
1021
+ if not isinstance(item, dict) or _positive_int(item.get("type")) not in type_names:
1022
+ continue
1023
+ kind = type_names[_positive_int(item.get("type")) or 1]
1024
+ component: JSONObject = _without_none({"id": _positive_int(item.get("id")), "type": kind})
1025
+ if kind == "auto_count":
1026
+ component.update({"length": int(item.get("length") or 3), "repeatPeriod": repeat_names.get(item.get("repeatPeriod"), "none")})
1027
+ elif kind == "submit_datetime":
1028
+ component["dateFormat"] = date_names.get(item.get("dateFormat"), "year_month_day")
1029
+ elif kind == "fixed_text":
1030
+ component["value"] = _required_text(
1031
+ item.get("customTitle"),
1032
+ message="custom serial number fixed text is empty",
1033
+ details={"componentId": _positive_int(item.get("id")), "path": "/spec/serialNumber/components/value"},
1034
+ )
1035
+ else:
1036
+ component["field"] = _require_selector(
1037
+ item.get("queId"),
1038
+ item.get("queTitle"),
1039
+ message="custom serial number field cannot be normalized",
1040
+ details={"componentId": _positive_int(item.get("id")), "path": "/spec/serialNumber/components/field"},
1041
+ )
1042
+ if item.get("dateFormat") is not None:
1043
+ component["dateFormat"] = date_names.get(item.get("dateFormat"), "year_month_day")
1044
+ components.append(component)
1045
+ if not components:
1046
+ raise AppFormFailure(
1047
+ "UNSUPPORTED_FIELD_CONFIG",
1048
+ "custom serial number has no components",
1049
+ "NORMALIZE",
1050
+ details={"path": "/spec/serialNumber/components"},
1051
+ )
1052
+ return {"mode": "custom", "components": components}
1053
+
1054
+ @staticmethod
1055
+ def _field_index(question: JSONObject) -> dict[int, str]:
1056
+ result: dict[int, str] = {}
1057
+ for row in question.get("formQues") or []:
1058
+ for item in row if isinstance(row, list) else []:
1059
+ if not isinstance(item, dict):
1060
+ continue
1061
+ if _positive_int(item.get("queType")) == 24:
1062
+ for inner in item.get("innerQuestions") or []:
1063
+ for nested in inner if isinstance(inner, list) else []:
1064
+ if isinstance(nested, dict) and _positive_int(nested.get("queId")):
1065
+ nested_id = _positive_int(nested.get("queId")) or 0
1066
+ result[nested_id] = _compat_label(nested.get("queTitle"), "Field", nested_id)
1067
+ elif _positive_int(item.get("queId")):
1068
+ item_id = _positive_int(item.get("queId")) or 0
1069
+ result[item_id] = _compat_label(item.get("queTitle"), "Field", item_id)
1070
+ return result
1071
+
1072
+ @staticmethod
1073
+ def _q_linker_binding(question: JSONObject, relations: list[JSONObject], field_names: dict[int, str]) -> JSONObject:
1074
+ config = question.get("remoteLookupConfig") if isinstance(question.get("remoteLookupConfig"), dict) else {}
1075
+ source_id = _positive_int(question.get("queId"))
1076
+ outputs = []
1077
+ for alias in config.get("resultFormatPath") or []:
1078
+ if not isinstance(alias, dict):
1079
+ continue
1080
+ name = _required_text(
1081
+ alias.get("aliasName"),
1082
+ message="Q-Linker output alias is empty",
1083
+ details={"queId": source_id, "path": "/config/qLinkerBinding/outputs/alias"},
1084
+ )
1085
+ path = _required_text(
1086
+ alias.get("aliasPath"),
1087
+ message="Q-Linker output path is empty",
1088
+ details={"queId": source_id, "alias": name, "path": "/config/qLinkerBinding/outputs/path"},
1089
+ )
1090
+ relation = next(
1091
+ (
1092
+ item
1093
+ for item in relations
1094
+ if _positive_int((item.get("aliasConfig") or {}).get("queId")) == source_id
1095
+ and str((item.get("aliasConfig") or {}).get("qlinkerAlias") or item.get("qlinkerAlias") or "") == name
1096
+ ),
1097
+ {},
1098
+ )
1099
+ outputs.append(
1100
+ _without_none(
1101
+ {
1102
+ "alias": name,
1103
+ "path": path,
1104
+ "aliasId": _positive_int(alias.get("aliasId")) or _positive_int((relation.get("aliasConfig") or {}).get("aliasId")),
1105
+ "targetField": _require_selector(
1106
+ relation.get("queId"),
1107
+ relation.get("queTitle") or field_names.get(_positive_int(relation.get("queId")) or -1),
1108
+ message="Q-Linker output target cannot be normalized",
1109
+ details={"queId": source_id, "alias": name},
1110
+ ),
1111
+ }
1112
+ )
1113
+ )
1114
+ type_to_source = {"headers": "header", "queryParams": "query_param", "urlEncodedValue": "url_encoded"}
1115
+ inputs = []
1116
+ static: dict[str, list[JSONObject]] = {"headers": [], "queryParams": [], "urlEncodedValue": []}
1117
+ for key, source in type_to_source.items():
1118
+ for item in config.get(key) or []:
1119
+ if not isinstance(item, dict):
1120
+ continue
1121
+ if _positive_int(item.get("type")) == 2:
1122
+ input_que_id = _positive_int(item.get("value"))
1123
+ input_key = _required_text(
1124
+ item.get("key"),
1125
+ message="Q-Linker input key is empty",
1126
+ details={"queId": source_id, "source": source, "path": "/config/qLinkerBinding/inputs/key"},
1127
+ )
1128
+ inputs.append(
1129
+ {
1130
+ "field": _require_selector(
1131
+ input_que_id,
1132
+ field_names.get(input_que_id or -1),
1133
+ message="Q-Linker input field cannot be normalized",
1134
+ details={"queId": source_id, "key": input_key},
1135
+ ),
1136
+ "key": input_key,
1137
+ "source": source,
1138
+ }
1139
+ )
1140
+ else:
1141
+ static_key = _required_text(
1142
+ item.get("key"),
1143
+ message="Q-Linker request key is empty",
1144
+ details={"queId": source_id, "source": source, "path": f"/config/qLinkerBinding/request/{source}/key"},
1145
+ )
1146
+ static[key].append({"key": static_key, "value": item.get("value")})
1147
+ request_values = {
1148
+ "url": config.get("url"),
1149
+ "jsonValue": config.get("jsonValue"),
1150
+ "xmlValue": config.get("xmlValue"),
1151
+ }
1152
+ json_path_inputs: dict[str, JSONObject] = {}
1153
+ for request_key, raw_value in tuple(request_values.items()):
1154
+ if not isinstance(raw_value, str):
1155
+ continue
1156
+
1157
+ def replace_reference(match: re.Match[str]) -> str:
1158
+ name = match.group("name").strip()
1159
+ if name:
1160
+ token = match.group("token").strip()
1161
+ matching_ids = [
1162
+ que_id
1163
+ for que_id, field_name in field_names.items()
1164
+ if field_name == name and _encrypt_question_id(que_id) == token
1165
+ ]
1166
+ json_path_inputs.setdefault(
1167
+ name,
1168
+ {
1169
+ "field": _selector(matching_ids[0] if len(matching_ids) == 1 else None, name),
1170
+ "key": name,
1171
+ "source": "json_path",
1172
+ },
1173
+ )
1174
+ return "{{" + name + "}}"
1175
+
1176
+ request_values[request_key] = Q_LINKER_VALUE_REFERENCE_RE.sub(replace_reference, raw_value)
1177
+ inputs.extend(json_path_inputs.values())
1178
+ request_url = _required_text(
1179
+ request_values["url"],
1180
+ message="Q-Linker request URL is empty",
1181
+ details={"queId": source_id, "path": "/config/qLinkerBinding/request/url"},
1182
+ )
1183
+ request = _without_none(
1184
+ {
1185
+ "url": request_url,
1186
+ "method": config.get("method") or "GET",
1187
+ **static,
1188
+ "bodyType": {1: "url_encoded", 2: "json", 3: "xml"}.get(_positive_int(config.get("bodyType")) or 1, "url_encoded"),
1189
+ "jsonValue": request_values["jsonValue"],
1190
+ "xmlValue": request_values["xmlValue"],
1191
+ "resultType": {1: "json", 2: "xml", 3: "text"}.get(_positive_int(config.get("resultType")) or 1, "json"),
1192
+ "autoTrigger": bool(question.get("autoTrigger")),
1193
+ "customButtonTextEnabled": bool(question.get("customBtnTextStatus")),
1194
+ "customButtonText": str(question.get("customBtnText") or ""),
1195
+ "insertValueDirectly": bool(config.get("beingInsertValueDirectly")),
1196
+ "hideOnForm": bool(config.get("beingHideOnForm")),
1197
+ }
1198
+ )
1199
+ if not outputs:
1200
+ raise AppFormFailure(
1201
+ "UNSUPPORTED_FIELD_CONFIG",
1202
+ "Q-Linker has no output mapping",
1203
+ "NORMALIZE",
1204
+ details={"queId": source_id, "path": "/config/qLinkerBinding/outputs"},
1205
+ )
1206
+ return {"inputs": inputs, "request": request, "outputs": outputs}
1207
+
1208
+ @staticmethod
1209
+ def _code_block_binding(question: JSONObject, relations: list[JSONObject], field_names: dict[int, str]) -> JSONObject:
1210
+ config = question.get("codeBlockConfig") if isinstance(question.get("codeBlockConfig"), dict) else {}
1211
+ source_id = _positive_int(question.get("queId"))
1212
+ outputs = []
1213
+ for alias in config.get("resultAliasPath") or []:
1214
+ if not isinstance(alias, dict):
1215
+ continue
1216
+ name = _required_text(
1217
+ alias.get("aliasName"),
1218
+ message="code block output alias is empty",
1219
+ details={"queId": source_id, "path": "/config/codeBlockBinding/outputs/alias"},
1220
+ )
1221
+ path = _required_text(
1222
+ alias.get("aliasPath"),
1223
+ message="code block output path is empty",
1224
+ details={"queId": source_id, "alias": name, "path": "/config/codeBlockBinding/outputs/path"},
1225
+ )
1226
+ relation = next(
1227
+ (
1228
+ item
1229
+ for item in relations
1230
+ if _positive_int((item.get("aliasConfig") or {}).get("queId")) == source_id
1231
+ and str((item.get("aliasConfig") or {}).get("qlinkerAlias") or "") == name
1232
+ ),
1233
+ {},
1234
+ )
1235
+ output = {
1236
+ "alias": name,
1237
+ "path": path,
1238
+ "targetField": _require_selector(
1239
+ relation.get("queId"),
1240
+ relation.get("queTitle") or field_names.get(_positive_int(relation.get("queId")) or -1),
1241
+ message="code block output target cannot be normalized",
1242
+ details={"queId": source_id, "alias": name},
1243
+ ),
1244
+ }
1245
+ if _positive_int(alias.get("aliasId")):
1246
+ output["aliasId"] = _positive_int(alias.get("aliasId"))
1247
+ outputs.append(output)
1248
+ code = str(config.get("codeContent") or "")
1249
+ inputs = []
1250
+ lines = code.splitlines()
1251
+ while lines and lines[0].strip().startswith("const ") and "qf_field.{" in lines[0]:
1252
+ line = lines.pop(0).strip()
1253
+ var = line[6:].split("=", 1)[0].strip()
1254
+ name = line.split("qf_field.{", 1)[1].split("$$", 1)[0]
1255
+ input_name = _required_text(
1256
+ name,
1257
+ message="code block input field is empty",
1258
+ details={"queId": source_id, "path": "/config/codeBlockBinding/inputs/field"},
1259
+ )
1260
+ inputs.append(
1261
+ {
1262
+ "field": {"name": input_name},
1263
+ "var": var,
1264
+ }
1265
+ )
1266
+ code = "\n".join(lines).lstrip("\n")
1267
+ if not code.strip():
1268
+ raise AppFormFailure(
1269
+ "UNSUPPORTED_FIELD_CONFIG",
1270
+ "code block has no code",
1271
+ "NORMALIZE",
1272
+ details={"queId": source_id, "path": "/config/codeBlockBinding/code"},
1273
+ )
1274
+ return _without_none(
1275
+ {
1276
+ "inputs": inputs,
1277
+ "code": code,
1278
+ "autoTrigger": question.get("autoTrigger"),
1279
+ "customButtonTextEnabled": question.get("customBtnTextStatus"),
1280
+ "customButtonText": question.get("customBtnText"),
1281
+ "hideOnForm": config.get("beingHideOnForm"),
1282
+ "outputs": outputs,
1283
+ }
1284
+ )
1285
+
1286
+ @staticmethod
1287
+ def _data_relation_config(question: JSONObject, relations: list[JSONObject]) -> JSONObject:
1288
+ que_id = _positive_int(question.get("queId"))
1289
+ relation = next((item for item in relations if _positive_int(item.get("queId")) == que_id), None)
1290
+ if relation is None:
1291
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation has no question relation", "NORMALIZE", details={"queId": que_id})
1292
+ relation_type = _positive_int(relation.get("relationType")) or 1
1293
+ displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
1294
+ alias = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
1295
+ if relation_type == 1:
1296
+ source_app_key = str(displayed.get("appKey") or "")
1297
+ if not source_app_key.strip():
1298
+ raise AppFormFailure(
1299
+ "UNSUPPORTED_FIELD_CONFIG",
1300
+ "data_relation source app cannot be normalized",
1301
+ "NORMALIZE",
1302
+ details={"queId": que_id},
1303
+ )
1304
+ source = {
1305
+ "kind": "app",
1306
+ "appKey": source_app_key,
1307
+ "field": _require_selector(
1308
+ relation.get("displayedQueId"),
1309
+ displayed.get("queTitle"),
1310
+ message="data_relation source field cannot be normalized",
1311
+ details={"queId": que_id},
1312
+ ),
1313
+ }
1314
+ else:
1315
+ producer = _require_selector(
1316
+ alias.get("queId"),
1317
+ alias.get("queTitle"),
1318
+ message="data_relation producer cannot be normalized",
1319
+ details={"queId": que_id},
1320
+ )
1321
+ alias_name = str(alias.get("qlinkerAlias") or relation.get("qlinkerAlias") or "").strip()
1322
+ if not alias_name:
1323
+ raise AppFormFailure(
1324
+ "UNSUPPORTED_FIELD_CONFIG",
1325
+ "data_relation alias cannot be normalized",
1326
+ "NORMALIZE",
1327
+ details={"queId": que_id},
1328
+ )
1329
+ source = {
1330
+ "kind": "q_linker" if relation_type == 2 else "code_block",
1331
+ "producer": producer,
1332
+ "alias": _without_none({"name": alias_name, "aliasId": _positive_int(alias.get("aliasId"))}),
1333
+ }
1334
+ result: JSONObject = {"source": source}
1335
+ backend_aggregation = question.get("aggreType")
1336
+ aggregation_names = {value: key for key, value in AGGREGATION_TO_BACKEND.items()}
1337
+ if backend_aggregation in aggregation_names:
1338
+ result["aggregation"] = aggregation_names[backend_aggregation]
1339
+ if _positive_int(relation.get("matchRuleType")) == 2 or relation.get("matchRuleFormula"):
1340
+ if not str(relation.get("matchRuleFormula") or "").strip():
1341
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation advanced formula is missing", "NORMALIZE", details={"queId": que_id})
1342
+ result["filters"] = {"mode": "advanced", "formula": str(relation.get("matchRuleFormula"))}
1343
+ else:
1344
+ groups = []
1345
+ for group in relation.get("matchRules") or []:
1346
+ if not isinstance(group, list):
1347
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation match rules are malformed", "NORMALIZE", details={"queId": que_id})
1348
+ conditions = [AppFormNormalizer._data_relation_condition(item, que_id=que_id) for item in group if isinstance(item, dict)]
1349
+ if conditions:
1350
+ groups.append(conditions)
1351
+ result["filters"] = {"mode": "simple", "groups": groups}
1352
+ sort = relation.get("sortConfig") if isinstance(relation.get("sortConfig"), dict) else None
1353
+ if sort is not None:
1354
+ if not bool(sort.get("status")):
1355
+ result["sort"] = {"enabled": False}
1356
+ elif _positive_int(sort.get("queId")):
1357
+ result["sort"] = {"enabled": True, "by": {"kind": "field", "field": _selector(sort.get("queId"), None)}, "direction": "asc" if sort.get("ascend") else "desc"}
1358
+ elif str(sort.get("qlinkerAlias") or "").strip():
1359
+ producer = _require_selector(
1360
+ alias.get("queId"),
1361
+ alias.get("queTitle"),
1362
+ message="data_relation alias sort producer cannot be normalized",
1363
+ details={"queId": que_id},
1364
+ )
1365
+ alias_name = str(sort.get("qlinkerAlias") or "").strip()
1366
+ result["sort"] = {
1367
+ "enabled": True,
1368
+ "by": {
1369
+ "kind": "alias",
1370
+ "producer": producer,
1371
+ "alias": _without_none({"name": alias_name, "aliasId": _positive_int(alias.get("aliasId")) if alias_name == str(alias.get("qlinkerAlias") or "") else None}),
1372
+ },
1373
+ "direction": "asc" if sort.get("ascend") else "desc",
1374
+ }
1375
+ else:
1376
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation sort cannot be normalized", "NORMALIZE", details={"queId": que_id})
1377
+ button = question.get("customButtonConfig") if isinstance(question.get("customButtonConfig"), dict) else None
1378
+ if button is not None and bool(button.get("status")):
1379
+ detail = button.get("customButtonDetailInfo") if isinstance(button.get("customButtonDetailInfo"), dict) else {}
1380
+ button_id = _positive_int(detail.get("buttonId")) or _positive_int(button.get("buttonId"))
1381
+ if button_id is None:
1382
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "enabled data_relation custom button has no buttonId", "NORMALIZE", details={"queId": que_id})
1383
+ result["customButton"] = {"enabled": True, "buttonId": button_id}
1384
+ return result
1385
+
1386
+ @staticmethod
1387
+ def _data_relation_condition(value: JSONObject, *, que_id: int | None) -> JSONObject:
1388
+ judge_type = value.get("judgeType")
1389
+ if isinstance(judge_type, bool) or not isinstance(judge_type, int) or judge_type not in BACKEND_TO_JUDGE_TYPE:
1390
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation judgeType cannot be normalized", "NORMALIZE", details={"queId": que_id, "judgeType": judge_type})
1391
+ condition: JSONObject = {
1392
+ "sourceField": _require_selector(value.get("queId"), value.get("queTitle"), message="data_relation source field cannot be normalized", details={"queId": que_id}),
1393
+ "operator": BACKEND_TO_JUDGE_TYPE[judge_type],
1394
+ }
1395
+ match_type = _positive_int(value.get("matchType")) or 1
1396
+ if match_type == 1:
1397
+ details = value.get("judgeValueDetails")
1398
+ if isinstance(details, list):
1399
+ literal_values = [
1400
+ str(item.get("value") if item.get("value") is not None else item.get("id"))
1401
+ for item in details
1402
+ if isinstance(item, dict) and (item.get("value") is not None or item.get("id") is not None)
1403
+ ]
1404
+ else:
1405
+ literal_values = deepcopy(value.get("judgeValues") or [])
1406
+ condition["operand"] = {"kind": "literal", "values": literal_values}
1407
+ elif match_type == 2:
1408
+ detail = value.get("judgeQueDetail") if isinstance(value.get("judgeQueDetail"), dict) else {}
1409
+ condition["operand"] = {
1410
+ "kind": "current_field",
1411
+ "field": _require_selector(detail.get("queId"), detail.get("queTitle"), message="data_relation current-field operand cannot be normalized", details={"queId": que_id}),
1412
+ }
1413
+ elif match_type == 7:
1414
+ alias = value.get("aliasConfig") if isinstance(value.get("aliasConfig"), dict) else {}
1415
+ alias_name = str(alias.get("qlinkerAlias") or "").strip()
1416
+ if not alias_name:
1417
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation alias operand cannot be normalized", "NORMALIZE", details={"queId": que_id})
1418
+ condition["operand"] = {
1419
+ "kind": "alias",
1420
+ "producer": _require_selector(alias.get("queId"), alias.get("queTitle"), message="data_relation alias producer cannot be normalized", details={"queId": que_id}),
1421
+ "alias": _without_none({"name": alias_name, "aliasId": _positive_int(alias.get("aliasId"))}),
1422
+ }
1423
+ else:
1424
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation matchType cannot be normalized", "NORMALIZE", details={"queId": que_id, "matchType": match_type})
1425
+ return condition
1426
+
1427
+
1428
+ class AppFormCompiler:
1429
+ def __init__(self, apps: AppTools) -> None:
1430
+ self.apps = apps
1431
+
1432
+ @staticmethod
1433
+ def is_unbound_intermediate_error(error: AppFormFailure) -> bool:
1434
+ return error.error_code == "UNSUPPORTED_FIELD_CONFIG" and error.message in {
1435
+ "Q-Linker output target cannot be normalized",
1436
+ "code block output target cannot be normalized",
1437
+ "data_relation has no question relation",
1438
+ }
1439
+
1440
+ def bind_backend_identities(self, *, form: JSONObject, resource: JSONObject) -> JSONObject:
1441
+ result = deepcopy(resource)
1442
+ questions_by_name: dict[str, list[JSONObject]] = {}
1443
+ sections_by_title: dict[str, list[JSONObject]] = {}
1444
+
1445
+ def register_question(question: JSONObject) -> None:
1446
+ name = str(question.get("queTitle") or "")
1447
+ if name:
1448
+ questions_by_name.setdefault(name, []).append(question)
1449
+
1450
+ for row in form.get("formQues") or []:
1451
+ if not isinstance(row, list):
1452
+ continue
1453
+ for question in row:
1454
+ if not isinstance(question, dict):
1455
+ continue
1456
+ if _positive_int(question.get("queType")) == 24:
1457
+ title = str(question.get("queTitle") or "")
1458
+ if title:
1459
+ sections_by_title.setdefault(title, []).append(question)
1460
+ for inner_row in question.get("innerQuestions") or []:
1461
+ for inner_question in inner_row if isinstance(inner_row, list) else []:
1462
+ if isinstance(inner_question, dict):
1463
+ register_question(inner_question)
1464
+ continue
1465
+ register_question(question)
1466
+
1467
+ def resolve_question(field: JSONObject, candidates: list[JSONObject], *, parent: str | None = None) -> JSONObject:
1468
+ field_id = _positive_int(field.get("queId"))
1469
+ if field_id is not None:
1470
+ matched = [item for item in candidates if _positive_int(item.get("queId")) == field_id]
1471
+ if len(matched) == 1:
1472
+ return matched[0]
1473
+ name = str(field.get("name") or "")
1474
+ matched = [item for item in candidates if str(item.get("queTitle") or "") == name]
1475
+ if len(matched) != 1:
1476
+ raise AppFormFailure(
1477
+ "RELATION_REBIND_FAILED",
1478
+ "identity write did not return a unique field identity",
1479
+ "IDENTITY_REBOUND",
1480
+ details={"field": name, "parent": parent, "matches": len(matched)},
1481
+ )
1482
+ return matched[0]
1483
+
1484
+ def bind_field(field: JSONObject) -> None:
1485
+ name = str(field.get("name") or "")
1486
+ question = resolve_question(field, questions_by_name.get(name) or [])
1487
+ que_id = _positive_int(question.get("queId"))
1488
+ if que_id is None:
1489
+ raise AppFormFailure(
1490
+ "RELATION_REBIND_FAILED",
1491
+ "identity write returned a field without queId",
1492
+ "IDENTITY_REBOUND",
1493
+ details={"field": name},
1494
+ )
1495
+ field["queId"] = que_id
1496
+ field_type = _field_type(field)
1497
+ alias_binding = {
1498
+ "code_block": ("codeBlockBinding", "codeBlockConfig", "resultAliasPath"),
1499
+ "q_linker": ("qLinkerBinding", "remoteLookupConfig", "resultFormatPath"),
1500
+ }.get(field_type)
1501
+ if alias_binding is not None:
1502
+ binding_key, backend_config_key, backend_aliases_key = alias_binding
1503
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
1504
+ binding = config.get(binding_key) if isinstance(config.get(binding_key), dict) else {}
1505
+ outputs = binding.get("outputs") if isinstance(binding.get("outputs"), list) else []
1506
+ backend_config = question.get(backend_config_key) if isinstance(question.get(backend_config_key), dict) else {}
1507
+ backend_aliases = [
1508
+ item
1509
+ for item in backend_config.get(backend_aliases_key) or []
1510
+ if isinstance(item, dict)
1511
+ ]
1512
+ for output_index, output in enumerate(outputs):
1513
+ if not isinstance(output, dict):
1514
+ continue
1515
+ alias_name = str(output.get("alias") or "").strip()
1516
+ alias_path = str(output.get("path") or "").strip()
1517
+ matches = [
1518
+ item
1519
+ for item in backend_aliases
1520
+ if str(item.get("aliasName") or "").strip() == alias_name
1521
+ and str(item.get("aliasPath") or "").strip() == alias_path
1522
+ ]
1523
+ if len(matches) != 1:
1524
+ raise AppFormFailure(
1525
+ "RELATION_REBIND_FAILED",
1526
+ "identity write did not return a unique output alias identity",
1527
+ "IDENTITY_REBOUND",
1528
+ details={
1529
+ "field": name,
1530
+ "fieldType": field_type,
1531
+ "outputIndex": output_index,
1532
+ "alias": alias_name,
1533
+ "path": alias_path,
1534
+ "matches": len(matches),
1535
+ },
1536
+ )
1537
+ alias_id = _positive_int(matches[0].get("aliasId"))
1538
+ if alias_id is None:
1539
+ raise AppFormFailure(
1540
+ "RELATION_REBIND_FAILED",
1541
+ "identity write returned an output alias without aliasId",
1542
+ "IDENTITY_REBOUND",
1543
+ details={
1544
+ "field": name,
1545
+ "fieldType": field_type,
1546
+ "outputIndex": output_index,
1547
+ "alias": alias_name,
1548
+ "path": alias_path,
1549
+ },
1550
+ )
1551
+ output["aliasId"] = alias_id
1552
+ subquestions = [item for item in question.get("subQuestions") or [] if isinstance(item, dict)]
1553
+ for subfield in field.get("subfields") or []:
1554
+ if not isinstance(subfield, dict):
1555
+ continue
1556
+ subquestion = resolve_question(subfield, subquestions, parent=name)
1557
+ sub_que_id = _positive_int(subquestion.get("queId"))
1558
+ if sub_que_id is None:
1559
+ raise AppFormFailure(
1560
+ "RELATION_REBIND_FAILED",
1561
+ "identity write returned a subfield without queId",
1562
+ "IDENTITY_REBOUND",
1563
+ details={"field": subfield.get("name"), "parent": name},
1564
+ )
1565
+ subfield["queId"] = sub_que_id
1566
+
1567
+ claimed_sections: set[int] = set()
1568
+ for block in result.get("spec", {}).get("body") or []:
1569
+ if not isinstance(block, dict):
1570
+ continue
1571
+ rows = [block]
1572
+ if block.get("kind") == "section":
1573
+ title = str(block.get("title") or "")
1574
+ section_id = _positive_int(block.get("sectionId"))
1575
+ candidates = [
1576
+ item
1577
+ for item in sections_by_title.get(title) or []
1578
+ if id(item) not in claimed_sections
1579
+ and (section_id is None or _positive_int(item.get("queId")) == section_id)
1580
+ ]
1581
+ if len(candidates) != 1:
1582
+ raise AppFormFailure(
1583
+ "RELATION_REBIND_FAILED",
1584
+ "identity write did not return a unique section identity",
1585
+ "IDENTITY_REBOUND",
1586
+ details={"section": title, "matches": len(candidates)},
1587
+ )
1588
+ section = candidates[0]
1589
+ claimed_sections.add(id(section))
1590
+ resolved_section_id = _positive_int(section.get("queId"))
1591
+ if resolved_section_id is None:
1592
+ raise AppFormFailure(
1593
+ "RELATION_REBIND_FAILED",
1594
+ "identity write returned a section without sectionId",
1595
+ "IDENTITY_REBOUND",
1596
+ details={"section": title},
1597
+ )
1598
+ block["sectionId"] = str(resolved_section_id)
1599
+ rows = block.get("rows") or []
1600
+ for row in rows:
1601
+ if not isinstance(row, dict):
1602
+ continue
1603
+ for field in row.get("fields") or []:
1604
+ if isinstance(field, dict):
1605
+ bind_field(field)
1606
+ return result
1607
+
1608
+ def recovery_resource(
1609
+ self,
1610
+ *,
1611
+ context: AppFormSchemaContext,
1612
+ app_key: str,
1613
+ form: JSONObject,
1614
+ declaration: JSONObject,
1615
+ ) -> JSONObject:
1616
+ resource = self.bind_backend_identities(form=form, resource=declaration)
1617
+ spec = resource["spec"]
1618
+ field_names = AppFormNormalizer._field_index(form)
1619
+ title_id = _positive_int(form.get("dataTitleQueId"))
1620
+ cover_id = _positive_int(form.get("dataCoverQueId"))
1621
+ spec.setdefault("appName", str(form.get("formTitle") or ""))
1622
+ spec.setdefault("appDescription", form.get("formDesc") or None)
1623
+ spec.setdefault("dataTitleField", _selector(title_id, field_names.get(title_id or 0)) if title_id is not None else None)
1624
+ spec.setdefault("dataCoverField", _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None else None)
1625
+ spec.setdefault("attachments", AppFormNormalizer._attachments(form.get("formAttach")))
1626
+ spec.setdefault(
1627
+ "serialNumber",
1628
+ AppFormNormalizer._serial_number(form.get("serialNumType"), form.get("serialNumConfig")),
1629
+ )
1630
+ resource["apiVersion"] = context.api_version
1631
+ resource["appKey"] = app_key
1632
+ resource.pop("packageId", None)
1633
+ return resource
1634
+
1635
+ def target_resource(self, *, context: AppFormSchemaContext, app_key: str, current: JSONObject, declaration: JSONObject) -> JSONObject:
1636
+ current_spec = current["spec"]
1637
+ declared_spec = declaration["spec"]
1638
+ target_spec = deepcopy(current_spec)
1639
+ for key in ("appName", "appDescription", "dataTitleField", "dataCoverField", "attachments", "serialNumber"):
1640
+ if key in declared_spec:
1641
+ target_spec[key] = deepcopy(declared_spec[key])
1642
+ target_spec["body"] = self._target_body(current_spec.get("body") or [], declared_spec.get("body") or [])
1643
+ target_fields = list(_iter_fields(target_spec["body"]))
1644
+ for key in ("dataTitleField", "dataCoverField"):
1645
+ if key in declared_spec or not isinstance(target_spec.get(key), dict):
1646
+ continue
1647
+ selector_id = _positive_int(target_spec[key].get("queId"))
1648
+ selected = [field for field in target_fields if selector_id is not None and _positive_int(field.get("queId")) == selector_id]
1649
+ if len(selected) == 1:
1650
+ target_spec[key] = _selector(selected[0].get("queId"), selected[0].get("name"))
1651
+ data_title, data_cover = self._validate_target(target_spec)
1652
+ target_spec["dataTitleField"] = _selector(data_title.get("queId"), data_title.get("name")) if data_title is not None else None
1653
+ target_spec["dataCoverField"] = _selector(data_cover.get("queId"), data_cover.get("name")) if data_cover is not None else None
1654
+ return {"apiVersion": context.api_version, "appKey": app_key, "spec": target_spec}
1655
+
1656
+ def preflight(self, *, profile: str, app_key: str, declaration: JSONObject, current_form: JSONObject | None) -> None:
1657
+ declaration = self._resolve_declared_field_names(declaration, current_form)
1658
+ spec = declaration.get("spec", {})
1659
+ body = spec.get("body") or []
1660
+ self._validate_target(spec, allow_missing_data_title=current_form is not None)
1661
+ fields = list(_iter_fields(body))
1662
+ templates = self._question_templates(current_form or {})
1663
+ internal_fields = [self._internal_field(field, templates=templates) for field in fields]
1664
+ self._assign_layout_temp_ids(body, internal_fields)
1665
+ try:
1666
+ internal_fields, _ = _normalize_and_validate_code_block_fields_for_write(fields=internal_fields)
1667
+ internal_fields, relations = _compile_code_block_binding_fields(fields=internal_fields, current_schema=current_form or {})
1668
+ relation_context = deepcopy(current_form or {})
1669
+ relation_context["questionRelations"] = relations
1670
+ internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
1671
+ self._validate_unique_relation_targets(relations, internal_fields)
1672
+ except (KeyError, ValueError) as error:
1673
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "CONTRACT_PRECHECK") from error
1674
+ self._assign_subfield_temp_ids(internal_fields)
1675
+ for field in fields:
1676
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
1677
+ if _field_type(field) == "relation":
1678
+ template = templates.get(_positive_int(field.get("queId")) or -1, {}).get("referenceConfig")
1679
+ self._reference_config(profile, config, template if isinstance(template, dict) else None)
1680
+ elif _field_type(field) == "q_linker":
1681
+ for index, item in enumerate((config.get("qLinkerBinding") or {}).get("inputs") or []):
1682
+ if not isinstance(item, dict) or item.get("source") != "json_path":
1683
+ continue
1684
+ selected = _resolve_internal_selector(item.get("field") or {}, internal_fields, f"qLinkerBinding.inputs[{index}].field")
1685
+ if str(item.get("key") or "") != str(selected.get("name") or ""):
1686
+ raise AppFormFailure(
1687
+ "UNSUPPORTED_FIELD_CONFIG",
1688
+ "json_path input key must equal the selected field name for lossless readback",
1689
+ "CONTRACT_PRECHECK",
1690
+ details={"key": item.get("key"), "field": selected.get("name")},
1691
+ )
1692
+ elif _field_type(field) == "data_relation" and "customButton" in config:
1693
+ template = templates.get(_positive_int(field.get("queId")) or -1)
1694
+ self._data_relation_custom_button(config.get("customButton") or {}, template)
1695
+ self._data_relation_relations(profile, app_key, fields, internal_fields)
1696
+
1697
+ @staticmethod
1698
+ def _resolve_declared_field_names(
1699
+ declaration: JSONObject,
1700
+ current_form: JSONObject | None,
1701
+ ) -> JSONObject:
1702
+ """Fill names for ID-only updates before compilation consumes field names."""
1703
+ result = deepcopy(declaration)
1704
+ current_names = AppFormNormalizer._field_index(current_form or {})
1705
+
1706
+ def visit(field: Any, path: str) -> None:
1707
+ if not isinstance(field, dict):
1708
+ return
1709
+ if not str(field.get("name") or "").strip():
1710
+ que_id = _positive_int(field.get("queId"))
1711
+ name = current_names.get(que_id or 0)
1712
+ if name:
1713
+ field["name"] = name
1714
+ else:
1715
+ raise AppFormFailure(
1716
+ "INVALID_SPEC",
1717
+ "field name is required unless queId identifies an existing field",
1718
+ "CONTRACT_PRECHECK",
1719
+ details={"path": path, "queId": field.get("queId")},
1720
+ )
1721
+ for index, child in enumerate(field.get("subfields") or []):
1722
+ visit(child, f"{path}/subfields/{index}")
1723
+
1724
+ spec = result.get("spec") if isinstance(result.get("spec"), dict) else {}
1725
+ body = spec.get("body") or []
1726
+ for block_index, block in enumerate(body):
1727
+ if not isinstance(block, dict):
1728
+ continue
1729
+ rows = [block] if block.get("kind") == "row" else block.get("rows") or []
1730
+ for row_index, row in enumerate(rows):
1731
+ if not isinstance(row, dict):
1732
+ continue
1733
+ for field_index, field in enumerate(row.get("fields") or []):
1734
+ visit(field, f"/spec/body/{block_index}/rows/{row_index}/fields/{field_index}")
1735
+ return result
1736
+
1737
+ @staticmethod
1738
+ def _assign_layout_temp_ids(body: list[Any], internal_fields: list[JSONObject]) -> None:
1739
+ """Assign one temporary-ID sequence to fields and new section wrappers."""
1740
+ temp_id = -10000
1741
+ internal_index = 0
1742
+
1743
+ def assign_field(field: Any) -> None:
1744
+ nonlocal temp_id, internal_index
1745
+ if not isinstance(field, dict):
1746
+ return
1747
+ if internal_index >= len(internal_fields):
1748
+ raise AppFormFailure(
1749
+ "INVALID_SPEC",
1750
+ "form layout and internal field list are inconsistent",
1751
+ "COMPILE",
1752
+ )
1753
+ internal = internal_fields[internal_index]
1754
+ internal_index += 1
1755
+ if _positive_int(field.get("queId")) is None:
1756
+ internal["que_temp_id"] = temp_id
1757
+ temp_id -= 100
1758
+
1759
+ for block in body:
1760
+ if not isinstance(block, dict):
1761
+ continue
1762
+ if block.get("kind") == "row":
1763
+ for field in block.get("fields") or []:
1764
+ assign_field(field)
1765
+ continue
1766
+ for row in block.get("rows") or []:
1767
+ if not isinstance(row, dict):
1768
+ continue
1769
+ for field in row.get("fields") or []:
1770
+ assign_field(field)
1771
+ if _positive_int(block.get("sectionId")) is None:
1772
+ temp_id -= 100
1773
+
1774
+ if internal_index != len(internal_fields):
1775
+ raise AppFormFailure(
1776
+ "INVALID_SPEC",
1777
+ "form layout and internal field list are inconsistent",
1778
+ "COMPILE",
1779
+ )
1780
+
1781
+ @staticmethod
1782
+ def _validate_unique_temp_ids(form_ques: list[list[JSONObject]]) -> None:
1783
+ seen: dict[int, str] = {}
1784
+
1785
+ def visit(question: Any) -> None:
1786
+ if not isinstance(question, dict):
1787
+ return
1788
+ temp_id = question.get("queTempId")
1789
+ if temp_id is not None:
1790
+ try:
1791
+ normalized = int(temp_id)
1792
+ except (TypeError, ValueError):
1793
+ normalized = None
1794
+ if normalized is not None:
1795
+ title = str(question.get("queTitle") or "")
1796
+ previous = seen.get(normalized)
1797
+ if previous is not None:
1798
+ raise AppFormFailure(
1799
+ "DUPLICATE_TEMP_ID",
1800
+ "form payload contains duplicate queTempId",
1801
+ "COMPILE",
1802
+ details={"queTempId": normalized, "questions": [previous, title]},
1803
+ )
1804
+ seen[normalized] = title
1805
+ for subquestion in question.get("subQuestions") or []:
1806
+ visit(subquestion)
1807
+ for inner_row in question.get("innerQuestions") or []:
1808
+ for inner_question in inner_row if isinstance(inner_row, list) else []:
1809
+ visit(inner_question)
1810
+
1811
+ for row in form_ques:
1812
+ for question in row if isinstance(row, list) else []:
1813
+ visit(question)
1814
+
1815
+ @staticmethod
1816
+ def _validate_unique_relation_targets(relations: list[JSONObject], fields: list[JSONObject]) -> None:
1817
+ field_names: dict[int, str] = {}
1818
+ for field in fields:
1819
+ field_ref = _positive_int(field.get("que_id"))
1820
+ if field_ref is None:
1821
+ field_ref = _coerce_any_int(field.get("que_temp_id"))
1822
+ if field_ref is not None:
1823
+ field_names[field_ref] = str(field.get("name") or field_ref)
1824
+ by_target: dict[int, JSONObject] = {}
1825
+ for relation in relations:
1826
+ target_ref = _coerce_any_int(relation.get("queId"))
1827
+ if target_ref is None:
1828
+ continue
1829
+ previous = by_target.get(target_ref)
1830
+ if previous is None:
1831
+ by_target[target_ref] = relation
1832
+ continue
1833
+ target_name = field_names.get(target_ref, str(target_ref))
1834
+ producers = []
1835
+ for item in (previous, relation):
1836
+ alias_config = item.get("aliasConfig") if isinstance(item.get("aliasConfig"), dict) else {}
1837
+ producer = str(alias_config.get("queTitle") or "").strip()
1838
+ producers.append(producer or f"relationType={item.get('relationType')}")
1839
+ raise ValueError(
1840
+ f"field '{target_name}' is targeted by multiple default-value relations "
1841
+ f"({producers[0]}, {producers[1]}); each field can receive output from only one relation"
1842
+ )
1843
+
1844
+ def _target_body(
1845
+ self,
1846
+ current_body: list[Any],
1847
+ declared_body: list[Any],
1848
+ *,
1849
+ subfields: bool = False,
1850
+ ) -> list[JSONObject]:
1851
+ current_fields = list(_iter_fields(current_body))
1852
+ by_id = {_positive_int(field.get("queId")): field for field in current_fields if _positive_int(field.get("queId"))}
1853
+ sections_by_id = {
1854
+ str(block.get("sectionId")): block
1855
+ for block in current_body
1856
+ if isinstance(block, dict) and block.get("kind") == "section" and block.get("sectionId")
1857
+ }
1858
+ current_sections = [
1859
+ block for block in current_body if isinstance(block, dict) and block.get("kind") == "section"
1860
+ ]
1861
+ claimed_fields: set[int] = set()
1862
+ claimed_sections: set[int] = set()
1863
+
1864
+ def merge_field(current: JSONObject | None, declared: JSONObject) -> JSONObject:
1865
+ target = _deep_overlay(current or {}, declared)
1866
+ if "config" in declared:
1867
+ target["config"] = deepcopy(declared["config"])
1868
+ target.setdefault("beingRequired", False)
1869
+ target.setdefault("questionDescription", None)
1870
+ if target.get("defaultValue") is not None:
1871
+ target.setdefault("defaultType", 1)
1872
+ if "defaultValue" in declared and declared.get("defaultValue") is None:
1873
+ target.pop("defaultType", None)
1874
+ target.pop("defaultValue", None)
1875
+ target["_clear_default_value"] = True
1876
+ if current is not None:
1877
+ target["queId"] = current.get("queId")
1878
+ else:
1879
+ target.pop("queId", None)
1880
+ if _field_type(target) in CHOICE_TYPES and "config" not in declared and current is None:
1881
+ target["config"] = {"options": ["是", "否"] if _field_type(target) == "single_choice" else []}
1882
+ return target
1883
+
1884
+ def target_field(declared: JSONObject, *, nested: bool) -> JSONObject:
1885
+ que_id = _positive_int(declared.get("queId"))
1886
+ current = by_id.get(que_id) if que_id is not None else None
1887
+ if current is not None and id(current) in claimed_fields:
1888
+ current = None
1889
+ if current is None:
1890
+ comparable = deepcopy(declared)
1891
+ comparable.pop("queId", None)
1892
+ candidates = []
1893
+ for candidate in current_fields:
1894
+ if id(candidate) in claimed_fields or _field_type(candidate) != _field_type(declared):
1895
+ continue
1896
+ merged = merge_field(candidate, comparable)
1897
+ if _field_semantic(merged) == _field_semantic(candidate):
1898
+ candidates.append(candidate)
1899
+ if len(candidates) > 1:
1900
+ raise AppFormFailure(
1901
+ "AMBIGUOUS_FIELD_MATCH",
1902
+ "field declaration matches multiple existing fields",
1903
+ "CONTRACT_PRECHECK",
1904
+ details={"name": declared.get("name"), "queIds": [item.get("queId") for item in candidates]},
1905
+ )
1906
+ current = candidates[0] if candidates else None
1907
+ if current is not None and _field_type(current) != _field_type(declared):
1908
+ raise AppFormFailure("FIELD_TYPE_IMMUTABLE", "field type cannot be changed in place", "CONTRACT_PRECHECK", details={"queId": current.get("queId"), "currentType": _field_type(current), "targetType": _field_type(declared)})
1909
+ if current is not None:
1910
+ claimed_fields.add(id(current))
1911
+ target = merge_field(current, declared)
1912
+ if _field_type(target) == "subtable":
1913
+ current_subfields = current.get("subfields") if isinstance((current or {}).get("subfields"), list) else []
1914
+ target["subfields"] = self._target_body(
1915
+ [{"kind": "row", "fields": current_subfields}],
1916
+ [{"kind": "row", "fields": target.get("subfields") or []}],
1917
+ subfields=True,
1918
+ )[0]["fields"]
1919
+ for item in target["subfields"]:
1920
+ item.pop("width", None)
1921
+ if nested:
1922
+ target.pop("width", None)
1923
+ return target
1924
+
1925
+ result = []
1926
+ for block in declared_body:
1927
+ target_block = deepcopy(block)
1928
+ if block.get("kind") == "row":
1929
+ target_block["fields"] = [target_field(field, nested=subfields) for field in block.get("fields") or []]
1930
+ if not subfields:
1931
+ self._fill_widths(target_block["fields"])
1932
+ else:
1933
+ target_block["rows"] = []
1934
+ for row in block.get("rows") or []:
1935
+ fields = [target_field(field, nested=subfields) for field in row.get("fields") or []]
1936
+ if not subfields:
1937
+ self._fill_widths(fields)
1938
+ target_block["rows"].append({"fields": fields})
1939
+ section_id = str(block.get("sectionId") or "")
1940
+ current_section = sections_by_id.get(section_id) if section_id else None
1941
+ if current_section is not None and id(current_section) in claimed_sections:
1942
+ current_section = None
1943
+ if current_section is None:
1944
+ comparable = deepcopy(target_block)
1945
+ comparable.pop("sectionId", None)
1946
+ candidates = [
1947
+ candidate
1948
+ for candidate in current_sections
1949
+ if id(candidate) not in claimed_sections
1950
+ and _semantic({key: value for key, value in candidate.items() if key != "sectionId"})
1951
+ == _semantic(comparable)
1952
+ ]
1953
+ if len(candidates) > 1:
1954
+ raise AppFormFailure(
1955
+ "AMBIGUOUS_LAYOUT_SECTION",
1956
+ "section declaration matches multiple existing sections",
1957
+ "CONTRACT_PRECHECK",
1958
+ details={"title": block.get("title"), "sectionIds": [item.get("sectionId") for item in candidates]},
1959
+ )
1960
+ current_section = candidates[0] if candidates else None
1961
+ if current_section is not None:
1962
+ claimed_sections.add(id(current_section))
1963
+ target_block["sectionId"] = current_section["sectionId"]
1964
+ else:
1965
+ target_block.pop("sectionId", None)
1966
+ result.append(target_block)
1967
+ return result
1968
+
1969
+ @staticmethod
1970
+ def _fill_widths(fields: list[JSONObject]) -> None:
1971
+ if not fields:
1972
+ raise AppFormFailure("INVALID_SPEC", "a row must contain at least one field", "CONTRACT_PRECHECK")
1973
+ present = ["width" in field for field in fields]
1974
+ if any(present) and not all(present):
1975
+ raise AppFormFailure("INVALID_ROW_WIDTH", "all fields in a row must provide width together", "CONTRACT_PRECHECK")
1976
+ if all(present):
1977
+ if any(_positive_int(field.get("width")) not in {25, 50, 75, 100} for field in fields):
1978
+ raise AppFormFailure("INVALID_ROW_WIDTH", "field widths must be one of 25, 50, 75, or 100", "CONTRACT_PRECHECK")
1979
+ if sum(int(field["width"]) for field in fields) != 100:
1980
+ raise AppFormFailure("INVALID_ROW_WIDTH", "field widths in a row must total 100", "CONTRACT_PRECHECK")
1981
+ return
1982
+ widths = {
1983
+ 1: [100],
1984
+ 2: [50, 50],
1985
+ 3: [50, 25, 25],
1986
+ 4: [25, 25, 25, 25],
1987
+ }[len(fields)]
1988
+ for field, width in zip(fields, widths, strict=True):
1989
+ field["width"] = width
1990
+
1991
+ @staticmethod
1992
+ def _validate_target(
1993
+ spec: JSONObject,
1994
+ *,
1995
+ allow_missing_data_title: bool = False,
1996
+ ) -> tuple[JSONObject | None, JSONObject | None]:
1997
+ body = spec.get("body") or []
1998
+ fields = list(_iter_fields(body))
1999
+ names: set[str] = set()
2000
+ for field in fields:
2001
+ name = str(field.get("name") or "")
2002
+ if name in names:
2003
+ raise AppFormFailure("DUPLICATE_FIELD_NAME", f"duplicate top-level field name: {name}", "CONTRACT_PRECHECK")
2004
+ names.add(name)
2005
+ if _field_type(field) == "subtable":
2006
+ sub_names: set[str] = set()
2007
+ for subfield in field.get("subfields") or []:
2008
+ if _field_type(subfield) not in SUBFIELD_TYPES:
2009
+ raise AppFormFailure("INVALID_SPEC", "unsupported subtable field type", "CONTRACT_PRECHECK", details={"allowedTypes": sorted(SUBFIELD_TYPES)})
2010
+ if subfield.get("name") in sub_names:
2011
+ raise AppFormFailure("DUPLICATE_FIELD_NAME", f"duplicate subtable field name: {subfield.get('name')}", "CONTRACT_PRECHECK")
2012
+ sub_names.add(str(subfield.get("name") or ""))
2013
+ title_selector = spec.get("dataTitleField")
2014
+ data_title: JSONObject | None = None
2015
+ if fields:
2016
+ if title_selector is None and allow_missing_data_title and "dataTitleField" not in spec:
2017
+ pass
2018
+ elif not isinstance(title_selector, dict):
2019
+ raise AppFormFailure(
2020
+ "INVALID_DATA_TITLE_FIELD",
2021
+ "non-empty body requires a dataTitleField selector",
2022
+ "CONTRACT_PRECHECK",
2023
+ )
2024
+ else:
2025
+ data_title = _resolve_canonical_selector(title_selector, fields, "dataTitleField")
2026
+ elif title_selector is not None:
2027
+ raise AppFormFailure(
2028
+ "INVALID_DATA_TITLE_FIELD",
2029
+ "dataTitleField must be null when body is empty",
2030
+ "CONTRACT_PRECHECK",
2031
+ )
2032
+
2033
+ cover_selector = spec.get("dataCoverField")
2034
+ data_cover: JSONObject | None = None
2035
+ if cover_selector is not None:
2036
+ if not isinstance(cover_selector, dict):
2037
+ raise AppFormFailure("INVALID_DATA_COVER_FIELD", "dataCoverField must be a field selector or null", "CONTRACT_PRECHECK")
2038
+ data_cover = _resolve_canonical_selector(cover_selector, fields, "dataCoverField")
2039
+ if _field_type(data_cover) != "attachment":
2040
+ raise AppFormFailure(
2041
+ "INVALID_DATA_COVER_FIELD",
2042
+ "dataCoverField must select a top-level attachment field",
2043
+ "CONTRACT_PRECHECK",
2044
+ )
2045
+ return data_title, data_cover
2046
+
2047
+ def payload(
2048
+ self,
2049
+ *,
2050
+ profile: str,
2051
+ app_key: str,
2052
+ current_form: JSONObject,
2053
+ target: JSONObject,
2054
+ edit_version_no: int,
2055
+ ) -> JSONObject:
2056
+ target_fields = list(_iter_fields(target["spec"]["body"]))
2057
+ templates = self._question_templates(current_form)
2058
+ internal_fields = [self._internal_field(field, templates=templates) for field in target_fields]
2059
+ self._assign_layout_temp_ids(target["spec"]["body"], internal_fields)
2060
+ try:
2061
+ internal_fields, _ = _normalize_and_validate_code_block_fields_for_write(fields=internal_fields)
2062
+ internal_fields, relations = _compile_code_block_binding_fields(fields=internal_fields, current_schema=current_form)
2063
+ relation_context = deepcopy(current_form)
2064
+ relation_context["questionRelations"] = relations
2065
+ internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
2066
+ self._validate_unique_relation_targets(relations, internal_fields)
2067
+ except (KeyError, ValueError) as error:
2068
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "COMPILE") from error
2069
+ self._assign_subfield_temp_ids(internal_fields)
2070
+ data_relations = self._data_relation_relations(profile, app_key, target_fields, internal_fields)
2071
+ internal_by_key = {(field.get("que_id"), field.get("name")): field for field in internal_fields}
2072
+ payload = self._save_base(current_form)
2073
+ self._apply_form_settings(payload, current_form=current_form, target_spec=target["spec"], internal_fields=internal_fields)
2074
+ payload["editVersionNo"] = edit_version_no
2075
+ payload["questionRelations"] = [
2076
+ relation
2077
+ for relation in relations
2078
+ if _positive_int(relation.get("relationType")) != 1
2079
+ ]
2080
+ payload["selectScopeRelations"] = [
2081
+ self._select_scope_relation(profile, field, internal)
2082
+ for field, internal in _iter_field_pairs(target_fields, internal_fields)
2083
+ if isinstance(field.get("config"), dict)
2084
+ and (field.get("config", {}).get("memberScopeRelation") or field.get("config", {}).get("departmentScopeRelation"))
2085
+ ]
2086
+ rows: list[list[JSONObject]] = []
2087
+ temp_id = -10000
2088
+ for block in target["spec"]["body"]:
2089
+ if block["kind"] == "row":
2090
+ row = []
2091
+ for field in block["fields"]:
2092
+ internal = internal_by_key.get((_positive_int(field.get("queId")), field.get("name")))
2093
+ question = self._question(profile, app_key, field, internal or {}, temp_id, relations)
2094
+ temp_id -= 100
2095
+ row.append(question)
2096
+ rows.append(row)
2097
+ else:
2098
+ inner_rows = []
2099
+ for target_row in block["rows"]:
2100
+ inner = []
2101
+ for field in target_row["fields"]:
2102
+ internal = internal_by_key.get((_positive_int(field.get("queId")), field.get("name")))
2103
+ inner.append(self._question(profile, app_key, field, internal or {}, temp_id, relations))
2104
+ temp_id -= 100
2105
+ inner_rows.append(inner)
2106
+ section_id = _positive_int(block.get("sectionId"))
2107
+ section = {
2108
+ "queId": section_id or 0,
2109
+ "queType": 24,
2110
+ "queTitle": block["title"],
2111
+ "queWidth": 100,
2112
+ "required": False,
2113
+ "queHint": "",
2114
+ "queDefaultType": 1,
2115
+ "innerQuestions": inner_rows,
2116
+ }
2117
+ if section_id is None:
2118
+ section["queTempId"] = temp_id
2119
+ temp_id -= 100
2120
+ rows.append([section])
2121
+ payload["formQues"] = rows
2122
+ self._validate_unique_temp_ids(rows)
2123
+ data_title = _resolve_canonical_selector(target["spec"]["dataTitleField"], target_fields, "dataTitleField") if target_fields else None
2124
+ data_cover_selector = target["spec"].get("dataCoverField")
2125
+ data_cover = _resolve_canonical_selector(data_cover_selector, target_fields, "dataCoverField") if isinstance(data_cover_selector, dict) else None
2126
+ payload["dataTitleQueId"] = self._field_runtime_id(data_title, internal_fields)
2127
+ payload["dataCoverQueId"] = self._field_runtime_id(data_cover, internal_fields) if data_cover else None
2128
+ payload["questionRelations"].extend(data_relations)
2129
+ _normalize_formula_defaults_for_save(payload.get("formQues"))
2130
+ return payload
2131
+
2132
+ @staticmethod
2133
+ def needs_identity_write(target: JSONObject) -> bool:
2134
+ fields = list(_iter_field_tree(_iter_fields(target["spec"]["body"])))
2135
+ missing_names = {str(field.get("name")) for field in fields if _positive_int(field.get("queId")) is None}
2136
+ for field in fields:
2137
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
2138
+ binding = config.get("qLinkerBinding") or config.get("codeBlockBinding")
2139
+ if _positive_int(field.get("queId")) is None and (
2140
+ _field_type(field) in {"q_linker", "data_relation"}
2141
+ or _field_type(field) == "code_block" and bool((binding or {}).get("outputs"))
2142
+ ):
2143
+ return True
2144
+ if isinstance(binding, dict):
2145
+ selectors = [item.get("field") for item in binding.get("inputs") or [] if isinstance(item, dict)]
2146
+ selectors += [item.get("targetField") for item in binding.get("outputs") or [] if isinstance(item, dict)]
2147
+ if any(isinstance(selector, dict) and not _positive_int(selector.get("queId")) and selector.get("name") in missing_names for selector in selectors):
2148
+ return True
2149
+ if _field_type(field) == "data_relation" and json.dumps(config, ensure_ascii=False, sort_keys=True):
2150
+ for selector in _walk_selectors(config):
2151
+ if not _positive_int(selector.get("queId")) and selector.get("name") in missing_names:
2152
+ return True
2153
+ return False
2154
+
2155
+ @staticmethod
2156
+ def pending_bindings(target: JSONObject) -> list[JSONObject]:
2157
+ fields = list(_iter_field_tree(_iter_fields(target["spec"]["body"])))
2158
+ missing_names = {str(field.get("name")) for field in fields if _positive_int(field.get("queId")) is None}
2159
+ pending: list[JSONObject] = []
2160
+ for field in fields:
2161
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
2162
+ binding = config.get("qLinkerBinding") or config.get("codeBlockBinding")
2163
+ if _positive_int(field.get("queId")) is None and (
2164
+ _field_type(field) in {"q_linker", "data_relation"}
2165
+ or _field_type(field) == "code_block" and bool((binding or {}).get("outputs"))
2166
+ ):
2167
+ pending.append(
2168
+ {
2169
+ "field": field.get("name"),
2170
+ "path": "identity",
2171
+ "selector": {"name": field.get("name")},
2172
+ }
2173
+ )
2174
+ if isinstance(binding, dict):
2175
+ for index, item in enumerate(binding.get("inputs") or []):
2176
+ selector = item.get("field") if isinstance(item, dict) else None
2177
+ if isinstance(selector, dict) and not _positive_int(selector.get("queId")) and selector.get("name") in missing_names:
2178
+ pending.append({"field": field.get("name"), "path": f"inputs/{index}", "selector": deepcopy(selector)})
2179
+ for index, item in enumerate(binding.get("outputs") or []):
2180
+ selector = item.get("targetField") if isinstance(item, dict) else None
2181
+ if isinstance(selector, dict) and not _positive_int(selector.get("queId")) and selector.get("name") in missing_names:
2182
+ pending.append({"field": field.get("name"), "path": f"outputs/{index}", "selector": deepcopy(selector)})
2183
+ if _field_type(field) == "data_relation":
2184
+ for selector in _walk_selectors(config):
2185
+ if not _positive_int(selector.get("queId")) and selector.get("name") in missing_names:
2186
+ pending.append({"field": field.get("name"), "path": "config", "selector": deepcopy(selector)})
2187
+ return pending
2188
+
2189
+ def identity_target(self, *, current: JSONObject, target: JSONObject) -> JSONObject:
2190
+ """Keep pending deletions in the first relation-binding write."""
2191
+ identity = deepcopy(target)
2192
+ target_ids = {
2193
+ _positive_int(field.get("queId"))
2194
+ for field in _iter_fields(identity["spec"]["body"])
2195
+ if _positive_int(field.get("queId")) is not None
2196
+ }
2197
+ target_sections = {
2198
+ str(block.get("sectionId")): block
2199
+ for block in identity["spec"]["body"]
2200
+ if isinstance(block, dict) and block.get("kind") == "section" and block.get("sectionId")
2201
+ }
2202
+
2203
+ def retained_fields(fields: list[Any]) -> list[JSONObject]:
2204
+ retained = [
2205
+ deepcopy(field)
2206
+ for field in fields
2207
+ if isinstance(field, dict) and _positive_int(field.get("queId")) not in target_ids
2208
+ ]
2209
+ for field in retained:
2210
+ field.pop("width", None)
2211
+ if retained:
2212
+ self._fill_widths(retained)
2213
+ return retained
2214
+
2215
+ for block in current["spec"]["body"]:
2216
+ if not isinstance(block, dict):
2217
+ continue
2218
+ if block.get("kind") == "row":
2219
+ fields = retained_fields(block.get("fields") or [])
2220
+ if fields:
2221
+ identity["spec"]["body"].append({"kind": "row", "fields": fields})
2222
+ continue
2223
+ retained_rows = []
2224
+ for row in block.get("rows") or []:
2225
+ if not isinstance(row, dict):
2226
+ continue
2227
+ fields = retained_fields(row.get("fields") or [])
2228
+ if fields:
2229
+ retained_rows.append({"fields": fields})
2230
+ if not retained_rows:
2231
+ continue
2232
+ section_id = str(block.get("sectionId") or "")
2233
+ target_section = target_sections.get(section_id)
2234
+ if target_section is not None:
2235
+ target_section["rows"].extend(retained_rows)
2236
+ else:
2237
+ retained_section = deepcopy(block)
2238
+ retained_section["rows"] = retained_rows
2239
+ identity["spec"]["body"].append(retained_section)
2240
+ return identity
2241
+
2242
+ @staticmethod
2243
+ def _question_templates(form: JSONObject) -> dict[int, JSONObject]:
2244
+ result = {}
2245
+
2246
+ def add(question: JSONObject) -> None:
2247
+ que_id = _positive_int(question.get("queId"))
2248
+ if que_id is not None:
2249
+ result[que_id] = question
2250
+ for subquestion in question.get("subQuestions") or []:
2251
+ if isinstance(subquestion, dict):
2252
+ add(subquestion)
2253
+
2254
+ for row in form.get("formQues") or []:
2255
+ for question in row if isinstance(row, list) else []:
2256
+ if not isinstance(question, dict):
2257
+ continue
2258
+ if _positive_int(question.get("queType")) == 24:
2259
+ for inner in question.get("innerQuestions") or []:
2260
+ for item in inner if isinstance(inner, list) else []:
2261
+ if isinstance(item, dict):
2262
+ add(item)
2263
+ else:
2264
+ add(question)
2265
+ return result
2266
+
2267
+ @staticmethod
2268
+ def _assign_subfield_temp_ids(fields: list[JSONObject]) -> None:
2269
+ temp_id = -1_000_000
2270
+ for field in fields:
2271
+ for subfield in field.get("subfields") or []:
2272
+ if _positive_int(subfield.get("que_id")) is None:
2273
+ subfield["que_temp_id"] = temp_id
2274
+ temp_id -= 1
2275
+
2276
+ def _internal_field(self, field: JSONObject, *, templates: dict[int, JSONObject]) -> JSONObject:
2277
+ canonical_type = _field_type(field)
2278
+ internal_type = "boolean" if canonical_type == "single_choice" else canonical_type
2279
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
2280
+ result: JSONObject = {
2281
+ "name": field["name"],
2282
+ "type": internal_type,
2283
+ "required": _field_required(field),
2284
+ "description": field.get("questionDescription"),
2285
+ "que_id": _positive_int(field.get("queId")),
2286
+ "options": deepcopy(config.get("options") or []),
2287
+ "subfields": [self._internal_field(item, templates=templates) for item in field.get("subfields") or []],
2288
+ "_app_form_type": canonical_type,
2289
+ }
2290
+ if "defaultValues" in config:
2291
+ result["default_values"] = deepcopy(config.get("defaultValues"))
2292
+ if "defaultType" in field:
2293
+ result["default_type"] = _positive_int(field.get("defaultType")) or 1
2294
+ if "defaultValue" in field:
2295
+ result["default_value"] = deepcopy(field.get("defaultValue"))
2296
+ if field.get("_clear_default_value") is True:
2297
+ result["default_type"] = 1
2298
+ result["default_value"] = None
2299
+ template = templates.get(result["que_id"] or -1)
2300
+ if template is not None:
2301
+ result["_question_template"] = deepcopy(template)
2302
+ if canonical_type == "department":
2303
+ scope = config.get("departmentScope") or {"mode": "all"}
2304
+ result["department_scope"] = {
2305
+ "mode": scope.get("mode"),
2306
+ "departments": [
2307
+ {"dept_id": item.get("deptId"), "dept_name": item.get("deptName")}
2308
+ for item in scope.get("departments") or []
2309
+ ],
2310
+ "include_sub_departs": bool(scope.get("includeSubDeparts")),
2311
+ }
2312
+ default_departments = config.get("defaultDepartments")
2313
+ if isinstance(default_departments, dict):
2314
+ result["department_default_scope"] = {
2315
+ "departments": [
2316
+ {"dept_id": item.get("deptId"), "dept_name": item.get("deptName")}
2317
+ for item in default_departments.get("departments") or []
2318
+ if isinstance(item, dict)
2319
+ ],
2320
+ "include_sub_departs": bool(default_departments.get("includeSubDeparts")),
2321
+ }
2322
+ elif canonical_type == "member":
2323
+ result["member_scope"] = deepcopy(config.get("memberScope"))
2324
+ result["member_default_scope"] = deepcopy(config.get("defaultMembers"))
2325
+ elif canonical_type == "relation":
2326
+ result.update(
2327
+ {
2328
+ "target_app_key": config.get("targetAppKey"),
2329
+ "relation_mode": config.get("relationMode") or "single",
2330
+ "display_field": _snake_selector(config.get("displayField")),
2331
+ "visible_fields": [_snake_selector(item) for item in config.get("visibleFields") or []],
2332
+ "reference_default_formula_auto_fill": config.get("beingDefaultFormulaAutoFillEnabled"),
2333
+ "reference_default_value_match_rules": deepcopy(config.get("defaultValueMatchRules")),
2334
+ "_relation_config_explicit": True,
2335
+ }
2336
+ )
2337
+ elif canonical_type == "q_linker":
2338
+ result["q_linker_binding"] = _snake_q_linker(config.get("qLinkerBinding") or {})
2339
+ if template is not None and isinstance(template.get("remoteLookupConfig"), dict):
2340
+ result["remote_lookup_config"] = deepcopy(template["remoteLookupConfig"])
2341
+ elif canonical_type == "code_block":
2342
+ result["code_block_binding"] = _snake_code_block(config.get("codeBlockBinding") or {})
2343
+ return result
2344
+
2345
+ def _question(self, profile: str, app_key: str, field: JSONObject, internal: JSONObject, temp_id: int, relations: list[JSONObject]) -> JSONObject:
2346
+ field_type = _field_type(field)
2347
+ que_type = CANONICAL_TO_QUE_TYPE[field_type]
2348
+ build_type = "boolean" if field_type == "single_choice" else "text" if field_type in {"link", "data_relation"} else field_type
2349
+ built_internal = deepcopy(internal)
2350
+ built_internal["type"] = build_type
2351
+ if build_type == "subtable":
2352
+ built_internal["subfields"] = []
2353
+ try:
2354
+ generated = _field_to_question(built_internal, temp_id=temp_id)
2355
+ except (KeyError, ValueError):
2356
+ generated, _ = build_question(
2357
+ {"label": field["name"], "type": FieldType.text, "required": _field_required(field), "description": field.get("questionDescription"), "config": {}},
2358
+ temp_id,
2359
+ )
2360
+ template = internal.get("_question_template") if isinstance(internal.get("_question_template"), dict) else None
2361
+ question = deepcopy(template) if template is not None else generated
2362
+ for key, value in generated.items():
2363
+ if key in {"queId", "queTempId", "queType", "queTitle", "queWidth", "required", "queHint", "options", "deptSelectScope", "deptSelectScopeType", "referenceConfig", "remoteLookupConfig", "codeBlockConfig", "autoTrigger", "customBtnTextStatus", "customBtnText", "queDefaultType"}:
2364
+ question[key] = deepcopy(value)
2365
+ que_id = _positive_int(field.get("queId"))
2366
+ question["queId"] = que_id or 0
2367
+ if que_id is None:
2368
+ question["queTempId"] = internal.get("que_temp_id") or temp_id
2369
+ else:
2370
+ question.pop("queTempId", None)
2371
+ question.update(
2372
+ {
2373
+ "queType": que_type,
2374
+ "queTitle": field["name"],
2375
+ "queWidth": int(field.get("width") or 100),
2376
+ "required": _field_required(field),
2377
+ "queHint": field.get("questionDescription") or "",
2378
+ }
2379
+ )
2380
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
2381
+ if field_type in {"text", "long_text", "link", "phone", "email"}:
2382
+ for key in ("minWordCount", "maxWordCount", "noRepeat", "canScan", "scanType"):
2383
+ if key in config:
2384
+ question[key] = _backend_unlimited(config[key]) if key in {"minWordCount", "maxWordCount"} else config[key]
2385
+ if field_type == "phone":
2386
+ for key in ("msgValidateStatus", "fixedPhone"):
2387
+ if key in config:
2388
+ question[key] = config[key]
2389
+ elif field_type in {"number", "amount"}:
2390
+ for key in ("numberLimitStatus", "minValue", "maxValue", "decimalDigit", "numberFormat", "currencyStatus", "currencyType", "canDecimal", "noRepeat", "canScan", "scanType"):
2391
+ if key in config:
2392
+ question[key] = config[key]
2393
+ if field_type == "amount" and "canDecimal" not in question:
2394
+ question["canDecimal"] = True
2395
+ elif field_type in CHOICE_TYPES:
2396
+ for key in ("minOpts", "maxOpts", "optDirection", "optSelectMode", "beingShowColor"):
2397
+ if key in config:
2398
+ question[key] = _backend_unlimited(config[key]) if key in {"minOpts", "maxOpts"} else config[key]
2399
+ elif field_type == "member":
2400
+ scope = config.get("memberScope")
2401
+ default_scope = config.get("defaultMembers")
2402
+ if isinstance(scope, dict):
2403
+ question["memberSelectScopeType"] = {"all": 1, "custom": 2, "relation": 3}.get(scope.get("mode"), 1)
2404
+ question["memberSelectScope"] = _backend_member_scope(scope)
2405
+ if isinstance(default_scope, dict):
2406
+ question["memberDftValue"] = _backend_member_scope(default_scope)
2407
+ for key in ("noRepeat", "canScan", "scanType"):
2408
+ if key in config:
2409
+ question[key] = config[key]
2410
+ elif field_type == "department":
2411
+ scope = config.get("departmentScope") if isinstance(config.get("departmentScope"), dict) else None
2412
+ if scope is not None:
2413
+ question["deptSelectScopeType"] = {"all": 1, "custom": 2, "relation": 3}.get(scope.get("mode"), 1)
2414
+ question["deptSelectScope"] = {
2415
+ "depart": [
2416
+ _without_none({"deptId": item.get("deptId"), "deptName": item.get("deptName")})
2417
+ for item in scope.get("departments") or []
2418
+ if isinstance(item, dict)
2419
+ ],
2420
+ "includeSubDeparts": bool(scope.get("includeSubDeparts")),
2421
+ }
2422
+ default_scope = config.get("defaultDepartments")
2423
+ if isinstance(default_scope, dict):
2424
+ question["deptDftValue"] = {
2425
+ "depart": [
2426
+ _without_none({"deptId": item.get("deptId"), "deptName": item.get("deptName")})
2427
+ for item in default_scope.get("departments") or []
2428
+ if isinstance(item, dict)
2429
+ ],
2430
+ "includeSubDeparts": bool(default_scope.get("includeSubDeparts")),
2431
+ }
2432
+ for key in ("noRepeat", "canScan", "scanType", "beingShowParentPath"):
2433
+ if key in config:
2434
+ question[key] = config[key]
2435
+ elif field_type == "attachment":
2436
+ for key in ("fileSize", "fileType", "fileBeingCaptureOnly", "imageCompress", "pluginStatus", "maxFileLimitStatus", "maxFileLimit", "canScan", "scanType"):
2437
+ if key in config:
2438
+ question[key] = deepcopy(config[key])
2439
+ elif field_type == "address":
2440
+ for key in ("addressPrecision", "pickPosition", "canScan", "scanType"):
2441
+ if key in config:
2442
+ question[key] = config[key]
2443
+ elif field_type == "subtable":
2444
+ for key in ("tableRowsLimit", "tableNoRepeatStatus", "tableRowHeight"):
2445
+ if key in config:
2446
+ question[key] = deepcopy(config[key])
2447
+ if isinstance(config.get("tableNoRepeatQuestion"), list):
2448
+ question["tableNoRepeatQuestion"] = [
2449
+ self._resolve_subfield_runtime_id(item, internal.get("subfields") or [], "tableNoRepeatQuestion")
2450
+ for item in config["tableNoRepeatQuestion"]
2451
+ ]
2452
+ if "defaultValues" in config:
2453
+ question["queDefaultValues"] = deepcopy(config.get("defaultValues"))
2454
+ if "defaultType" in field:
2455
+ question["queDefaultType"] = _positive_int(field.get("defaultType")) or 1
2456
+ if "defaultValue" in field:
2457
+ question["queDefaultValue"] = deepcopy(field.get("defaultValue"))
2458
+ if field.get("_clear_default_value") is True:
2459
+ question["queDefaultType"] = 1
2460
+ question["queDefaultValue"] = None
2461
+ if field_type == "date":
2462
+ question["dateType"] = 0
2463
+ elif field_type == "datetime":
2464
+ question["dateType"] = 1
2465
+ elif field_type in CHOICE_TYPES:
2466
+ old_options = {
2467
+ str(item.get("optValue")): item
2468
+ for item in (template or {}).get("options") or []
2469
+ if isinstance(item, dict) and item.get("optValue") is not None
2470
+ }
2471
+ options = build_options((field.get("config") or {}).get("options") or [], start_temp_id=temp_id - 1)
2472
+ for option in options:
2473
+ old = old_options.get(str(option.get("optValue")))
2474
+ if old is not None:
2475
+ option.update({key: deepcopy(value) for key, value in old.items() if key not in {"optValue"}})
2476
+ question["options"] = options
2477
+ question["unnamedOptions"] = [item.get("optValue") for item in options]
2478
+ default_options = (field.get("config") or {}).get("defaultOptions")
2479
+ if isinstance(default_options, list):
2480
+ for option in options:
2481
+ option["beingDefault"] = str(option.get("optValue")) in {str(item) for item in default_options}
2482
+ elif field_type == "subtable":
2483
+ question["subQuestions"] = [
2484
+ self._question(profile, app_key, subfield, sub_internal, temp_id - ((index + 1) * 10), relations)
2485
+ for index, (subfield, sub_internal) in enumerate(zip(field.get("subfields") or [], internal.get("subfields") or []))
2486
+ ]
2487
+ for subquestion in question["subQuestions"]:
2488
+ subquestion["queWidth"] = 100
2489
+ elif field_type == "relation":
2490
+ current_reference = (template or {}).get("referenceConfig")
2491
+ question["referenceConfig"] = self._reference_config(
2492
+ profile,
2493
+ field.get("config") or {},
2494
+ current_reference if isinstance(current_reference, dict) else None,
2495
+ )
2496
+ elif field_type == "data_relation":
2497
+ config = field.get("config") or {}
2498
+ related_que_id = _positive_int(internal.get("data_relation_related_que_id"))
2499
+ if related_que_id is None:
2500
+ question.pop("relatedQueId", None)
2501
+ else:
2502
+ question["relatedQueId"] = related_que_id
2503
+ que_origin_type = _positive_int(internal.get("data_relation_origin_type"))
2504
+ if que_origin_type is None:
2505
+ raise AppFormFailure(
2506
+ "UNSUPPORTED_FIELD_CONFIG",
2507
+ "data_relation source type could not be derived",
2508
+ "COMPILE",
2509
+ details={"field": field.get("name")},
2510
+ )
2511
+ question["queOriginType"] = que_origin_type
2512
+ aggregation = config.get("aggregation")
2513
+ if aggregation is None:
2514
+ question.pop("aggreType", None)
2515
+ else:
2516
+ question["aggreType"] = AGGREGATION_TO_BACKEND[aggregation]
2517
+ if "customButton" in config:
2518
+ question["customButtonConfig"] = self._data_relation_custom_button(
2519
+ config.get("customButton") or {},
2520
+ template,
2521
+ )
2522
+ return question
2523
+
2524
+ def _reference_config(self, profile: str, config: JSONObject, template: JSONObject | None = None) -> JSONObject:
2525
+ target_app_key = str(config.get("targetAppKey") or "")
2526
+ target_form = self._read_form(profile, target_app_key, being_draft=False)
2527
+ candidates = _backend_question_candidates(target_form)
2528
+ display = _resolve_backend_selector(config.get("displayField") or {}, candidates, "displayField")
2529
+ visible = [_resolve_backend_selector(item, candidates, "visibleFields") for item in config.get("visibleFields") or []]
2530
+ if all(_positive_int(item.get("queId")) != _positive_int(display.get("queId")) for item in visible):
2531
+ visible.insert(0, display)
2532
+ result = deepcopy(template or {})
2533
+ result.update(
2534
+ {
2535
+ "referAppKey": target_app_key,
2536
+ "referQueId": display["queId"],
2537
+ "optionalDataNum": 1 if config.get("relationMode", "single") == "single" else 2,
2538
+ "referQuestions": [
2539
+ {"queId": item["queId"], "queTitle": item["queTitle"], "queType": item["queType"], "queAuth": 3, "ordinal": index}
2540
+ for index, item in enumerate(visible)
2541
+ ],
2542
+ "referAuthQues": [{"queId": item["queId"], "queAuth": 1} for item in visible],
2543
+ "beingDefaultFormulaAutoFillEnabled": config.get("beingDefaultFormulaAutoFillEnabled"),
2544
+ "defaultValueFormula": config.get("defaultValueFormula"),
2545
+ "defaultValueMatchRules": deepcopy(config.get("defaultValueMatchRules")),
2546
+ }
2547
+ )
2548
+ if "defaultValues" in config:
2549
+ result["queDefaultValues"] = deepcopy(config.get("defaultValues"))
2550
+ return result
2551
+
2552
+ def _select_scope_relation(self, profile: str, field: JSONObject, internal: JSONObject) -> JSONObject:
2553
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
2554
+ relation = config.get("memberScopeRelation") or config.get("departmentScopeRelation")
2555
+ if not isinstance(relation, dict):
2556
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "select scope relation must be an object", "COMPILE")
2557
+ target_app_key = str(relation.get("targetAppKey") or "")
2558
+ if not target_app_key:
2559
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "select scope relation requires targetAppKey", "COMPILE")
2560
+ target_form = self._read_form(profile, target_app_key, being_draft=False)
2561
+ candidates = _backend_question_candidates(target_form)
2562
+ displayed = _resolve_backend_selector(relation.get("displayedField") or {}, candidates, "selectScopeRelation.displayedField")
2563
+ payload: JSONObject = {
2564
+ "queId": _positive_int(internal.get("que_id")) or int(internal.get("que_temp_id") or 0),
2565
+ "displayedQueId": _positive_int(displayed.get("queId")),
2566
+ "displayedQueInfo": {
2567
+ "appKey": target_app_key,
2568
+ "appTitle": relation.get("targetAppTitle") or target_form.get("formTitle") or "",
2569
+ "queId": _positive_int(displayed.get("queId")),
2570
+ "queTitle": displayed.get("queTitle") or "",
2571
+ "queType": _positive_int(displayed.get("queType")) or 2,
2572
+ },
2573
+ }
2574
+ for key in ("matchRuleType", "matchRules", "matchRuleFormula"):
2575
+ if key in relation:
2576
+ payload[key] = deepcopy(relation.get(key))
2577
+ return _without_none(payload)
2578
+
2579
+ @staticmethod
2580
+ def _resolve_subfield_runtime_id(selector: JSONObject, internal_fields: list[JSONObject], location: str) -> int:
2581
+ if not isinstance(selector, dict):
2582
+ raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must be an object", "COMPILE")
2583
+ que_id = _positive_int(selector.get("queId"))
2584
+ name = str(selector.get("name") or "")
2585
+ matches = [
2586
+ item for item in internal_fields
2587
+ if (que_id is None or _positive_int(item.get("que_id")) == que_id)
2588
+ and (not name or str(item.get("name") or "") == name)
2589
+ ]
2590
+ if len(matches) != 1:
2591
+ raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one subfield", "COMPILE", details={"selector": selector, "matches": len(matches)})
2592
+ return _positive_int(matches[0].get("que_id")) or int(matches[0].get("que_temp_id") or 0)
2593
+
2594
+ @staticmethod
2595
+ def _data_relation_custom_button(config: JSONObject, template: JSONObject | None) -> JSONObject:
2596
+ current = (template or {}).get("customButtonConfig")
2597
+ current = current if isinstance(current, dict) else {}
2598
+ detail = current.get("customButtonDetailInfo")
2599
+ detail = detail if isinstance(detail, dict) else {}
2600
+ if not bool(config.get("enabled")):
2601
+ return {"status": False, **({"customButtonDetailInfo": deepcopy(detail)} if detail else {})}
2602
+ declared_id = _positive_int(config.get("buttonId"))
2603
+ current_id = _positive_int(detail.get("buttonId")) or _positive_int(current.get("buttonId"))
2604
+ if declared_id is None or current_id != declared_id:
2605
+ raise AppFormFailure(
2606
+ "UNSUPPORTED_FIELD_CONFIG",
2607
+ "enabled data_relation custom button must reference the field's current buttonId",
2608
+ "CONTRACT_PRECHECK",
2609
+ details={"declaredButtonId": declared_id, "currentButtonId": current_id},
2610
+ )
2611
+ return {"status": True, "customButtonDetailInfo": deepcopy(detail)}
2612
+
2613
+ def _data_relation_relations(self, profile: str, app_key: str, fields: list[JSONObject], internal_fields: list[JSONObject]) -> list[JSONObject]:
2614
+ available_internal_fields = list(_iter_field_tree(internal_fields))
2615
+ result = []
2616
+ for field, internal in _iter_field_pairs(fields, internal_fields):
2617
+ if _field_type(field) != "data_relation":
2618
+ continue
2619
+ config = field.get("config") or {}
2620
+ source = config.get("source") or {}
2621
+ field_id = _positive_int(internal.get("que_id")) or int(internal.get("que_temp_id") or 0) or None
2622
+ relation: JSONObject = {
2623
+ "queId": field_id,
2624
+ "relationType": {"app": 1, "q_linker": 2, "code_block": 3}.get(source.get("kind"), 1),
2625
+ "matchRuleType": 1,
2626
+ "matchRules": [],
2627
+ "matchRuleFormula": None,
2628
+ "tableMatchRules": [],
2629
+ "sortConfig": None,
2630
+ }
2631
+ source_producer: JSONObject | None = None
2632
+ source_fields = available_internal_fields
2633
+ source_fields_are_backend = False
2634
+ if source.get("kind") == "app":
2635
+ target_form = self._read_form(profile, str(source.get("appKey") or ""), being_draft=False)
2636
+ source_fields = _backend_question_candidates(target_form)
2637
+ source_fields_are_backend = True
2638
+ target = _resolve_backend_selector(source.get("field") or {}, source_fields, "source.field")
2639
+ relation.update(
2640
+ {
2641
+ "displayedQueId": target["queId"],
2642
+ "displayedQueInfo": {
2643
+ "appKey": source.get("appKey"),
2644
+ "queId": target["queId"],
2645
+ "queTitle": target["queTitle"],
2646
+ "queType": target["queType"],
2647
+ },
2648
+ }
2649
+ )
2650
+ internal["data_relation_related_que_id"] = target["queId"]
2651
+ internal["data_relation_origin_type"] = target["queType"]
2652
+ else:
2653
+ producer = _resolve_internal_selector(source.get("producer") or {}, available_internal_fields, "source.producer")
2654
+ source_producer = producer
2655
+ alias = source.get("alias") or {}
2656
+ alias_origin_type, resolved_alias_id = self._data_relation_alias_metadata(
2657
+ producer=producer,
2658
+ alias=alias,
2659
+ internal_fields=available_internal_fields,
2660
+ )
2661
+ relation.update(
2662
+ {
2663
+ "displayedQueId": None,
2664
+ "displayedQueInfo": None,
2665
+ "qlinkerAlias": alias.get("name"),
2666
+ "aliasConfig": _without_none(
2667
+ {
2668
+ "queId": producer.get("que_id") or producer.get("que_temp_id"),
2669
+ "queTitle": producer.get("name"),
2670
+ "qlinkerAlias": alias.get("name"),
2671
+ "aliasId": resolved_alias_id,
2672
+ }
2673
+ ),
2674
+ }
2675
+ )
2676
+ internal.pop("data_relation_related_que_id", None)
2677
+ internal["data_relation_origin_type"] = alias_origin_type
2678
+ filters = config.get("filters")
2679
+ if isinstance(filters, dict) and filters.get("mode") == "advanced":
2680
+ relation["matchRuleFormula"] = filters.get("formula")
2681
+ elif isinstance(filters, dict):
2682
+ relation["matchRules"] = [
2683
+ [
2684
+ self._data_relation_condition(
2685
+ item,
2686
+ source_fields=source_fields,
2687
+ current_fields=available_internal_fields,
2688
+ source_fields_are_backend=source_fields_are_backend,
2689
+ )
2690
+ for item in group
2691
+ ]
2692
+ for group in filters.get("groups") or []
2693
+ ]
2694
+ sort = config.get("sort")
2695
+ if isinstance(sort, dict):
2696
+ if not sort.get("enabled"):
2697
+ relation["sortConfig"] = {"status": False}
2698
+ elif (sort.get("by") or {}).get("kind") == "field":
2699
+ sort_field = self._resolve_data_relation_source_field(
2700
+ (sort.get("by") or {}).get("field") or {},
2701
+ source_fields=source_fields,
2702
+ source_fields_are_backend=source_fields_are_backend,
2703
+ location="sort.by.field",
2704
+ )
2705
+ relation["sortConfig"] = {
2706
+ "status": True,
2707
+ "queId": self._data_relation_field_ref(sort_field),
2708
+ "queTitle": self._data_relation_field_name(sort_field),
2709
+ "ascend": sort.get("direction") == "asc",
2710
+ }
2711
+ else:
2712
+ by = sort.get("by") or {}
2713
+ producer = _resolve_internal_selector(by.get("producer") or {}, available_internal_fields, "sort.by.producer")
2714
+ alias = by.get("alias") or {}
2715
+ alias_name = str(alias.get("name") or "").strip()
2716
+ if not alias_name:
2717
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "alias data_relation sort requires an alias name", "CONTRACT_PRECHECK")
2718
+ relation["sortConfig"] = {
2719
+ "status": True,
2720
+ "queId": None,
2721
+ "qlinkerAlias": alias_name,
2722
+ "ascend": sort.get("direction") == "asc",
2723
+ }
2724
+ if producer.get("_app_form_type") not in {"q_linker", "code_block"}:
2725
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "alias sort producer must be Q-Linker or code block", "CONTRACT_PRECHECK")
2726
+ if source_producer is None or (
2727
+ producer.get("que_id") or producer.get("que_temp_id"),
2728
+ producer.get("name"),
2729
+ ) != (
2730
+ source_producer.get("que_id") or source_producer.get("que_temp_id"),
2731
+ source_producer.get("name"),
2732
+ ):
2733
+ raise AppFormFailure(
2734
+ "UNSUPPORTED_FIELD_CONFIG",
2735
+ "alias sort producer must match the data_relation source producer",
2736
+ "CONTRACT_PRECHECK",
2737
+ )
2738
+ result.append(relation)
2739
+ if config.get("aggregation") is not None:
2740
+ internal["aggre_type"] = AGGREGATION_TO_BACKEND[config["aggregation"]]
2741
+ return result
2742
+
2743
+ @classmethod
2744
+ def _data_relation_condition(
2745
+ cls,
2746
+ condition: JSONObject,
2747
+ *,
2748
+ source_fields: list[JSONObject],
2749
+ current_fields: list[JSONObject],
2750
+ source_fields_are_backend: bool,
2751
+ ) -> JSONObject:
2752
+ source = cls._resolve_data_relation_source_field(
2753
+ condition.get("sourceField") or {},
2754
+ source_fields=source_fields,
2755
+ source_fields_are_backend=source_fields_are_backend,
2756
+ location="sourceField",
2757
+ )
2758
+ operand = condition.get("operand") or {}
2759
+ operator = str(condition.get("operator") or "")
2760
+ cls._validate_data_relation_operator(source, operator, operand)
2761
+ payload: JSONObject = {
2762
+ "queId": cls._data_relation_field_ref(source),
2763
+ "queTitle": cls._data_relation_field_name(source),
2764
+ "queType": cls._data_relation_que_type(source),
2765
+ "judgeType": JUDGE_TYPE_TO_BACKEND[operator],
2766
+ }
2767
+ if operand.get("kind") == "literal":
2768
+ judge_values, judge_value_details = cls._data_relation_literal_values(
2769
+ source,
2770
+ deepcopy(operand.get("values") or []),
2771
+ )
2772
+ payload.update(
2773
+ {
2774
+ "matchType": 1,
2775
+ "judgeValues": judge_values,
2776
+ "judgeValueDetails": judge_value_details,
2777
+ }
2778
+ )
2779
+ elif operand.get("kind") == "current_field":
2780
+ target = _resolve_internal_selector(operand.get("field") or {}, current_fields, "operand.field")
2781
+ target_id = target.get("que_id") or target.get("que_temp_id")
2782
+ payload.update({"matchType": 2, "judgeValues": [str(target_id)], "judgeQueDetail": {"queId": target_id, "queTitle": target.get("name")}})
2783
+ else:
2784
+ producer = _resolve_internal_selector(operand.get("producer") or {}, current_fields, "operand.producer")
2785
+ alias = operand.get("alias") or {}
2786
+ payload.update(
2787
+ {
2788
+ "matchType": 7,
2789
+ "judgeValues": [str(alias.get("name") or "")],
2790
+ "aliasConfig": _without_none({"queId": producer.get("que_id") or producer.get("que_temp_id"), "queTitle": producer.get("name"), "qlinkerAlias": alias.get("name"), "aliasId": _positive_int(alias.get("aliasId"))}),
2791
+ }
2792
+ )
2793
+ return payload
2794
+
2795
+ @staticmethod
2796
+ def _data_relation_literal_values(source: JSONObject, values: list[Any]) -> tuple[list[str], list[JSONObject]]:
2797
+ field_type = _canonical_backend_field_type(source)
2798
+ options = [item for item in source.get("options") or [] if isinstance(item, dict)]
2799
+ option_by_name = {
2800
+ str(item.get("optValue")): item
2801
+ for item in options
2802
+ if item.get("optValue") is not None
2803
+ }
2804
+ option_by_id = {
2805
+ str(item.get("optId")): item
2806
+ for item in options
2807
+ if _positive_int(item.get("optId")) is not None
2808
+ }
2809
+ judge_values: list[str] = []
2810
+ details: list[JSONObject] = []
2811
+ for value in values:
2812
+ if isinstance(value, bool):
2813
+ text = "true" if value else "false"
2814
+ elif isinstance(value, (str, int, float)):
2815
+ text = str(value)
2816
+ else:
2817
+ raise AppFormFailure(
2818
+ "UNSUPPORTED_FIELD_CONFIG",
2819
+ "literal data_relation values must be strings",
2820
+ "CONTRACT_PRECHECK",
2821
+ details={"field": AppFormCompiler._data_relation_field_name(source), "value": value},
2822
+ )
2823
+ if field_type in CHOICE_TYPES and options:
2824
+ option = option_by_name.get(text) or option_by_id.get(text)
2825
+ if option is None:
2826
+ raise AppFormFailure(
2827
+ "UNSUPPORTED_FIELD_CONFIG",
2828
+ f"literal value {text!r} is not an option of data_relation source field "
2829
+ f"{AppFormCompiler._data_relation_field_name(source)!r}",
2830
+ "CONTRACT_PRECHECK",
2831
+ details={"value": text, "allowedValues": sorted(option_by_name)},
2832
+ )
2833
+ option_id = _positive_int(option.get("optId"))
2834
+ option_name = str(option.get("optValue") or text)
2835
+ judge_values.append(str(option_id) if option_id is not None else option_name)
2836
+ details.append(_without_none({"id": option_id, "value": option_name}))
2837
+ continue
2838
+ judge_values.append(text)
2839
+ details.append({"value": text})
2840
+ return judge_values, details
2841
+
2842
+ @staticmethod
2843
+ def _resolve_data_relation_source_field(
2844
+ selector: JSONObject,
2845
+ *,
2846
+ source_fields: list[JSONObject],
2847
+ source_fields_are_backend: bool,
2848
+ location: str,
2849
+ ) -> JSONObject:
2850
+ if source_fields_are_backend:
2851
+ return _resolve_backend_selector(selector, source_fields, location)
2852
+ return _resolve_internal_selector(selector, source_fields, location)
2853
+
2854
+ @staticmethod
2855
+ def _data_relation_field_ref(field: JSONObject) -> int | None:
2856
+ return _positive_int(field.get("queId")) or _positive_int(field.get("que_id")) or int(field.get("que_temp_id") or 0) or None
2857
+
2858
+ @staticmethod
2859
+ def _data_relation_field_name(field: JSONObject) -> str:
2860
+ return str(field.get("queTitle") or field.get("name") or "")
2861
+
2862
+ @staticmethod
2863
+ def _data_relation_que_type(field: JSONObject) -> int:
2864
+ backend_type = _positive_int(field.get("queType"))
2865
+ if backend_type is not None:
2866
+ return backend_type
2867
+ return CANONICAL_TO_QUE_TYPE.get(str(field.get("_app_form_type") or _field_type(field)), 2)
2868
+
2869
+ @staticmethod
2870
+ def _data_relation_alias_metadata(
2871
+ *,
2872
+ producer: JSONObject,
2873
+ alias: JSONObject,
2874
+ internal_fields: list[JSONObject],
2875
+ ) -> tuple[int, int | None]:
2876
+ binding_key = "q_linker_binding" if producer.get("_app_form_type") == "q_linker" else "code_block_binding"
2877
+ binding = producer.get(binding_key) if isinstance(producer.get(binding_key), dict) else {}
2878
+ alias_name = str(alias.get("name") or "")
2879
+ alias_id = _positive_int(alias.get("aliasId"))
2880
+ matches = []
2881
+ for output in binding.get("outputs") or []:
2882
+ if not isinstance(output, dict):
2883
+ continue
2884
+ output_name = str(output.get("alias_name") or output.get("alias") or "")
2885
+ output_id = _positive_int(output.get("alias_id") or output.get("aliasId"))
2886
+ if output_name != alias_name or (alias_id is not None and output_id is not None and output_id != alias_id):
2887
+ continue
2888
+ matches.append(output)
2889
+ if len(matches) != 1:
2890
+ raise AppFormFailure(
2891
+ "UNSUPPORTED_FIELD_CONFIG",
2892
+ "data_relation alias must resolve to exactly one producer output",
2893
+ "CONTRACT_PRECHECK",
2894
+ details={"producer": producer.get("name"), "alias": alias, "matches": len(matches)},
2895
+ )
2896
+ output = matches[0]
2897
+ selector = output.get("target_field") or output.get("targetField") or {}
2898
+ target = _resolve_internal_selector(selector, internal_fields, "source.alias.targetField")
2899
+ origin_type = CANONICAL_TO_QUE_TYPE.get(str(target.get("_app_form_type") or _field_type(target)))
2900
+ if origin_type is None:
2901
+ raise AppFormFailure(
2902
+ "UNSUPPORTED_FIELD_CONFIG",
2903
+ "data_relation alias output type is unsupported",
2904
+ "CONTRACT_PRECHECK",
2905
+ details={"producer": producer.get("name"), "alias": alias, "target": target.get("name")},
2906
+ )
2907
+ resolved_alias_id = alias_id or _positive_int(output.get("alias_id") or output.get("aliasId"))
2908
+ return origin_type, resolved_alias_id
2909
+
2910
+ @staticmethod
2911
+ def _validate_data_relation_operator(source: JSONObject, operator: str, operand: JSONObject) -> None:
2912
+ common = {"equal", "not_equal", "is_empty", "is_not_empty"}
2913
+ text_ops = common | {"contains", "not_contains", "equal_any", "not_equal_any", "fuzzy_match", "contains_any", "contains_all"}
2914
+ ordered_ops = common | {"greater_than", "greater_or_equal", "less_than", "less_or_equal", "dynamic_range", "decimal_range"}
2915
+ organization_ops = common | {"belongs_to", "not_belongs_to", "subordinate_to", "not_subordinate_to", "subordinate_included", "subordinate_not_included", "contains_any", "contains_all"}
2916
+ field_type = _canonical_backend_field_type(source)
2917
+ if field_type in {"number", "amount", "date", "datetime"}:
2918
+ allowed = ordered_ops
2919
+ elif field_type in {"member", "department"}:
2920
+ allowed = organization_ops
2921
+ else:
2922
+ allowed = text_ops
2923
+ if operator not in allowed:
2924
+ raise AppFormFailure(
2925
+ "UNSUPPORTED_FIELD_CONFIG",
2926
+ f"operator {operator!r} is not supported for {field_type or 'unknown'} fields",
2927
+ "CONTRACT_PRECHECK",
2928
+ details={"field": source.get("name"), "fieldType": field_type, "operator": operator},
2929
+ )
2930
+ values = operand.get("values") if operand.get("kind") == "literal" else None
2931
+ if operator in EMPTY_OPERATORS:
2932
+ if operand.get("kind") != "literal" or values not in ([], None):
2933
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "empty-value operators require an empty literal operand", "CONTRACT_PRECHECK")
2934
+ elif operand.get("kind") == "literal" and not values:
2935
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "literal data_relation operands must contain at least one value", "CONTRACT_PRECHECK")
2936
+
2937
+ @staticmethod
2938
+ def _field_runtime_id(field: JSONObject | None, internal_fields: list[JSONObject]) -> int | None:
2939
+ if field is None:
2940
+ return None
2941
+ internal = next((item for item in internal_fields if item.get("name") == field.get("name")), None)
2942
+ if internal is None:
2943
+ return _positive_int(field.get("queId"))
2944
+ return _positive_int(internal.get("que_id")) or int(internal.get("que_temp_id") or 0) or None
2945
+
2946
+ @staticmethod
2947
+ def _save_base(current: JSONObject) -> JSONObject:
2948
+ read_only = {"appKey", "formId", "baseQues", "applyAuthType", "canCurrentApply", "wsType", "accountLevel", "collectionStatus", "editVersionNo", "formQues", "questionRelations", "selectScopeRelations"}
2949
+ return {key: deepcopy(value) for key, value in current.items() if key not in read_only}
2950
+
2951
+ def _apply_form_settings(self, payload: JSONObject, *, current_form: JSONObject, target_spec: JSONObject, internal_fields: list[JSONObject]) -> None:
2952
+ payload["formTitle"] = target_spec["appName"]
2953
+ payload["formDesc"] = target_spec.get("appDescription") or ""
2954
+ current_attachments = {
2955
+ (str(item.get("fileName") or item.get("name") or ""), str(item.get("fileUrl") or item.get("url") or "")): item
2956
+ for item in current_form.get("formAttach") or []
2957
+ if isinstance(item, dict)
2958
+ }
2959
+ current_attachments_by_id = {
2960
+ _positive_int(item.get("attachId") or item.get("id")): item
2961
+ for item in current_form.get("formAttach") or []
2962
+ if isinstance(item, dict) and _positive_int(item.get("attachId") or item.get("id")) is not None
2963
+ }
2964
+ payload["formAttach"] = []
2965
+ for item in target_spec.get("attachments") or []:
2966
+ backend = {"fileName": item["fileName"], "fileUrl": item["fileUrl"]}
2967
+ attach_id = _positive_int(item.get("attachId"))
2968
+ if attach_id is not None:
2969
+ current_item = current_attachments_by_id.get(attach_id)
2970
+ current_identity = (
2971
+ str((current_item or {}).get("fileName") or (current_item or {}).get("name") or ""),
2972
+ str((current_item or {}).get("fileUrl") or (current_item or {}).get("url") or ""),
2973
+ )
2974
+ if current_item is None or current_identity != (item["fileName"], item["fileUrl"]):
2975
+ attach_id = None
2976
+ if attach_id is None:
2977
+ attach_id = _positive_int(current_attachments.get((item["fileName"], item["fileUrl"]), {}).get("attachId"))
2978
+ if attach_id is not None:
2979
+ backend["attachId"] = attach_id
2980
+ payload["formAttach"].append(backend)
2981
+ serial = target_spec.get("serialNumber") or {"mode": "default"}
2982
+ payload["serialNumType"] = 1 if serial.get("mode") == "default" else 2
2983
+ payload["serialNumConfig"] = [] if serial.get("mode") == "default" else self._serial_components(
2984
+ serial.get("components") or [],
2985
+ internal_fields,
2986
+ current_form.get("serialNumConfig") or [],
2987
+ )
2988
+
2989
+ @staticmethod
2990
+ def _serial_components(components: list[JSONObject], fields: list[JSONObject], current: list[Any] | None = None) -> list[JSONObject]:
2991
+ type_ids = {"auto_count": 1, "submit_datetime": 2, "fixed_text": 3, "field": 4}
2992
+ result = []
2993
+ current = current or []
2994
+ for index, item in enumerate(components):
2995
+ component_id = _positive_int(item.get("id"))
2996
+ if component_id is None and index < len(current) and isinstance(current[index], dict):
2997
+ current_item = current[index]
2998
+ if _positive_int(current_item.get("type")) == type_ids[item["type"]]:
2999
+ component_id = _positive_int(current_item.get("id"))
3000
+ payload: JSONObject = _without_none({"id": component_id, "type": type_ids[item["type"]]})
3001
+ if item["type"] == "auto_count":
3002
+ payload.update({"length": item["length"], "repeatPeriod": REPEAT_PERIOD_TO_BACKEND[item["repeatPeriod"]]})
3003
+ elif item["type"] == "submit_datetime":
3004
+ payload["dateFormat"] = DATE_FORMAT_TO_BACKEND[item["dateFormat"]]
3005
+ elif item["type"] == "fixed_text":
3006
+ payload["customTitle"] = item["value"]
3007
+ else:
3008
+ field = _resolve_internal_selector(item["field"], fields, "serialNumber.components.field")
3009
+ payload.update({"queId": field.get("que_id") or field.get("que_temp_id"), "queTitle": field.get("name")})
3010
+ if item.get("dateFormat"):
3011
+ payload["dateFormat"] = DATE_FORMAT_TO_BACKEND[item["dateFormat"]]
3012
+ result.append(payload)
3013
+ return result
3014
+
3015
+ def _read_form(self, profile: str, app_key: str, *, being_draft: bool) -> JSONObject:
3016
+ response = self.apps.app_get_form_schema(
3017
+ profile=profile,
3018
+ app_key=app_key,
3019
+ form_type=1,
3020
+ being_draft=being_draft,
3021
+ being_apply=None,
3022
+ audit_node_id=None,
3023
+ include_raw=True,
3024
+ )
3025
+ value = response.get("result")
3026
+ if not isinstance(value, dict):
3027
+ raise AppFormFailure("FORM_READ_FAILED", "backend returned an invalid form", "READ")
3028
+ return value
3029
+
3030
+
3031
+ class AppFormReconciler:
3032
+ RUNTIME_VERSION = "v1alpha1"
3033
+
3034
+ def __init__(self, *, apps: AppTools, packages: PackageTools, registry: AppFormSchemaRegistry) -> None:
3035
+ self.apps = apps
3036
+ self.packages = packages
3037
+ self.registry = registry
3038
+ self.normalizer = AppFormNormalizer()
3039
+ self.compiler = AppFormCompiler(apps)
3040
+
3041
+ def schema(
3042
+ self,
3043
+ *,
3044
+ schema_version: str | None,
3045
+ field_type: str | None,
3046
+ schema_kind: str | None = None,
3047
+ ) -> JSONObject:
3048
+ try:
3049
+ return {
3050
+ "status": "success",
3051
+ **self.registry.schema(
3052
+ schema_version=schema_version,
3053
+ field_type=field_type,
3054
+ schema_kind=schema_kind,
3055
+ ),
3056
+ }
3057
+ except AppFormContractError as error:
3058
+ return self._failure(error.error_code, error.message, "SCHEMA", details=error.details)
3059
+
3060
+ def _runtime_for(self, context: AppFormSchemaContext) -> tuple[AppFormNormalizer, AppFormCompiler]:
3061
+ if context.normalizer_version != self.RUNTIME_VERSION:
3062
+ raise AppFormContractError(
3063
+ "SCHEMA_VERSION_MISMATCH",
3064
+ "AppForm schema version has no matching reconciler runtime",
3065
+ details={
3066
+ "schemaVersion": context.schema_version,
3067
+ "normalizerVersion": context.normalizer_version,
3068
+ "runtimeVersion": self.RUNTIME_VERSION,
3069
+ },
3070
+ )
3071
+ return self.normalizer, self.compiler
3072
+
3073
+ def get(self, *, profile: str, app_key: str, schema_version: str | None, being_draft: bool) -> JSONObject:
3074
+ try:
3075
+ context = self.registry.resolve_schema_version(schema_version)
3076
+ normalizer, _compiler = self._runtime_for(context)
3077
+ form = self._read_form(profile, app_key, being_draft=being_draft)
3078
+ if not being_draft:
3079
+ form = _normalize_published_scope_omissions(form)
3080
+ resource = normalizer.from_backend(context=context, app_key=app_key, form=form)
3081
+ context.validate(resource)
3082
+ return {"status": "success", "schemaVersion": context.schema_version, "source": "draft" if being_draft else "published", "resource": resource}
3083
+ except (AppFormContractError, AppFormFailure) as error:
3084
+ return self._from_error(error)
3085
+ except (QingflowApiError, RuntimeError) as error:
3086
+ return self._api_failure("FORM_READ_FAILED", error, "GET")
3087
+
3088
+ def apply(self, *, profile: str, spec: JSONObject) -> JSONObject:
3089
+ warnings: list[JSONObject] = []
3090
+ app_key: str | None = None
3091
+ created = False
3092
+ binding_repair_needed = False
3093
+ diagnostics = self._diagnostics()
3094
+ try:
3095
+ if not isinstance(spec, dict):
3096
+ raise AppFormContractError("INVALID_SPEC", "AppForm apply payload must be an object", path="/")
3097
+ _validate_payload_limits(spec)
3098
+ api_version = spec.get("apiVersion")
3099
+ if not isinstance(api_version, str) or not api_version.strip():
3100
+ raise AppFormContractError("INVALID_SPEC", "apiVersion is required", path="/apiVersion")
3101
+ context = self.registry.resolve_api_version(api_version)
3102
+ normalizer, compiler = self._runtime_for(context)
3103
+ declaration, warnings = normalize_app_form_declaration(spec)
3104
+ is_update = "appKey" in declaration
3105
+ diagnostics["applyMode"] = "update" if is_update else "create"
3106
+ if not is_update and (
3107
+ _positive_int(declaration.get("packageId")) is None
3108
+ or not isinstance(declaration.get("spec"), dict)
3109
+ or not str(declaration["spec"].get("appName") or "").strip()
3110
+ ):
3111
+ raise AppFormContractError(
3112
+ "INVALID_APPLY_TARGET",
3113
+ "create mode requires packageId and spec.appName",
3114
+ details={"mode": "create"},
3115
+ )
3116
+ apply_dto = context.parse_apply(declaration)
3117
+ context.assert_apply_dto(apply_dto)
3118
+ declaration = apply_dto.resource
3119
+ package_id = _positive_int(declaration.get("packageId"))
3120
+ diagnostics["packageId"] = package_id
3121
+ preliminary_form: JSONObject | None = None
3122
+ if is_update:
3123
+ app_key = str(declaration["appKey"])
3124
+ try:
3125
+ self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
3126
+ preliminary_form = self._read_form(profile, app_key, being_draft=True)
3127
+ compiler.preflight(
3128
+ profile=profile,
3129
+ app_key=app_key,
3130
+ declaration=declaration,
3131
+ current_form=preliminary_form,
3132
+ )
3133
+ except (QingflowApiError, RuntimeError) as error:
3134
+ if self._is_not_found_error(error):
3135
+ raise AppFormFailure("APP_NOT_FOUND", "target application was not found", "TARGET_PRECHECK") from error
3136
+ raise
3137
+ else:
3138
+ try:
3139
+ self._read_package(profile, package_id or 0)
3140
+ except (QingflowApiError, RuntimeError) as error:
3141
+ if self._is_not_found_error(error):
3142
+ raise AppFormFailure("PACKAGE_NOT_FOUND", "target package was not found", "TARGET_PRECHECK") from error
3143
+ raise
3144
+ try:
3145
+ compiler.preflight(
3146
+ profile=profile,
3147
+ app_key="",
3148
+ declaration=declaration,
3149
+ current_form=None,
3150
+ )
3151
+ except (QingflowApiError, RuntimeError) as error:
3152
+ if self._is_not_found_error(error):
3153
+ raise AppFormFailure("APP_NOT_FOUND", "referenced application was not found", "REFERENCE_PRECHECK") from error
3154
+ raise
3155
+ app_key = self._create(profile, declaration)
3156
+ created = True
3157
+ diagnostics["operation"] = "CREATE_IN_PROGRESS"
3158
+ try:
3159
+ package_attached = self._package_contains(profile, package_id, app_key) if package_id is not None else None
3160
+ except (QingflowApiError, RuntimeError) as error:
3161
+ if self._is_not_found_error(error):
3162
+ raise AppFormFailure("PACKAGE_NOT_FOUND", "target package was not found", "TARGET_PRECHECK") from error
3163
+ raise
3164
+ diagnostics["packagePhase"] = "SKIPPED_UNMANAGED" if package_id is None else "SKIPPED_ALREADY_ATTACHED" if package_attached else "PENDING"
3165
+ diagnostics["packageAttached"] = package_attached
3166
+
3167
+ if preliminary_form is None:
3168
+ preliminary_form = self._read_form(profile, app_key, being_draft=True)
3169
+ try:
3170
+ current = normalizer.from_backend(context=context, app_key=app_key, form=preliminary_form)
3171
+ except AppFormFailure as error:
3172
+ if not compiler.is_unbound_intermediate_error(error):
3173
+ raise
3174
+ diagnostics["pendingBindings"] = compiler.pending_bindings(declaration)
3175
+ declaration = compiler.bind_backend_identities(form=preliminary_form, resource=declaration)
3176
+ current = compiler.recovery_resource(
3177
+ context=context,
3178
+ app_key=app_key,
3179
+ form=preliminary_form,
3180
+ declaration=declaration,
3181
+ )
3182
+ binding_repair_needed = True
3183
+ target = compiler.target_resource(context=context, app_key=app_key, current=current, declaration=declaration)
3184
+ form_changed = binding_repair_needed or not _canonical_equal(current, target)
3185
+ publish_needed = created
3186
+ if not form_changed and not created:
3187
+ try:
3188
+ published = self._published_resource(
3189
+ profile=profile,
3190
+ app_key=app_key,
3191
+ context=context,
3192
+ normalizer=normalizer,
3193
+ )
3194
+ publish_needed = not _published_equal(current, published)
3195
+ except (QingflowApiError, RuntimeError, AppFormFailure) as error:
3196
+ raise AppFormFailure("PUBLISHED_FORM_READ_FAILED", str(error), "PRECHECK_PUBLISHED_READ") from error
3197
+ diagnostics["diff"] = self._diff(current, target)
3198
+
3199
+ if not form_changed and not publish_needed:
3200
+ diagnostics["targetApplied"] = True
3201
+ diagnostics["remainingDiff"] = self._diff(current, target)
3202
+ diagnostics["published"] = True
3203
+ if package_id is not None and not package_attached:
3204
+ self._attach_package(profile, package_id, app_key, target["spec"]["appName"], diagnostics)
3205
+ diagnostics["operation"] = "APPLIED"
3206
+ else:
3207
+ diagnostics["operation"] = "UNCHANGED"
3208
+ return self._success(context, target, diagnostics, warnings)
3209
+
3210
+ edit_version_no = self._edit_version(profile, app_key)
3211
+ authoritative_form = self._read_form(profile, app_key, being_draft=True)
3212
+ if binding_repair_needed:
3213
+ authoritative = compiler.recovery_resource(
3214
+ context=context,
3215
+ app_key=app_key,
3216
+ form=authoritative_form,
3217
+ declaration=declaration,
3218
+ )
3219
+ else:
3220
+ authoritative = normalizer.from_backend(context=context, app_key=app_key, form=authoritative_form)
3221
+ target = compiler.target_resource(context=context, app_key=app_key, current=authoritative, declaration=declaration)
3222
+ form_changed = binding_repair_needed or not _canonical_equal(authoritative, target)
3223
+ diagnostics["diff"] = self._diff(authoritative, target)
3224
+ wrote_form = False
3225
+ if form_changed:
3226
+ needs_identity_write = compiler.needs_identity_write(target)
3227
+ if needs_identity_write:
3228
+ diagnostics["pendingBindings"] = compiler.pending_bindings(target)
3229
+ first_target = compiler.identity_target(current=authoritative, target=target) if needs_identity_write else target
3230
+ payload = compiler.payload(
3231
+ profile=profile,
3232
+ app_key=app_key,
3233
+ current_form=authoritative_form,
3234
+ target=first_target,
3235
+ edit_version_no=edit_version_no,
3236
+ )
3237
+ diagnostics["writeAttempts"] += 1
3238
+ diagnostics["formPhase"] = (
3239
+ "IDENTITY_WRITE"
3240
+ if needs_identity_write
3241
+ else "RELATION_REBIND"
3242
+ if binding_repair_needed
3243
+ else "DIRECT_WRITE"
3244
+ )
3245
+ try:
3246
+ self.apps.app_update_form_schema(profile=profile, app_key=app_key, payload=payload)
3247
+ except (QingflowApiError, RuntimeError) as error:
3248
+ self._raise_write_failure(error, diagnostics, first_write=True)
3249
+ diagnostics["writeCount"] += 1
3250
+ wrote_form = True
3251
+ if needs_identity_write:
3252
+ rebound_form = self._read_form(profile, app_key, being_draft=True)
3253
+ rebound_target = compiler.bind_backend_identities(form=rebound_form, resource=target)
3254
+ rebound_payload = compiler.payload(
3255
+ profile=profile,
3256
+ app_key=app_key,
3257
+ current_form=rebound_form,
3258
+ target=rebound_target,
3259
+ edit_version_no=edit_version_no,
3260
+ )
3261
+ diagnostics["writeAttempts"] += 1
3262
+ try:
3263
+ self.apps.app_update_form_schema(profile=profile, app_key=app_key, payload=rebound_payload)
3264
+ except (QingflowApiError, RuntimeError) as error:
3265
+ self._raise_write_failure(error, diagnostics, first_write=False)
3266
+ diagnostics["writeCount"] += 1
3267
+ diagnostics["formPhase"] = "RELATION_REBIND"
3268
+ diagnostics["pendingBindings"] = []
3269
+
3270
+ if wrote_form:
3271
+ draft_form = self._read_form(profile, app_key, being_draft=True)
3272
+ draft = normalizer.from_backend(context=context, app_key=app_key, form=draft_form)
3273
+ bound_target = compiler.bind_backend_identities(form=draft_form, resource=target)
3274
+ expected_form = compiler.payload(
3275
+ profile=profile,
3276
+ app_key=app_key,
3277
+ current_form=draft_form,
3278
+ target=bound_target,
3279
+ edit_version_no=edit_version_no,
3280
+ )
3281
+ expected = normalizer.from_backend(context=context, app_key=app_key, form=expected_form)
3282
+ else:
3283
+ draft = authoritative
3284
+ expected = target
3285
+
3286
+ diagnostics["targetApplied"] = _canonical_equal(draft, expected)
3287
+ diagnostics["remainingDiff"] = self._diff(draft, expected)
3288
+ if diagnostics["targetApplied"] is not True:
3289
+ raise AppFormFailure(
3290
+ "APPLY_READBACK_MISMATCH",
3291
+ "form readback did not match the complete apply target",
3292
+ "FORM_READBACK",
3293
+ status="partial_success",
3294
+ details={"remainingDiff": deepcopy(diagnostics["remainingDiff"])},
3295
+ )
3296
+
3297
+ publish_needed = created or wrote_form
3298
+ if not publish_needed:
3299
+ try:
3300
+ published = self._published_resource(
3301
+ profile=profile,
3302
+ app_key=app_key,
3303
+ context=context,
3304
+ normalizer=normalizer,
3305
+ )
3306
+ diagnostics["published"] = _published_equal(draft, published)
3307
+ publish_needed = not diagnostics["published"]
3308
+ except (QingflowApiError, RuntimeError, AppFormFailure) as error:
3309
+ self._finish_edit(profile, app_key, edit_version_no, diagnostics)
3310
+ raise AppFormFailure(
3311
+ "PUBLISHED_FORM_READ_FAILED",
3312
+ str(error),
3313
+ "AUTHORITATIVE_PUBLISHED_READ",
3314
+ status="partial_success",
3315
+ details=diagnostics,
3316
+ ) from error
3317
+
3318
+ if publish_needed:
3319
+ self._publish_and_verify(
3320
+ profile=profile,
3321
+ app_key=app_key,
3322
+ edit_version_no=edit_version_no,
3323
+ context=context,
3324
+ draft=draft,
3325
+ diagnostics=diagnostics,
3326
+ )
3327
+ else:
3328
+ self._finish_edit(profile, app_key, edit_version_no, diagnostics)
3329
+ if package_id is not None and not package_attached:
3330
+ self._attach_package(profile, package_id, app_key, draft["spec"]["appName"], diagnostics)
3331
+ diagnostics["operation"] = "CREATED" if created else "APPLIED" if wrote_form or publish_needed or diagnostics["packageAttachCount"] else "UNCHANGED"
3332
+ return self._success(context, draft, diagnostics, warnings)
3333
+ except AppFormContractError as error:
3334
+ if created and app_key:
3335
+ return self._rollback_created_after_failure(
3336
+ profile=profile,
3337
+ app_key=app_key,
3338
+ error_code=error.error_code,
3339
+ message=error.message,
3340
+ phase="VALIDATE",
3341
+ details={**error.details, **({"path": error.path} if error.path else {})},
3342
+ diagnostics=diagnostics,
3343
+ warnings=warnings,
3344
+ )
3345
+ diagnostics["operation"] = {
3346
+ "create": "CREATE_FAILED",
3347
+ "update": "UPDATE_FAILED",
3348
+ }.get(str(diagnostics.get("applyMode") or ""), "VALIDATION_FAILED")
3349
+ validation_diagnostics = {
3350
+ key: diagnostics[key]
3351
+ for key in ("operation", "applyMode")
3352
+ if diagnostics.get(key) is not None
3353
+ }
3354
+ return self._failure(
3355
+ error.error_code,
3356
+ error.message,
3357
+ "VALIDATE",
3358
+ details={**error.details, **({"path": error.path} if error.path else {})},
3359
+ diagnostics=validation_diagnostics,
3360
+ app_key=app_key,
3361
+ warnings=warnings,
3362
+ )
3363
+ except AppFormFailure as error:
3364
+ preserve_created_app = error.error_code == "APPLY_READBACK_MISMATCH"
3365
+ if (
3366
+ created
3367
+ and app_key
3368
+ and not preserve_created_app
3369
+ and error.status != "unknown"
3370
+ and diagnostics.get("writeMayHaveSucceeded") is not True
3371
+ ):
3372
+ return self._rollback_created_after_failure(
3373
+ profile=profile,
3374
+ app_key=app_key,
3375
+ error_code=error.error_code,
3376
+ message=error.message,
3377
+ phase=error.phase,
3378
+ details=error.details,
3379
+ diagnostics=diagnostics,
3380
+ warnings=warnings,
3381
+ )
3382
+ if created:
3383
+ diagnostics["operation"] = "CREATE_INCOMPLETE"
3384
+ else:
3385
+ diagnostics["operation"] = {
3386
+ "create": "CREATE_FAILED",
3387
+ "update": "UPDATE_FAILED",
3388
+ }.get(str(diagnostics.get("applyMode") or ""), "VALIDATION_FAILED")
3389
+ status = "partial_success" if created and error.status == "failed" else error.status
3390
+ result = self._failure(error.error_code, error.message, error.phase, status=status, details=error.details, diagnostics=diagnostics, app_key=app_key, warnings=warnings)
3391
+ if error.error_code == "APPLY_READBACK_MISMATCH" and app_key:
3392
+ result["requiresFurtherRepair"] = True
3393
+ result["mustReadCurrentStateBeforeRepair"] = True
3394
+ result["nextApplyMode"] = "update"
3395
+ result["suggested_next_call"] = {
3396
+ "tool_name": "app_form_get",
3397
+ "arguments": {
3398
+ "profile": profile,
3399
+ "app_key": app_key,
3400
+ "being_draft": True,
3401
+ },
3402
+ }
3403
+ if created:
3404
+ result["message"] = (
3405
+ "the application was created and its form was saved, but backend readback still differs from the apply target; "
3406
+ "the application was preserved and was not published"
3407
+ )
3408
+ result["createdAppPreserved"] = True
3409
+ result["recoveryHint"] = (
3410
+ "first read the current draft using the returned appKey; compare it with remainingDiff, then merge the intended changes into that current state, "
3411
+ "keep the appKey in the declaration, validate, and apply again in update mode"
3412
+ )
3413
+ return result
3414
+ except (QingflowApiError, RuntimeError) as error:
3415
+ uncertain = self._is_uncertain_api_error(error)
3416
+ code = (
3417
+ "APP_CREATE_RESULT_UNKNOWN"
3418
+ if not app_key and uncertain
3419
+ else "APP_CREATE_FAILED"
3420
+ if not app_key
3421
+ else "APPLY_RESULT_UNKNOWN"
3422
+ if uncertain
3423
+ else "APP_FORM_APPLY_FAILED"
3424
+ )
3425
+ if created and app_key and not uncertain:
3426
+ return self._rollback_created_after_failure(
3427
+ profile=profile,
3428
+ app_key=app_key,
3429
+ error_code=code,
3430
+ message=str(self._error_details(error).get("message") or error),
3431
+ phase="APPLY",
3432
+ details=self._error_details(error),
3433
+ diagnostics=diagnostics,
3434
+ warnings=warnings,
3435
+ )
3436
+ if created:
3437
+ diagnostics["operation"] = "CREATE_INCOMPLETE"
3438
+ else:
3439
+ diagnostics["operation"] = {
3440
+ "create": "CREATE_FAILED",
3441
+ "update": "UPDATE_FAILED",
3442
+ }.get(str(diagnostics.get("applyMode") or ""), "VALIDATION_FAILED")
3443
+ mutation_started = bool(
3444
+ created
3445
+ or diagnostics["writeAttempts"]
3446
+ or diagnostics["publishAttempted"]
3447
+ or diagnostics["packageAttachAttempted"]
3448
+ )
3449
+ status = "unknown" if uncertain else "partial_success" if mutation_started else "failed"
3450
+ return self._api_failure(code, error, "CREATE" if not app_key else "APPLY", status=status, diagnostics=diagnostics, app_key=app_key, warnings=warnings)
3451
+
3452
+ def _rollback_created_after_failure(
3453
+ self,
3454
+ *,
3455
+ profile: str,
3456
+ app_key: str,
3457
+ error_code: str,
3458
+ message: str,
3459
+ phase: str,
3460
+ details: JSONObject | None,
3461
+ diagnostics: JSONObject,
3462
+ warnings: list[JSONObject],
3463
+ ) -> JSONObject:
3464
+ diagnostics["createRollbackAttempted"] = True
3465
+ failure_details = deepcopy(details or {})
3466
+ transport_error = failure_details.pop("transportError", None)
3467
+ if isinstance(transport_error, dict):
3468
+ failure_details["failureCause"] = deepcopy(transport_error)
3469
+ try:
3470
+ self.apps.app_delete(profile=profile, app_key=app_key)
3471
+ except (QingflowApiError, RuntimeError) as rollback_error:
3472
+ rollback_details = self._error_details(rollback_error)
3473
+ uncertain = self._is_uncertain_api_error(rollback_error)
3474
+ diagnostics["createRollbackSucceeded"] = None if uncertain else False
3475
+ diagnostics["operation"] = "CREATE_ROLLBACK_UNKNOWN" if uncertain else "CREATE_ROLLBACK_FAILED"
3476
+ failure_details["rollbackError"] = rollback_details
3477
+ return self._failure(
3478
+ error_code,
3479
+ (
3480
+ "AppForm creation failed, and cleanup of the newly created application "
3481
+ f"{'could not be confirmed' if uncertain else 'also failed'}: {message}"
3482
+ ),
3483
+ phase,
3484
+ status="unknown" if uncertain else "partial_success",
3485
+ details=failure_details,
3486
+ diagnostics=diagnostics,
3487
+ app_key=app_key,
3488
+ warnings=warnings,
3489
+ )
3490
+ diagnostics["createRollbackSucceeded"] = True
3491
+ diagnostics["operation"] = "CREATE_ROLLED_BACK"
3492
+ result = self._failure(
3493
+ error_code,
3494
+ f"AppForm creation failed and the newly created application was deleted: {message}",
3495
+ phase,
3496
+ status="failed",
3497
+ details=failure_details,
3498
+ diagnostics=diagnostics,
3499
+ app_key=app_key,
3500
+ warnings=warnings,
3501
+ )
3502
+ result["recoverable"] = True
3503
+ result["recoveryHint"] = "fix the original failure cause, then submit the create declaration again"
3504
+ return result
3505
+
3506
+ def delete(self, *, profile: str, app_key: str) -> JSONObject:
3507
+ try:
3508
+ result = self.apps.app_delete(profile=profile, app_key=app_key)
3509
+ return {"status": "success", "operation": "DELETED", "appKey": app_key, "result": result.get("result")}
3510
+ except (QingflowApiError, RuntimeError) as error:
3511
+ return self._api_failure("APP_DELETE_FAILED", error, "DELETE", app_key=app_key)
3512
+
3513
+ def _create(self, profile: str, declaration: JSONObject) -> str:
3514
+ spec = declaration["spec"]
3515
+ app_name = str(spec["appName"])
3516
+ package_id = int(declaration["packageId"])
3517
+ payload = {
3518
+ "appName": app_name,
3519
+ "appIcon": encode_workspace_icon_with_defaults(icon=None, color=None, title=app_name),
3520
+ "auth": default_member_auth(),
3521
+ "tagIds": [package_id],
3522
+ }
3523
+ created = self.apps.app_create(profile=profile, payload=payload, retry_without_qf_version=False)
3524
+ result = created.get("result") if isinstance(created.get("result"), dict) else {}
3525
+ app_key = str(result.get("appKey") or ((result.get("appKeys") or [""])[0] if isinstance(result.get("appKeys"), list) else ""))
3526
+ if not app_key:
3527
+ raise AppFormFailure("APP_CREATE_RESULT_UNKNOWN", "create response did not contain appKey", "CREATE", status="unknown", details={"createResult": result})
3528
+ return app_key
3529
+
3530
+ def _publish_and_verify(
3531
+ self,
3532
+ *,
3533
+ profile: str,
3534
+ app_key: str,
3535
+ edit_version_no: int,
3536
+ context: AppFormSchemaContext,
3537
+ draft: JSONObject,
3538
+ diagnostics: JSONObject,
3539
+ ) -> None:
3540
+ for attempt in range(2):
3541
+ try:
3542
+ self._publish_once(profile, app_key, edit_version_no, diagnostics)
3543
+ break
3544
+ except AppFormFailure as error:
3545
+ if error.error_code == "PUBLISH_FAILED":
3546
+ diagnostics["published"] = False
3547
+ raise
3548
+ if error.error_code != "APPLY_RESULT_UNKNOWN":
3549
+ raise
3550
+ except (QingflowApiError, RuntimeError) as error:
3551
+ details = self._error_details(error)
3552
+ if self._looks_like_version_conflict(details):
3553
+ raise AppFormFailure(
3554
+ "EDIT_VERSION_CONFLICT",
3555
+ "the application edit version changed during publish",
3556
+ "PUBLISH",
3557
+ status="partial_success",
3558
+ details={**diagnostics, "transportError": details},
3559
+ ) from error
3560
+ if not self._is_uncertain_api_error(error):
3561
+ diagnostics.update({"publishResponseAccepted": False, "published": False})
3562
+ raise AppFormFailure(
3563
+ "PUBLISH_FAILED",
3564
+ "publish request was rejected",
3565
+ "PUBLISH",
3566
+ status="partial_success",
3567
+ details={**diagnostics, "transportError": self._error_details(error)},
3568
+ ) from error
3569
+
3570
+ diagnostics["publishResponseAccepted"] = None
3571
+ recovered = self._published_matches(profile, app_key, context, draft, diagnostics)
3572
+ if recovered is True:
3573
+ finish_error: AppFormFailure | None = None
3574
+ try:
3575
+ self._finish_edit(profile, app_key, edit_version_no, diagnostics)
3576
+ except AppFormFailure as error:
3577
+ finish_error = error
3578
+ self._published_matches(profile, app_key, context, draft, diagnostics)
3579
+ if finish_error is not None:
3580
+ self._raise_finish_failure(finish_error, diagnostics)
3581
+ if diagnostics["published"] is not True:
3582
+ raise AppFormFailure(
3583
+ "PUBLISH_FAILED" if diagnostics["published"] is False else "APPLY_RESULT_UNKNOWN",
3584
+ "published form changed during publish recovery",
3585
+ "PUBLISH_READBACK",
3586
+ status="partial_success" if diagnostics["published"] is False else "unknown",
3587
+ details=diagnostics,
3588
+ )
3589
+ return
3590
+ if attempt == 0 and recovered is False:
3591
+ continue
3592
+ raise AppFormFailure(
3593
+ "APPLY_RESULT_UNKNOWN",
3594
+ "publish result could not be confirmed",
3595
+ "PUBLISH",
3596
+ status="unknown",
3597
+ details=diagnostics,
3598
+ )
3599
+
3600
+ finish_error: AppFormFailure | None = None
3601
+ try:
3602
+ self._finish_edit(profile, app_key, edit_version_no, diagnostics)
3603
+ except AppFormFailure as error:
3604
+ finish_error = error
3605
+ self._published_matches(profile, app_key, context, draft, diagnostics)
3606
+ if finish_error is not None:
3607
+ self._raise_finish_failure(finish_error, diagnostics)
3608
+ if diagnostics["published"] is not True:
3609
+ raise AppFormFailure(
3610
+ "PUBLISH_FAILED" if diagnostics["published"] is False else "APPLY_RESULT_UNKNOWN",
3611
+ "published form could not be verified against the draft",
3612
+ "PUBLISH_READBACK",
3613
+ status="partial_success" if diagnostics["published"] is False else "unknown",
3614
+ details=diagnostics,
3615
+ )
3616
+
3617
+ def _publish_once(self, profile: str, app_key: str, edit_version_no: int, diagnostics: JSONObject) -> None:
3618
+ diagnostics["publishAttempted"] = True
3619
+ diagnostics["publishAttempts"] += 1
3620
+ response = self.apps.app_publish_raw(
3621
+ profile=profile,
3622
+ app_key=app_key,
3623
+ payload={"editVersionNo": edit_version_no},
3624
+ )
3625
+ http_status = response.get("http_status")
3626
+ raw = response.get("response")
3627
+ if self._looks_like_version_conflict({"http_status": http_status, "response": raw}):
3628
+ raise AppFormFailure(
3629
+ "EDIT_VERSION_CONFLICT",
3630
+ "the application edit version changed during publish",
3631
+ "PUBLISH",
3632
+ status="partial_success",
3633
+ details={**diagnostics, "response": deepcopy(raw)},
3634
+ )
3635
+ if not isinstance(http_status, int) or not 200 <= http_status < 300 or not isinstance(raw, dict):
3636
+ raise AppFormFailure(
3637
+ "APPLY_RESULT_UNKNOWN",
3638
+ "publish response did not contain a valid HTTP result envelope",
3639
+ "PUBLISH",
3640
+ status="unknown",
3641
+ details=diagnostics,
3642
+ )
3643
+
3644
+ has_success = "success" in raw
3645
+ has_code = "code" in raw
3646
+ success_ok = raw.get("success") is True if has_success else None
3647
+ code_value = raw.get("code")
3648
+ code_ok = isinstance(code_value, int) and not isinstance(code_value, bool) and code_value == 0 if has_code else None
3649
+ if not has_success and not has_code:
3650
+ raise AppFormFailure(
3651
+ "APPLY_RESULT_UNKNOWN",
3652
+ "publish response omitted the ResponseResult success indicator",
3653
+ "PUBLISH",
3654
+ status="unknown",
3655
+ details=diagnostics,
3656
+ )
3657
+ if success_ok is False or code_ok is False or (success_ok is not None and code_ok is not None and success_ok != code_ok):
3658
+ diagnostics["publishResponseAccepted"] = False
3659
+ raise AppFormFailure(
3660
+ "PUBLISH_FAILED",
3661
+ "publish ResponseResult reported failure",
3662
+ "PUBLISH",
3663
+ status="partial_success",
3664
+ details={**diagnostics, "response": deepcopy(raw)},
3665
+ )
3666
+
3667
+ result = next((raw[key] for key in ("result", "data", "page", "obj") if key in raw), None)
3668
+ if not isinstance(result, dict):
3669
+ raise AppFormFailure(
3670
+ "APPLY_RESULT_UNKNOWN",
3671
+ "publish response did not contain a PublishRes object",
3672
+ "PUBLISH",
3673
+ status="unknown",
3674
+ details=diagnostics,
3675
+ )
3676
+ diagnostics["appPublishStatus"] = result.get("appPublishStatus")
3677
+ diagnostics["publishDetails"] = deepcopy(result.get("details") or [])
3678
+ if result.get("publishSuccess") is False:
3679
+ diagnostics["publishResponseAccepted"] = False
3680
+ raise AppFormFailure(
3681
+ "PUBLISH_FAILED",
3682
+ "publish checks did not pass",
3683
+ "PUBLISH",
3684
+ status="partial_success",
3685
+ details=diagnostics,
3686
+ )
3687
+ if result.get("publishSuccess") is not True:
3688
+ raise AppFormFailure(
3689
+ "APPLY_RESULT_UNKNOWN",
3690
+ "PublishRes.publishSuccess was missing or invalid",
3691
+ "PUBLISH",
3692
+ status="unknown",
3693
+ details=diagnostics,
3694
+ )
3695
+ diagnostics["publishResponseAccepted"] = True
3696
+ diagnostics["publishCount"] += 1
3697
+
3698
+ def _published_matches(
3699
+ self,
3700
+ profile: str,
3701
+ app_key: str,
3702
+ context: AppFormSchemaContext,
3703
+ draft: JSONObject,
3704
+ diagnostics: JSONObject,
3705
+ ) -> bool | None:
3706
+ try:
3707
+ normalizer, _compiler = self._runtime_for(context)
3708
+ published = self._published_resource(
3709
+ profile=profile,
3710
+ app_key=app_key,
3711
+ context=context,
3712
+ normalizer=normalizer,
3713
+ )
3714
+ diagnostics["published"] = _published_equal(draft, published)
3715
+ except (QingflowApiError, RuntimeError, AppFormFailure):
3716
+ diagnostics["published"] = None
3717
+ return diagnostics["published"]
3718
+
3719
+ def _published_resource(
3720
+ self,
3721
+ *,
3722
+ profile: str,
3723
+ app_key: str,
3724
+ context: AppFormSchemaContext,
3725
+ normalizer: AppFormNormalizer,
3726
+ ) -> JSONObject:
3727
+ published_form = self._read_form(profile, app_key, being_draft=False)
3728
+ comparable_form = _normalize_published_scope_omissions(published_form)
3729
+ return normalizer.from_backend(context=context, app_key=app_key, form=comparable_form)
3730
+
3731
+ def _finish_edit(self, profile: str, app_key: str, edit_version_no: int, diagnostics: JSONObject) -> None:
3732
+ try:
3733
+ self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
3734
+ except (QingflowApiError, RuntimeError) as error:
3735
+ details = self._error_details(error)
3736
+ if self._looks_like_version_conflict(details):
3737
+ raise AppFormFailure(
3738
+ "EDIT_VERSION_CONFLICT",
3739
+ "the application edit version changed before editing could be finished",
3740
+ "EDIT_FINISHED",
3741
+ status="partial_success",
3742
+ details={**diagnostics, "transportError": details},
3743
+ ) from error
3744
+ raise AppFormFailure(
3745
+ "EDIT_FINISHED_FAILED",
3746
+ "application editing could not be finished",
3747
+ "EDIT_FINISHED",
3748
+ status="partial_success",
3749
+ details={**diagnostics, "transportError": details},
3750
+ ) from error
3751
+ diagnostics["editFinished"] = True
3752
+
3753
+ @staticmethod
3754
+ def _raise_finish_failure(error: AppFormFailure, diagnostics: JSONObject) -> None:
3755
+ raise AppFormFailure(
3756
+ error.error_code,
3757
+ error.message,
3758
+ error.phase,
3759
+ status=error.status,
3760
+ details={**(error.details or {}), **diagnostics},
3761
+ ) from error
3762
+
3763
+ def _raise_write_failure(self, error: QingflowApiError | RuntimeError, diagnostics: JSONObject, *, first_write: bool) -> None:
3764
+ details = self._error_details(error)
3765
+ if self._looks_like_version_conflict(details):
3766
+ raise AppFormFailure(
3767
+ "EDIT_VERSION_CONFLICT",
3768
+ "the application edit version changed during apply",
3769
+ diagnostics["formPhase"],
3770
+ status="failed" if first_write else "partial_success",
3771
+ details={**diagnostics, "transportError": details},
3772
+ ) from error
3773
+ diagnostics["writeMayHaveSucceeded"] = self._is_uncertain_api_error(error)
3774
+ backend_message = str(details.get("message") or "").strip()
3775
+ message = "form write failed or could not be confirmed"
3776
+ if backend_message:
3777
+ message = f"{message}: {backend_message}"
3778
+ raise AppFormFailure(
3779
+ "APPLY_RESULT_UNKNOWN" if diagnostics["writeMayHaveSucceeded"] else "RELATION_REBIND_FAILED" if not first_write else "APP_FORM_WRITE_FAILED",
3780
+ message,
3781
+ diagnostics["formPhase"],
3782
+ status="unknown" if diagnostics["writeMayHaveSucceeded"] else "partial_success" if not first_write else "failed",
3783
+ details={**diagnostics, "transportError": details},
3784
+ ) from error
3785
+
3786
+ @staticmethod
3787
+ def _error_details(error: QingflowApiError | RuntimeError) -> JSONObject:
3788
+ if isinstance(error, QingflowApiError):
3789
+ return error.to_dict()
3790
+ try:
3791
+ value = json.loads(str(error))
3792
+ except (TypeError, json.JSONDecodeError):
3793
+ return {"message": str(error)}
3794
+ return value if isinstance(value, dict) else {"message": str(error)}
3795
+
3796
+ @classmethod
3797
+ def _is_uncertain_api_error(cls, error: QingflowApiError | RuntimeError) -> bool:
3798
+ return str(cls._error_details(error).get("category") or "").lower() == "network"
3799
+
3800
+ @staticmethod
3801
+ def _looks_like_version_conflict(details: JSONObject) -> bool:
3802
+ rendered = json.dumps(details, ensure_ascii=False, sort_keys=True).lower()
3803
+ codes = {
3804
+ details.get("backend_code"),
3805
+ details.get("code"),
3806
+ details.get("http_status"),
3807
+ }
3808
+ return (
3809
+ "editversion" in rendered
3810
+ or "edit version" in rendered
3811
+ or "编辑版本" in rendered
3812
+ or any(code in {409, "409", 49613, "49613"} for code in codes)
3813
+ )
3814
+
3815
+ @classmethod
3816
+ def _is_not_found_error(cls, error: QingflowApiError | RuntimeError) -> bool:
3817
+ if isinstance(error, QingflowApiError) and is_auth_like_error(error):
3818
+ return False
3819
+ details = cls._error_details(error)
3820
+ return details.get("http_status") == 404 or details.get("backend_code") in {404, "404", 40002, "40002", 40027, "40027"}
3821
+
3822
+ def _attach_package(self, profile: str, package_id: int, app_key: str, title: str, diagnostics: JSONObject) -> None:
3823
+ diagnostics["packageAttachAttempted"] = True
3824
+ package = self._read_package(profile, package_id)
3825
+ items = [deepcopy(item) for item in package.get("tagItems") or [] if isinstance(item, dict)]
3826
+ if any(str(item.get("appKey") or "") == app_key for item in items):
3827
+ diagnostics.update({"packagePhase": "SKIPPED_ALREADY_ATTACHED", "packageAttached": True})
3828
+ return
3829
+ items.append({"itemType": 1, "appKey": app_key, "title": title, "iconUrl": None})
3830
+ try:
3831
+ self.packages.package_sort_items(profile=profile, tag_id=package_id, tag_items=items)
3832
+ except (QingflowApiError, RuntimeError) as error:
3833
+ uncertain = self._is_uncertain_api_error(error)
3834
+ diagnostics["packageAttachMayHaveSucceeded"] = uncertain
3835
+ raise AppFormFailure(
3836
+ "PACKAGE_ATTACH_RESULT_UNKNOWN" if uncertain else "PACKAGE_ATTACH_FAILED",
3837
+ "package attachment failed or could not be confirmed",
3838
+ "PACKAGE_ATTACH",
3839
+ status="unknown" if uncertain else "partial_success",
3840
+ details={**diagnostics, "transportError": self._error_details(error)},
3841
+ ) from error
3842
+ diagnostics["packageAttachCount"] = 1
3843
+ try:
3844
+ attached = self._package_contains(profile, package_id, app_key)
3845
+ except (QingflowApiError, RuntimeError, AppFormFailure) as error:
3846
+ diagnostics["packageAttachMayHaveSucceeded"] = True
3847
+ raise AppFormFailure(
3848
+ "PACKAGE_ATTACH_RESULT_UNKNOWN",
3849
+ "package attachment could not be read back",
3850
+ "PACKAGE_ATTACH",
3851
+ status="unknown",
3852
+ details={**diagnostics, "readbackError": str(error)},
3853
+ ) from error
3854
+ if not attached:
3855
+ diagnostics["packageAttachMayHaveSucceeded"] = True
3856
+ raise AppFormFailure(
3857
+ "PACKAGE_ATTACH_RESULT_UNKNOWN",
3858
+ "package attachment was not present on readback",
3859
+ "PACKAGE_ATTACH",
3860
+ status="partial_success",
3861
+ details=diagnostics,
3862
+ )
3863
+ diagnostics.update({"packagePhase": "ATTACHED", "packageAttached": True})
3864
+
3865
+ def _read_form(self, profile: str, app_key: str, *, being_draft: bool) -> JSONObject:
3866
+ response = self.apps.app_get_form_schema(profile=profile, app_key=app_key, form_type=1, being_draft=being_draft, being_apply=None, audit_node_id=None, include_raw=True)
3867
+ result = response.get("result")
3868
+ if not isinstance(result, dict):
3869
+ raise AppFormFailure("FORM_READ_FAILED", "backend returned invalid FormInfo", "READ")
3870
+ return result
3871
+
3872
+ def _read_package(self, profile: str, package_id: int) -> JSONObject:
3873
+ response = self.packages.package_get(profile=profile, tag_id=package_id, include_raw=True)
3874
+ result = response.get("result")
3875
+ if not isinstance(result, dict):
3876
+ raise AppFormFailure("PACKAGE_READ_FAILED", "backend returned invalid package detail", "TARGET_PRECHECK")
3877
+ return result
3878
+
3879
+ def _package_contains(self, profile: str, package_id: int | None, app_key: str) -> bool | None:
3880
+ if package_id is None:
3881
+ return None
3882
+ package = self._read_package(profile, package_id)
3883
+ return any(str(item.get("appKey") or "") == app_key for item in package.get("tagItems") or [] if isinstance(item, dict))
3884
+
3885
+ def _edit_version(self, profile: str, app_key: str) -> int:
3886
+ response = self.apps.app_get_edit_version_no(profile=profile, app_key=app_key)
3887
+ result = response.get("result")
3888
+ version = _positive_int(result.get("editVersionNo")) if isinstance(result, dict) else _positive_int(result)
3889
+ if version is None:
3890
+ raise AppFormFailure("EDIT_VERSION_READ_FAILED", "editVersionNo response was invalid", "EDIT_VERSION")
3891
+ return version
3892
+
3893
+ @staticmethod
3894
+ def _diff(current: JSONObject, target: JSONObject) -> JSONObject:
3895
+ current_fields = {_positive_int(item.get("queId")) or f"name:{item.get('name')}": item for item in _iter_fields(current["spec"]["body"])}
3896
+ target_fields = {_positive_int(item.get("queId")) or f"name:{item.get('name')}": item for item in _iter_fields(target["spec"]["body"])}
3897
+ return {
3898
+ "added": [deepcopy(item) for key, item in target_fields.items() if key not in current_fields],
3899
+ "updated": [deepcopy(item) for key, item in target_fields.items() if key in current_fields and _semantic(item) != _semantic(current_fields[key])],
3900
+ "removed": [deepcopy(item) for key, item in current_fields.items() if key not in target_fields],
3901
+ "layoutChanged": _layout_semantic(current["spec"]["body"]) != _layout_semantic(target["spec"]["body"]),
3902
+ "configChanged": _semantic({key: value for key, value in target["spec"].items() if key != "body"}) != _semantic({key: value for key, value in current["spec"].items() if key != "body"}),
3903
+ }
3904
+
3905
+ @staticmethod
3906
+ def _diagnostics() -> JSONObject:
3907
+ return {
3908
+ "operation": "APPLIED",
3909
+ "applyMode": None,
3910
+ "packageId": None,
3911
+ "diff": {},
3912
+ "targetApplied": None,
3913
+ "remainingDiff": {},
3914
+ "writeCount": 0,
3915
+ "writeAttempts": 0,
3916
+ "writeMayHaveSucceeded": False,
3917
+ "formPhase": "NO_WRITE",
3918
+ "pendingBindings": [],
3919
+ "publishAttempted": False,
3920
+ "publishAttempts": 0,
3921
+ "publishResponseAccepted": None,
3922
+ "publishCount": 0,
3923
+ "published": None,
3924
+ "editFinished": False,
3925
+ "packageAttachAttempted": False,
3926
+ "packageAttachCount": 0,
3927
+ "packageAttached": None,
3928
+ "packageAttachMayHaveSucceeded": False,
3929
+ "packagePhase": "SKIPPED_UNMANAGED",
3930
+ }
3931
+
3932
+ @staticmethod
3933
+ def _success(context: AppFormSchemaContext, resource: JSONObject, diagnostics: JSONObject, warnings: list[JSONObject]) -> JSONObject:
3934
+ diff = diagnostics.get("diff") if isinstance(diagnostics.get("diff"), dict) else {}
3935
+ changes: JSONObject = {}
3936
+ for source_key, result_key in (
3937
+ ("added", "addedFields"),
3938
+ ("updated", "updatedFields"),
3939
+ ("removed", "removedFields"),
3940
+ ):
3941
+ fields = diff.get(source_key)
3942
+ if isinstance(fields, list) and fields:
3943
+ changes[result_key] = [
3944
+ str(field.get("name") or field.get("queId") or "unknown")
3945
+ for field in fields
3946
+ if isinstance(field, dict)
3947
+ ]
3948
+ if diff.get("layoutChanged") is True:
3949
+ changes["layoutChanged"] = True
3950
+ if diff.get("configChanged") is True:
3951
+ changes["configChanged"] = True
3952
+
3953
+ actions = {
3954
+ key: value
3955
+ for key, value in {
3956
+ "formWrites": diagnostics.get("writeCount"),
3957
+ "publishes": diagnostics.get("publishCount"),
3958
+ "packageAttachments": diagnostics.get("packageAttachCount"),
3959
+ }.items()
3960
+ if isinstance(value, int) and value > 0
3961
+ }
3962
+ return {
3963
+ "status": "success",
3964
+ "schemaVersion": context.schema_version,
3965
+ "operation": diagnostics["operation"],
3966
+ "appKey": resource["appKey"],
3967
+ "appName": resource["spec"]["appName"],
3968
+ **({"changes": changes} if changes else {}),
3969
+ **({"actions": actions} if actions else {}),
3970
+ **({"warnings": warnings} if warnings else {}),
3971
+ }
3972
+
3973
+ @staticmethod
3974
+ def _failure(error_code: str, message: str, phase: str, *, status: str = "failed", details: JSONObject | None = None, diagnostics: JSONObject | None = None, app_key: str | None = None, warnings: list[JSONObject] | None = None) -> JSONObject:
3975
+ output_diagnostics = deepcopy(diagnostics or {})
3976
+ for key in ("diff", "remainingDiff"):
3977
+ if key in output_diagnostics:
3978
+ output_diagnostics[key] = AppFormReconciler._compact_diff(output_diagnostics[key])
3979
+ result = {
3980
+ "status": status,
3981
+ "error_code": error_code,
3982
+ "recoverable": status != "failed" or error_code in {"PUBLISH_FAILED", "PACKAGE_ATTACH_RESULT_UNKNOWN"},
3983
+ "message": message,
3984
+ **output_diagnostics,
3985
+ "phase": phase,
3986
+ **({"appKey": app_key} if app_key else {}),
3987
+ **({"warnings": warnings} if warnings else {}),
3988
+ }
3989
+ if details:
3990
+ # Apply diagnostics are already exposed at the top level. Keep only
3991
+ # additional error context in details to avoid returning the same
3992
+ # large diagnostic object twice.
3993
+ compact_details = {
3994
+ key: value
3995
+ for key, value in details.items()
3996
+ if key not in result
3997
+ }
3998
+ for key in ("diff", "remainingDiff"):
3999
+ if key in compact_details:
4000
+ compact_details[key] = AppFormReconciler._compact_diff(compact_details[key])
4001
+ if compact_details:
4002
+ result["details"] = compact_details
4003
+ recovery = {
4004
+ "EDIT_VERSION_CONFLICT": "read the latest draft, rebuild the complete declaration, validate, and retry once",
4005
+ "EDIT_FINISHED_FAILED": "if published equals draft, release the current account's edit lock without publishing again",
4006
+ "APP_CREATE_RESULT_UNKNOWN": "do not replay the create declaration without an appKey",
4007
+ "APPLY_READBACK_MISMATCH": "read the latest draft, rebuild the complete declaration, validate, and retry once",
4008
+ "PACKAGE_ATTACH_RESULT_UNKNOWN": "retain appKey, packageId, and the complete spec before recovery",
4009
+ }.get(error_code)
4010
+ if recovery:
4011
+ result["recoveryHint"] = recovery
4012
+ return result
4013
+
4014
+ @staticmethod
4015
+ def _compact_diff(diff: Any) -> Any:
4016
+ if not isinstance(diff, dict):
4017
+ return diff
4018
+ compact = deepcopy(diff)
4019
+ for key in ("added", "updated", "removed"):
4020
+ fields = compact.get(key)
4021
+ if not isinstance(fields, list):
4022
+ continue
4023
+ compact[key] = [
4024
+ str(field.get("name") or field.get("queId") or "unknown") if isinstance(field, dict) else str(field)
4025
+ for field in fields
4026
+ if isinstance(field, (dict, str, int))
4027
+ ]
4028
+ return compact
4029
+
4030
+ @classmethod
4031
+ def _from_error(cls, error: AppFormContractError | AppFormFailure) -> JSONObject:
4032
+ if isinstance(error, AppFormContractError):
4033
+ return cls._failure(error.error_code, error.message, "VALIDATE", details={**error.details, **({"path": error.path} if error.path else {})})
4034
+ return cls._failure(error.error_code, error.message, error.phase, status=error.status, details=error.details)
4035
+
4036
+ @classmethod
4037
+ def _api_failure(cls, error_code: str, error: QingflowApiError | RuntimeError, phase: str, *, status: str = "failed", diagnostics: JSONObject | None = None, app_key: str | None = None, warnings: list[JSONObject] | None = None) -> JSONObject:
4038
+ details = cls._error_details(error)
4039
+ return cls._failure(
4040
+ error_code,
4041
+ str(details.get("message") or error),
4042
+ phase,
4043
+ status=status,
4044
+ details=_without_none(details),
4045
+ diagnostics=diagnostics,
4046
+ app_key=app_key,
4047
+ warnings=warnings,
4048
+ )
4049
+
4050
+
4051
+ def _walk_selectors(value: Any) -> Iterable[JSONObject]:
4052
+ if isinstance(value, dict):
4053
+ if "queId" in value or "name" in value:
4054
+ yield value
4055
+ for item in value.values():
4056
+ yield from _walk_selectors(item)
4057
+ elif isinstance(value, list):
4058
+ for item in value:
4059
+ yield from _walk_selectors(item)
4060
+
4061
+
4062
+ def _snake_selector(value: Any) -> JSONObject:
4063
+ value = value if isinstance(value, dict) else {}
4064
+ return _without_none({"que_id": _positive_int(value.get("queId")), "name": value.get("name")})
4065
+
4066
+
4067
+ def _snake_q_linker(value: JSONObject) -> JSONObject:
4068
+ request = value.get("request") if isinstance(value.get("request"), dict) else {}
4069
+ return {
4070
+ "inputs": [
4071
+ {"field": _snake_selector(item.get("field")), "key": item.get("key"), "source": item.get("source")}
4072
+ for item in value.get("inputs") or []
4073
+ if isinstance(item, dict)
4074
+ ],
4075
+ "request": _without_none(
4076
+ {
4077
+ "url": request.get("url"),
4078
+ "method": request.get("method") or "GET",
4079
+ "headers": deepcopy(request.get("headers") or []),
4080
+ "query_params": deepcopy(request.get("queryParams") or []),
4081
+ "body_type": {"url_encoded": 1, "json": 2, "xml": 3}.get(request.get("bodyType"), 1),
4082
+ "url_encoded_value": deepcopy(request.get("urlEncodedValue") or []),
4083
+ "json_value": request.get("jsonValue"),
4084
+ "xml_value": request.get("xmlValue"),
4085
+ "result_type": {"json": 1, "xml": 2, "text": 3}.get(request.get("resultType"), 1),
4086
+ "auto_trigger": request.get("autoTrigger"),
4087
+ "custom_button_text_enabled": request.get("customButtonTextEnabled"),
4088
+ "custom_button_text": request.get("customButtonText"),
4089
+ "being_insert_value_directly": request.get("insertValueDirectly"),
4090
+ "being_hide_on_form": request.get("hideOnForm"),
4091
+ }
4092
+ ),
4093
+ "outputs": [
4094
+ _without_none({"alias": item.get("alias"), "path": item.get("path"), "alias_id": _positive_int(item.get("aliasId")), "target_field": _snake_selector(item.get("targetField"))})
4095
+ for item in value.get("outputs") or []
4096
+ if isinstance(item, dict)
4097
+ ],
4098
+ }
4099
+
4100
+
4101
+ def _snake_code_block(value: JSONObject) -> JSONObject:
4102
+ return _without_none(
4103
+ {
4104
+ "inputs": [
4105
+ _without_none({"field": _snake_selector(item.get("field")), "var": item.get("var")})
4106
+ for item in value.get("inputs") or []
4107
+ if isinstance(item, dict)
4108
+ ],
4109
+ "code": value.get("code"),
4110
+ "auto_trigger": value.get("autoTrigger"),
4111
+ "custom_button_text_enabled": value.get("customButtonTextEnabled"),
4112
+ "custom_button_text": value.get("customButtonText"),
4113
+ "being_hide_on_form": value.get("hideOnForm"),
4114
+ "outputs": [
4115
+ _without_none({"alias": item.get("alias"), "path": item.get("path"), "alias_id": _positive_int(item.get("aliasId")), "target_field": _snake_selector(item.get("targetField"))})
4116
+ for item in value.get("outputs") or []
4117
+ if isinstance(item, dict)
4118
+ ],
4119
+ }
4120
+ )
4121
+
4122
+
4123
+ def _backend_question_candidates(form: JSONObject) -> list[JSONObject]:
4124
+ result = []
4125
+ for item in form.get("baseQues") or []:
4126
+ if isinstance(item, dict):
4127
+ result.append(item)
4128
+ for row in form.get("formQues") or []:
4129
+ for item in row if isinstance(row, list) else []:
4130
+ if not isinstance(item, dict):
4131
+ continue
4132
+ if _positive_int(item.get("queType")) == 24:
4133
+ for inner in item.get("innerQuestions") or []:
4134
+ result.extend(nested for nested in inner if isinstance(inner, list) and isinstance(nested, dict))
4135
+ else:
4136
+ result.append(item)
4137
+ return result
4138
+
4139
+
4140
+ def _canonical_backend_field_type(field: JSONObject) -> str:
4141
+ internal_type = str(field.get("_app_form_type") or "")
4142
+ if internal_type:
4143
+ return internal_type
4144
+ que_type = _positive_int(field.get("queType"))
4145
+ if que_type == 4:
4146
+ return "datetime" if field.get("dateType") not in {None, 0} else "date"
4147
+ if que_type == 8:
4148
+ return "amount" if field.get("canDecimal") is True else "number"
4149
+ return QUE_TYPE_TO_CANONICAL.get(que_type or -1, "")
4150
+
4151
+
4152
+ def _resolve_backend_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
4153
+ que_id = _positive_int(selector.get("queId"))
4154
+ name = str(selector.get("name") or "")
4155
+ matches = [
4156
+ item
4157
+ for item in candidates
4158
+ if (que_id is None or _positive_int(item.get("queId")) == que_id)
4159
+ and (not name or str(item.get("queTitle") or "") == name)
4160
+ ]
4161
+ if len(matches) != 1:
4162
+ raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one field", "CONTRACT_PRECHECK", details={"selector": selector, "matches": len(matches)})
4163
+ return matches[0]
4164
+
4165
+
4166
+ def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
4167
+ que_id = _positive_int(selector.get("queId"))
4168
+ name = str(selector.get("name") or "")
4169
+ matches = [
4170
+ item
4171
+ for item in candidates
4172
+ if (que_id is None or _positive_int(item.get("queId")) == que_id)
4173
+ and (not name or str(item.get("name") or "") == name)
4174
+ ]
4175
+ if len(matches) != 1:
4176
+ raise AppFormFailure(
4177
+ "INVALID_FIELD_SELECTOR",
4178
+ f"{location} must resolve to exactly one top-level field",
4179
+ "CONTRACT_PRECHECK",
4180
+ details={"selector": selector, "matches": len(matches)},
4181
+ )
4182
+ return matches[0]
4183
+
4184
+
4185
+ def _resolve_internal_selector(selector: JSONObject, fields: list[JSONObject], location: str) -> JSONObject:
4186
+ que_id = _positive_int(selector.get("queId") or selector.get("que_id"))
4187
+ name = str(selector.get("name") or "")
4188
+ matches = [
4189
+ item
4190
+ for item in fields
4191
+ if (que_id is None or _positive_int(item.get("que_id")) == que_id)
4192
+ and (not name or str(item.get("name") or "") == name)
4193
+ ]
4194
+ if len(matches) != 1:
4195
+ raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one field", "COMPILE", details={"selector": selector, "matches": len(matches)})
4196
+ return matches[0]