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