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