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

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