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

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