@qingflow-tech/qingflow-cli 1.1.43 → 1.1.44

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