@qingflow-tech/qingflow-cli 1.1.50-beta.1 → 1.1.50-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +2 -2
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +9 -5
- package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +3 -3
- package/src/qingflow_mcp/app_form_compat.py +3 -0
- package/src/qingflow_mcp/app_form_validator.py +16 -3
- package/src/qingflow_mcp/builder_facade/app_form.py +360 -68
- package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +61 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +5 -9
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +8 -15
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +43 -12
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +146 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +58 -24
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +3 -7
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +8 -0
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +5 -10
|
@@ -45,6 +45,7 @@ QUE_TYPE_TO_CANONICAL = {
|
|
|
45
45
|
11: "single_select",
|
|
46
46
|
12: "multi_select",
|
|
47
47
|
13: "attachment",
|
|
48
|
+
16: "rich_text",
|
|
48
49
|
18: "subtable",
|
|
49
50
|
19: "data_relation",
|
|
50
51
|
20: "q_linker",
|
|
@@ -56,6 +57,7 @@ QUE_TYPE_TO_CANONICAL = {
|
|
|
56
57
|
CANONICAL_TO_QUE_TYPE = {
|
|
57
58
|
"text": 2,
|
|
58
59
|
"long_text": 3,
|
|
60
|
+
"rich_text": 16,
|
|
59
61
|
"number": 8,
|
|
60
62
|
"amount": 8,
|
|
61
63
|
"link": 9,
|
|
@@ -80,8 +82,10 @@ SUBFIELD_TYPES = {
|
|
|
80
82
|
"text",
|
|
81
83
|
"long_text",
|
|
82
84
|
"number",
|
|
85
|
+
"amount",
|
|
83
86
|
"link",
|
|
84
87
|
"date",
|
|
88
|
+
"datetime",
|
|
85
89
|
"phone",
|
|
86
90
|
"email",
|
|
87
91
|
"single_choice",
|
|
@@ -94,6 +98,43 @@ SUBFIELD_TYPES = {
|
|
|
94
98
|
"department",
|
|
95
99
|
}
|
|
96
100
|
CHOICE_TYPES = {"single_choice", "single_select", "multi_select"}
|
|
101
|
+
|
|
102
|
+
# 数据关联筛选 dynamic_range 操作数的动态范围枚举(id → 名称),与后端
|
|
103
|
+
# Constants.DYNAMIC_TYPE(apaas-common Constants.java:4674)单一来源同步:
|
|
104
|
+
# 提取侧 judgeValueDetails.value 即此名称形态(实测 id=7 value="今年"),写回侧
|
|
105
|
+
# 按名称恢复 id——后端 checkRelationMatchRules(FormSvcImpl:10445)对 judgeType=8
|
|
106
|
+
# 校验 judgeValueDetails 非空且 id 非 null,丢失 id 即 400「筛选条件配置有误」。
|
|
107
|
+
# id=22「自定义」在 data_relation 筛选存量里未见(value 形态非固定),不映射名称。
|
|
108
|
+
DYNAMIC_RANGE_NAME_TO_ID = {
|
|
109
|
+
"今日": 1,
|
|
110
|
+
"昨日": 2,
|
|
111
|
+
"本周 (周日 ~ 周六)": 3,
|
|
112
|
+
"上周 (周日 ~ 周六)": 4,
|
|
113
|
+
"本月": 5,
|
|
114
|
+
"上月": 6,
|
|
115
|
+
"今年": 7,
|
|
116
|
+
"去年": 8,
|
|
117
|
+
"近7天": 9,
|
|
118
|
+
"近30天": 10,
|
|
119
|
+
"近90天": 11,
|
|
120
|
+
"本周 (周一 ~ 周日)": 12,
|
|
121
|
+
"上周 (周一 ~ 周日)": 13,
|
|
122
|
+
"明天": 14,
|
|
123
|
+
"下周(周日 ~ 周六)": 15,
|
|
124
|
+
"下月": 16,
|
|
125
|
+
"本季度": 17,
|
|
126
|
+
"下季度": 18,
|
|
127
|
+
"上季度": 19,
|
|
128
|
+
"明年": 20,
|
|
129
|
+
"下周 (周一 ~ 周日)": 21,
|
|
130
|
+
}
|
|
131
|
+
# 名称宽松匹配(剔内嵌空白)兜底:后端数组存在带/不带空格的变体(如 15 与 3/12 形态不一),
|
|
132
|
+
# 归一后仍唯一命中才采用,避免歧义名称误映射。
|
|
133
|
+
_DYNAMIC_RANGE_NORMALIZED = {}
|
|
134
|
+
for _name, _id in DYNAMIC_RANGE_NAME_TO_ID.items():
|
|
135
|
+
_key = "".join(_name.split())
|
|
136
|
+
_DYNAMIC_RANGE_NORMALIZED.setdefault(_key, _id)
|
|
137
|
+
|
|
97
138
|
INTEGRATION_TARGET_TYPES = {
|
|
98
139
|
"text",
|
|
99
140
|
"long_text",
|
|
@@ -662,8 +703,9 @@ class AppFormNormalizer:
|
|
|
662
703
|
]
|
|
663
704
|
if fields:
|
|
664
705
|
rows.append({"fields": fields})
|
|
665
|
-
|
|
666
|
-
|
|
706
|
+
# 空 section 是合法存量形态:后端 updateFormInfo 对空 innerQuestions 仅产出
|
|
707
|
+
# 零行 draft_section_question(段落本身作为 queType=24 question 照常保存),
|
|
708
|
+
# 读面 getFormInfoByAppKey 同样返回空 innerQuestions——按声明原样写,空↔空稳定。
|
|
667
709
|
body.append(
|
|
668
710
|
{
|
|
669
711
|
"kind": "section",
|
|
@@ -919,8 +961,6 @@ class AppFormNormalizer:
|
|
|
919
961
|
reference = question.get("referenceConfig") if isinstance(question.get("referenceConfig"), dict) else {}
|
|
920
962
|
refer_questions = [item for item in reference.get("referQuestions") or [] if isinstance(item, dict)]
|
|
921
963
|
display_id = _positive_int(reference.get("referQueId"))
|
|
922
|
-
display = next((item for item in refer_questions if _positive_int(item.get("queId")) == display_id), None)
|
|
923
|
-
visible = [item for item in refer_questions if _positive_int(item.get("queAuth")) != 2]
|
|
924
964
|
target_app_key = str(reference.get("referAppKey") or "")
|
|
925
965
|
if not target_app_key.strip():
|
|
926
966
|
raise AppFormFailure(
|
|
@@ -932,25 +972,37 @@ class AppFormNormalizer:
|
|
|
932
972
|
relation_config: JSONObject = {
|
|
933
973
|
"targetAppKey": target_app_key,
|
|
934
974
|
"relationMode": "multiple" if _positive_int(reference.get("optionalDataNum")) not in {None, 1} else "single",
|
|
935
|
-
"
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
975
|
+
"displayFields": [
|
|
976
|
+
{
|
|
977
|
+
"field": _require_selector(
|
|
978
|
+
item.get("queId"),
|
|
979
|
+
item.get("queTitle"),
|
|
980
|
+
message="relation display field cannot be normalized",
|
|
981
|
+
details={"queId": que_id, "path": "/config/displayFields"},
|
|
982
|
+
),
|
|
983
|
+
"auth": {1: "editable", 2: "hidden", 3: "visible"}.get(
|
|
984
|
+
_positive_int(item.get("queAuth")), "visible"
|
|
985
|
+
),
|
|
986
|
+
}
|
|
987
|
+
for item in sorted(
|
|
988
|
+
refer_questions,
|
|
989
|
+
key=lambda item: (
|
|
990
|
+
item.get("ordinal") is None,
|
|
991
|
+
item.get("ordinal") if isinstance(item.get("ordinal"), int) else 0,
|
|
992
|
+
),
|
|
947
993
|
)
|
|
948
|
-
for item in visible
|
|
949
994
|
],
|
|
950
995
|
"beingDefaultFormulaAutoFillEnabled": reference.get("beingDefaultFormulaAutoFillEnabled"),
|
|
951
996
|
"defaultValueMatchRules": reference.get("defaultValueMatchRules"),
|
|
952
997
|
"defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
|
|
953
998
|
}
|
|
999
|
+
if display_id is not None:
|
|
1000
|
+
relation_config["twoWayBoundField"] = _require_selector(
|
|
1001
|
+
display_id,
|
|
1002
|
+
next((item.get("queTitle") for item in refer_questions if _positive_int(item.get("queId")) == display_id), None),
|
|
1003
|
+
message="relation two-way bound field cannot be normalized",
|
|
1004
|
+
details={"queId": que_id, "path": "/config/twoWayBoundField"},
|
|
1005
|
+
)
|
|
954
1006
|
if supports_reference_default_formula:
|
|
955
1007
|
formula = reference.get("defaultValueFormula")
|
|
956
1008
|
relation_config["defaultValueFormula"] = formula if not isinstance(formula, str) or formula.strip() else None
|
|
@@ -1292,7 +1344,12 @@ class AppFormNormalizer:
|
|
|
1292
1344
|
que_id = _positive_int(question.get("queId"))
|
|
1293
1345
|
relation = next((item for item in relations if _positive_int(item.get("queId")) == que_id), None)
|
|
1294
1346
|
if relation is None:
|
|
1295
|
-
|
|
1347
|
+
# 后端 GET 读面会过滤 displayed 目标缺失的 relation 行(FormSvcImpl#getQuestionRelationsBeans
|
|
1348
|
+
# 对 displayedForm/displayedQuestion 为 null 的行 continue,40013 存量实证:displayed_que_id=0
|
|
1349
|
+
# 共 264 行、displayed_form/que 闭包外 98/116 行)。此时字段本体仍在 formQues 中,
|
|
1350
|
+
# 但关联配置不可回读——保留字段并回读空 config(source 省略);写回侧不重建该字段的
|
|
1351
|
+
# relation 行,与后端读面行为对称。库表级 diff 将此类行归入「历史数据保全」单列。
|
|
1352
|
+
return {}
|
|
1296
1353
|
relation_type = _positive_int(relation.get("relationType")) or 1
|
|
1297
1354
|
displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
|
|
1298
1355
|
alias = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
|
|
@@ -1393,15 +1450,25 @@ class AppFormNormalizer:
|
|
|
1393
1450
|
if isinstance(judge_type, bool) or not isinstance(judge_type, int) or judge_type not in BACKEND_TO_JUDGE_TYPE:
|
|
1394
1451
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation judgeType cannot be normalized", "NORMALIZE", details={"queId": que_id, "judgeType": judge_type})
|
|
1395
1452
|
condition: JSONObject = {
|
|
1396
|
-
"sourceField":
|
|
1453
|
+
"sourceField": _selector(value.get("queId"), value.get("queTitle")) or None,
|
|
1397
1454
|
"operator": BACKEND_TO_JUDGE_TYPE[judge_type],
|
|
1398
1455
|
}
|
|
1456
|
+
if condition["sourceField"] is None:
|
|
1457
|
+
# 存量读回形态:后端反解析出的条件源字段缺失(queId=0 且无名称,原公式
|
|
1458
|
+
# 如 #queId_0$)。省略 sourceField 保留 operator/operand 语义,写回时以
|
|
1459
|
+
# queId=0 透传(后端 matchRulesToFormula 重新生成 #queId_0$,往返对称)。
|
|
1460
|
+
del condition["sourceField"]
|
|
1399
1461
|
match_type = _positive_int(value.get("matchType")) or 1
|
|
1400
1462
|
if match_type == 1:
|
|
1401
1463
|
details = value.get("judgeValueDetails")
|
|
1402
1464
|
if isinstance(details, list):
|
|
1465
|
+
# value 为空串且 id 非空 → 悬空选项引用(选项已被删,id 仍指向已删
|
|
1466
|
+
# optId):以 id 形态保留(写回时透传为悬空引用,往返对称;约束 2:
|
|
1467
|
+
# 语义保真优先于「apply 通过」——不产出不可写回的空串声明)。
|
|
1403
1468
|
literal_values = [
|
|
1404
|
-
str(item
|
|
1469
|
+
str(item["id"])
|
|
1470
|
+
if item.get("value") in (None, "") and item.get("id") is not None
|
|
1471
|
+
else str(item.get("value"))
|
|
1405
1472
|
for item in details
|
|
1406
1473
|
if isinstance(item, dict) and (item.get("value") is not None or item.get("id") is not None)
|
|
1407
1474
|
]
|
|
@@ -1652,7 +1719,10 @@ class AppFormCompiler:
|
|
|
1652
1719
|
selected = [field for field in target_fields if selector_id is not None and _positive_int(field.get("queId")) == selector_id]
|
|
1653
1720
|
if len(selected) == 1:
|
|
1654
1721
|
target_spec[key] = _selector(selected[0].get("queId"), selected[0].get("name"))
|
|
1655
|
-
|
|
1722
|
+
# target 层面同样放行 dataTitleField=null:后端写链
|
|
1723
|
+
# FormSvcImpl.normalizeDataTitleQueId 会兑底到第一个可用字段,
|
|
1724
|
+
# current 与 declared 均为 null 时 target 保持 null 合法。
|
|
1725
|
+
data_title, data_cover = self._validate_target(target_spec, allow_missing_data_title=True)
|
|
1656
1726
|
target_spec["dataTitleField"] = _selector(data_title.get("queId"), data_title.get("name")) if data_title is not None else None
|
|
1657
1727
|
target_spec["dataCoverField"] = _selector(data_cover.get("queId"), data_cover.get("name")) if data_cover is not None else None
|
|
1658
1728
|
return {"apiVersion": context.api_version, "appKey": app_key, "spec": target_spec}
|
|
@@ -1978,10 +2048,13 @@ class AppFormCompiler:
|
|
|
1978
2048
|
if any(present) and not all(present):
|
|
1979
2049
|
raise AppFormFailure("INVALID_ROW_WIDTH", "all fields in a row must provide width together", "CONTRACT_PRECHECK")
|
|
1980
2050
|
if all(present):
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2051
|
+
# 宽度取值:任意 1-100 整数(后端 html_width 无取值校验,存量存在
|
|
2052
|
+
# 33/34/20/30 等值,33+33+34=100 的三等分历史布局);仅保留
|
|
2053
|
+
# 「全有或全无」约束。行总和不强制 =100:后端写链无总和校验
|
|
2054
|
+
# (QuestionConstant.DEFAULT_QUE_WIDTH=100 仅为新建默认值),存量
|
|
2055
|
+
# 数据存在字段删除后留白的行,get 读出的原布局必须能原样 apply。
|
|
2056
|
+
if any(not (isinstance(field.get("width"), int) and 1 <= field["width"] <= 100) for field in fields):
|
|
2057
|
+
raise AppFormFailure("INVALID_ROW_WIDTH", "field widths must be an integer between 1 and 100", "CONTRACT_PRECHECK")
|
|
1985
2058
|
return
|
|
1986
2059
|
widths = {
|
|
1987
2060
|
1: [100],
|
|
@@ -2017,7 +2090,11 @@ class AppFormCompiler:
|
|
|
2017
2090
|
title_selector = spec.get("dataTitleField")
|
|
2018
2091
|
data_title: JSONObject | None = None
|
|
2019
2092
|
if fields:
|
|
2020
|
-
|
|
2093
|
+
# dataTitleField=null(键存在值为空)与省略键同权放行:后端写链
|
|
2094
|
+
# FormSvcImpl.normalizeDataTitleQueId 对 null 自动 fallback 到第一个
|
|
2095
|
+
# 可用字段(findDefaultDataTitleQueId),apply 侧缺省完全合法;
|
|
2096
|
+
# get 读面在后端 dataTitleQueId 未设置时原样输出 null,round-trip 必须接受。
|
|
2097
|
+
if title_selector is None and allow_missing_data_title:
|
|
2021
2098
|
pass
|
|
2022
2099
|
elif not isinstance(title_selector, dict):
|
|
2023
2100
|
raise AppFormFailure(
|
|
@@ -2124,7 +2201,8 @@ class AppFormCompiler:
|
|
|
2124
2201
|
rows.append([section])
|
|
2125
2202
|
payload["formQues"] = rows
|
|
2126
2203
|
self._validate_unique_temp_ids(rows)
|
|
2127
|
-
|
|
2204
|
+
target_data_title_selector = target["spec"]["dataTitleField"]
|
|
2205
|
+
data_title = _resolve_canonical_selector(target_data_title_selector, target_fields, "dataTitleField") if target_fields and isinstance(target_data_title_selector, dict) else None
|
|
2128
2206
|
data_cover_selector = target["spec"].get("dataCoverField")
|
|
2129
2207
|
data_cover = _resolve_canonical_selector(data_cover_selector, target_fields, "dataCoverField") if isinstance(data_cover_selector, dict) else None
|
|
2130
2208
|
payload["dataTitleQueId"] = self._field_runtime_id(data_title, internal_fields)
|
|
@@ -2331,6 +2409,16 @@ class AppFormCompiler:
|
|
|
2331
2409
|
{
|
|
2332
2410
|
"target_app_key": config.get("targetAppKey"),
|
|
2333
2411
|
"relation_mode": config.get("relationMode") or "single",
|
|
2412
|
+
"two_way_bound_field": _snake_selector(config.get("twoWayBoundField")),
|
|
2413
|
+
"display_fields": [
|
|
2414
|
+
{
|
|
2415
|
+
"field": _snake_selector(item.get("field")),
|
|
2416
|
+
"auth": item.get("auth") or "visible",
|
|
2417
|
+
}
|
|
2418
|
+
for item in config.get("displayFields") or []
|
|
2419
|
+
if isinstance(item, dict)
|
|
2420
|
+
],
|
|
2421
|
+
# Deprecated input names stay available to the compiler compatibility translator.
|
|
2334
2422
|
"display_field": _snake_selector(config.get("displayField")),
|
|
2335
2423
|
"visible_fields": [_snake_selector(item) for item in config.get("visibleFields") or []],
|
|
2336
2424
|
"reference_default_formula_auto_fill": config.get("beingDefaultFormulaAutoFillEnabled"),
|
|
@@ -2349,7 +2437,7 @@ class AppFormCompiler:
|
|
|
2349
2437
|
def _question(self, profile: str, app_key: str, field: JSONObject, internal: JSONObject, temp_id: int, relations: list[JSONObject]) -> JSONObject:
|
|
2350
2438
|
field_type = _field_type(field)
|
|
2351
2439
|
que_type = CANONICAL_TO_QUE_TYPE[field_type]
|
|
2352
|
-
build_type = "boolean" if field_type == "single_choice" else "text" if field_type in {"link", "data_relation"} else field_type
|
|
2440
|
+
build_type = "boolean" if field_type == "single_choice" else "text" if field_type in {"link", "data_relation", "rich_text"} else field_type
|
|
2353
2441
|
built_internal = deepcopy(internal)
|
|
2354
2442
|
built_internal["type"] = build_type
|
|
2355
2443
|
if build_type == "subtable":
|
|
@@ -2506,18 +2594,24 @@ class AppFormCompiler:
|
|
|
2506
2594
|
config = field.get("config") or {}
|
|
2507
2595
|
related_que_id = _positive_int(internal.get("data_relation_related_que_id"))
|
|
2508
2596
|
if related_que_id is None:
|
|
2509
|
-
|
|
2597
|
+
# 存量读回形态(后端 GET 不回传该字段的 relation):模板里保留着后端
|
|
2598
|
+
# 镜像字段(relatedQueId/queOriginType),透传保持字段级语义不失真。
|
|
2599
|
+
template_related = _positive_int((template or {}).get("relatedQueId"))
|
|
2600
|
+
if template_related is not None:
|
|
2601
|
+
question["relatedQueId"] = template_related
|
|
2602
|
+
else:
|
|
2603
|
+
question.pop("relatedQueId", None)
|
|
2510
2604
|
else:
|
|
2511
2605
|
question["relatedQueId"] = related_que_id
|
|
2512
2606
|
que_origin_type = _positive_int(internal.get("data_relation_origin_type"))
|
|
2513
2607
|
if que_origin_type is None:
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
"
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2608
|
+
template_origin = _positive_int((template or {}).get("queOriginType"))
|
|
2609
|
+
if template_origin is not None:
|
|
2610
|
+
question["queOriginType"] = template_origin
|
|
2611
|
+
else:
|
|
2612
|
+
question.pop("queOriginType", None)
|
|
2613
|
+
else:
|
|
2614
|
+
question["queOriginType"] = que_origin_type
|
|
2521
2615
|
aggregation = config.get("aggregation")
|
|
2522
2616
|
if aggregation is None:
|
|
2523
2617
|
question.pop("aggreType", None)
|
|
@@ -2534,21 +2628,75 @@ class AppFormCompiler:
|
|
|
2534
2628
|
target_app_key = str(config.get("targetAppKey") or "")
|
|
2535
2629
|
target_form = self._read_form(profile, target_app_key, being_draft=False)
|
|
2536
2630
|
candidates = _backend_question_candidates(target_form)
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2631
|
+
explicit_display = isinstance(config.get("displayFields"), list)
|
|
2632
|
+
deprecated_keys = [
|
|
2633
|
+
key
|
|
2634
|
+
for key in ("displayField", "display_field", "visibleFields", "visible_fields")
|
|
2635
|
+
if key in config and config.get(key) is not None
|
|
2636
|
+
]
|
|
2637
|
+
if explicit_display and deprecated_keys:
|
|
2638
|
+
raise AppFormFailure(
|
|
2639
|
+
"INVALID_SPEC",
|
|
2640
|
+
"relation cannot mix displayFields with deprecated displayField/visibleFields",
|
|
2641
|
+
"CONTRACT_PRECHECK",
|
|
2642
|
+
)
|
|
2643
|
+
if explicit_display:
|
|
2644
|
+
# 显式 displayFields(含空列表)按声明原样写:空列表 = 无展示字段,round-trip 空↔空稳定。
|
|
2645
|
+
# 后端语义(2026-09-23 沿源码定案):FormQuestionSvcImpl.creteOrUpdateReference 对空
|
|
2646
|
+
# referQuestions 走 CollectionUtils.isNotEmpty 跳过插入,读面 getReferenceConfig
|
|
2647
|
+
# 同样容忍空——空列表是合法存量形态(双向绑定 referQueId 非空、未配展示字段)。
|
|
2648
|
+
display_entries = [item for item in config["displayFields"] if isinstance(item, dict)]
|
|
2649
|
+
elif deprecated_keys:
|
|
2650
|
+
# v1alpha1 compatibility: the old displayField was both the main display field and the binding field.
|
|
2651
|
+
legacy_display = config.get("displayField") or config.get("display_field") or {}
|
|
2652
|
+
legacy_visible = config.get("visibleFields") or config.get("visible_fields") or []
|
|
2653
|
+
display_entries = [{"field": legacy_display, "auth": "visible"}]
|
|
2654
|
+
display_entries.extend({"field": item, "auth": "visible"} for item in legacy_visible if isinstance(item, dict))
|
|
2655
|
+
else:
|
|
2656
|
+
raise AppFormFailure(
|
|
2657
|
+
"INVALID_SPEC",
|
|
2658
|
+
"relation config requires displayFields (explicit empty list allowed for fields without display fields)",
|
|
2659
|
+
"CONTRACT_PRECHECK",
|
|
2660
|
+
)
|
|
2661
|
+
resolved_display = []
|
|
2662
|
+
seen_ids = set()
|
|
2663
|
+
for index, entry in enumerate(display_entries):
|
|
2664
|
+
selector = entry.get("field") if isinstance(entry, dict) else {}
|
|
2665
|
+
item = _resolve_backend_selector(selector or {}, candidates, f"displayFields[{index}].field")
|
|
2666
|
+
if item["queId"] in seen_ids:
|
|
2667
|
+
raise AppFormFailure(
|
|
2668
|
+
"INVALID_SPEC",
|
|
2669
|
+
f"displayFields[{index}].field resolves to duplicate queId {item['queId']}",
|
|
2670
|
+
"CONTRACT_PRECHECK",
|
|
2671
|
+
)
|
|
2672
|
+
seen_ids.add(item["queId"])
|
|
2673
|
+
auth = (entry.get("auth") if isinstance(entry, dict) else None) or "visible"
|
|
2674
|
+
auth_code = {"editable": 1, "hidden": 2, "visible": 3}.get(auth)
|
|
2675
|
+
if auth_code is None:
|
|
2676
|
+
raise AppFormFailure("INVALID_SPEC", "displayFields auth must be editable, hidden, or visible", "CONTRACT_PRECHECK")
|
|
2677
|
+
resolved_display.append((item, auth_code))
|
|
2678
|
+
two_way = config.get("twoWayBoundField") or config.get("two_way_bound_field")
|
|
2679
|
+
if two_way is None and (config.get("displayField") or config.get("display_field")):
|
|
2680
|
+
two_way = config.get("displayField") or config.get("display_field")
|
|
2681
|
+
resolved_two_way = _resolve_backend_selector(two_way, candidates, "twoWayBoundField") if two_way else None
|
|
2682
|
+
template_auth = (template or {}).get("referAuthQues") if isinstance((template or {}).get("referAuthQues"), list) else None
|
|
2541
2683
|
result = deepcopy(template or {})
|
|
2542
2684
|
result.update(
|
|
2543
2685
|
{
|
|
2544
2686
|
"referAppKey": target_app_key,
|
|
2545
|
-
"referQueId":
|
|
2687
|
+
"referQueId": resolved_two_way.get("queId") if resolved_two_way else None,
|
|
2546
2688
|
"optionalDataNum": 1 if config.get("relationMode", "single") == "single" else 2,
|
|
2547
2689
|
"referQuestions": [
|
|
2548
|
-
|
|
2549
|
-
|
|
2690
|
+
{"queId": item["queId"], "queTitle": item["queTitle"], "queType": item["queType"], "queAuth": auth, "ordinal": index}
|
|
2691
|
+
for index, (item, auth) in enumerate(resolved_display)
|
|
2550
2692
|
],
|
|
2551
|
-
|
|
2693
|
+
# 选择引用数据时的列表权限轴(draft_reference_que_auth,含 section/parent 结构)
|
|
2694
|
+
# 与展示字段轴(referQuestions)独立;声明未建模该轴,已有配置原样透传
|
|
2695
|
+
# (空列表同样原样保留——被默认值覆盖会改变存量语义,历史数据保全红线),
|
|
2696
|
+
# 仅新建字段(无模板)按展示字段默认 editable,避免删除重建丢权限配置。
|
|
2697
|
+
"referAuthQues": deepcopy(template_auth)
|
|
2698
|
+
if template_auth is not None
|
|
2699
|
+
else [{"queId": item["queId"], "queAuth": 1} for item, _ in resolved_display],
|
|
2552
2700
|
"beingDefaultFormulaAutoFillEnabled": config.get("beingDefaultFormulaAutoFillEnabled"),
|
|
2553
2701
|
"defaultValueFormula": config.get("defaultValueFormula"),
|
|
2554
2702
|
"defaultValueMatchRules": deepcopy(config.get("defaultValueMatchRules")),
|
|
@@ -2627,6 +2775,13 @@ class AppFormCompiler:
|
|
|
2627
2775
|
continue
|
|
2628
2776
|
config = field.get("config") or {}
|
|
2629
2777
|
source = config.get("source") or {}
|
|
2778
|
+
if not source.get("kind"):
|
|
2779
|
+
# 存量读回形态:后端 GET 不回传该字段的关联配置(displayed 目标缺失被
|
|
2780
|
+
# getQuestionRelationsBeans continue 过滤)。normalizer 提取为无 source 的
|
|
2781
|
+
# 空 config;写回侧保持对称——不重建 relation 行(后端删除重建后自然无行)。
|
|
2782
|
+
internal.pop("data_relation_related_que_id", None)
|
|
2783
|
+
internal.pop("data_relation_origin_type", None)
|
|
2784
|
+
continue
|
|
2630
2785
|
field_id = _positive_int(internal.get("que_id")) or int(internal.get("que_temp_id") or 0) or None
|
|
2631
2786
|
relation: JSONObject = {
|
|
2632
2787
|
"queId": field_id,
|
|
@@ -2758,25 +2913,32 @@ class AppFormCompiler:
|
|
|
2758
2913
|
current_fields: list[JSONObject],
|
|
2759
2914
|
source_fields_are_backend: bool,
|
|
2760
2915
|
) -> JSONObject:
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2916
|
+
source_selector = condition.get("sourceField") or {}
|
|
2917
|
+
if source_selector.get("queId") or str(source_selector.get("name") or "").strip():
|
|
2918
|
+
source = cls._resolve_data_relation_source_field(
|
|
2919
|
+
source_selector,
|
|
2920
|
+
source_fields=source_fields,
|
|
2921
|
+
source_fields_are_backend=source_fields_are_backend,
|
|
2922
|
+
location="sourceField",
|
|
2923
|
+
)
|
|
2924
|
+
else:
|
|
2925
|
+
# 存量读回形态:无源字段(原公式 #queId_0$)。以 queId=0 透传,后端
|
|
2926
|
+
# matchRulesToFormula 重新生成 #queId_0$,与原始库内公式往返对称。
|
|
2927
|
+
source = {}
|
|
2767
2928
|
operand = condition.get("operand") or {}
|
|
2768
2929
|
operator = str(condition.get("operator") or "")
|
|
2769
2930
|
cls._validate_data_relation_operator(source, operator, operand)
|
|
2770
2931
|
payload: JSONObject = {
|
|
2771
|
-
"queId": cls._data_relation_field_ref(source),
|
|
2772
|
-
"queTitle": cls._data_relation_field_name(source),
|
|
2773
|
-
"queType": cls._data_relation_que_type(source),
|
|
2932
|
+
"queId": cls._data_relation_field_ref(source) or 0,
|
|
2933
|
+
"queTitle": cls._data_relation_field_name(source) or None,
|
|
2934
|
+
"queType": cls._data_relation_que_type(source) if source else None,
|
|
2774
2935
|
"judgeType": JUDGE_TYPE_TO_BACKEND[operator],
|
|
2775
2936
|
}
|
|
2776
2937
|
if operand.get("kind") == "literal":
|
|
2777
2938
|
judge_values, judge_value_details = cls._data_relation_literal_values(
|
|
2778
2939
|
source,
|
|
2779
2940
|
deepcopy(operand.get("values") or []),
|
|
2941
|
+
operator=operator,
|
|
2780
2942
|
)
|
|
2781
2943
|
payload.update(
|
|
2782
2944
|
{
|
|
@@ -2802,7 +2964,7 @@ class AppFormCompiler:
|
|
|
2802
2964
|
return payload
|
|
2803
2965
|
|
|
2804
2966
|
@staticmethod
|
|
2805
|
-
def _data_relation_literal_values(source: JSONObject, values: list[Any]) -> tuple[list[str], list[JSONObject]]:
|
|
2967
|
+
def _data_relation_literal_values(source: JSONObject, values: list[Any], *, operator: str = "") -> tuple[list[str], list[JSONObject]]:
|
|
2806
2968
|
field_type = _canonical_backend_field_type(source)
|
|
2807
2969
|
options = [item for item in source.get("options") or [] if isinstance(item, dict)]
|
|
2808
2970
|
option_by_name = {
|
|
@@ -2832,6 +2994,13 @@ class AppFormCompiler:
|
|
|
2832
2994
|
if field_type in CHOICE_TYPES and options:
|
|
2833
2995
|
option = option_by_name.get(text) or option_by_id.get(text)
|
|
2834
2996
|
if option is None:
|
|
2997
|
+
# 悬空选项引用(选项已被删,id 仍指向已删 optId):透传为存量
|
|
2998
|
+
# 同构的悬空引用(judgeValues=[optId], details=[{id,value:""}]),
|
|
2999
|
+
# 后端接受该形态(生产库存量即如此),往返对称;不是选项校验失败。
|
|
3000
|
+
if text.isdigit() and _positive_int(text) is not None:
|
|
3001
|
+
judge_values.append(text)
|
|
3002
|
+
details.append({"id": int(text), "value": ""})
|
|
3003
|
+
continue
|
|
2835
3004
|
raise AppFormFailure(
|
|
2836
3005
|
"UNSUPPORTED_FIELD_CONFIG",
|
|
2837
3006
|
f"literal value {text!r} is not an option of data_relation source field "
|
|
@@ -2844,6 +3013,28 @@ class AppFormCompiler:
|
|
|
2844
3013
|
judge_values.append(str(option_id) if option_id is not None else option_name)
|
|
2845
3014
|
details.append(_without_none({"id": option_id, "value": option_name}))
|
|
2846
3015
|
continue
|
|
3016
|
+
if operator == "dynamic_range":
|
|
3017
|
+
# 动态范围(judgeType=8):后端 checkRelationMatchRules 要求
|
|
3018
|
+
# judgeValueDetails 非空且 id 非_null;提取侧 values 取自
|
|
3019
|
+
# judgeValueDetails.value(动态范围名称),写回必须恢复 id——
|
|
3020
|
+
# 名称→id 映射对齐 Constants.DYNAMIC_TYPE;数字字符串直通(id 形态)。
|
|
3021
|
+
range_id = DYNAMIC_RANGE_NAME_TO_ID.get(text)
|
|
3022
|
+
if range_id is None and text.isdigit():
|
|
3023
|
+
range_id = _positive_int(text)
|
|
3024
|
+
if range_id is None:
|
|
3025
|
+
# 宽松匹配(剔内嵌空白)兜底:数组存在带/不带空格变体
|
|
3026
|
+
range_id = _DYNAMIC_RANGE_NORMALIZED.get("".join(text.split()))
|
|
3027
|
+
if range_id is None:
|
|
3028
|
+
raise AppFormFailure(
|
|
3029
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
3030
|
+
f"literal value {text!r} is not a known dynamic range of data_relation "
|
|
3031
|
+
f"source field {AppFormCompiler._data_relation_field_name(source)!r}",
|
|
3032
|
+
"CONTRACT_PRECHECK",
|
|
3033
|
+
details={"value": text, "allowedValues": sorted(DYNAMIC_RANGE_NAME_TO_ID)},
|
|
3034
|
+
)
|
|
3035
|
+
judge_values.append(str(range_id))
|
|
3036
|
+
details.append({"id": range_id, "value": text})
|
|
3037
|
+
continue
|
|
2847
3038
|
judge_values.append(text)
|
|
2848
3039
|
details.append({"value": text})
|
|
2849
3040
|
return judge_values, details
|
|
@@ -2866,7 +3057,13 @@ class AppFormCompiler:
|
|
|
2866
3057
|
|
|
2867
3058
|
@staticmethod
|
|
2868
3059
|
def _data_relation_field_name(field: JSONObject) -> str:
|
|
2869
|
-
|
|
3060
|
+
# matchRules 筛选源字段的 queTitle 在存量里对表格子字段带父表名前缀
|
|
3061
|
+
# (「父表·子字段」):写回按同形态组装,与提取(后端拼接的 queTitle)对称。
|
|
3062
|
+
title = str(field.get("queTitle") or field.get("name") or "")
|
|
3063
|
+
sup_title = str(field.get("_sup_que_title") or "")
|
|
3064
|
+
if sup_title and title:
|
|
3065
|
+
return f"{sup_title}·{title}"
|
|
3066
|
+
return title
|
|
2870
3067
|
|
|
2871
3068
|
@staticmethod
|
|
2872
3069
|
def _data_relation_que_type(field: JSONObject) -> int:
|
|
@@ -2924,7 +3121,11 @@ class AppFormCompiler:
|
|
|
2924
3121
|
organization_ops = common | {"belongs_to", "not_belongs_to", "subordinate_to", "not_subordinate_to", "subordinate_included", "subordinate_not_included", "contains_any", "contains_all"}
|
|
2925
3122
|
field_type = _canonical_backend_field_type(source)
|
|
2926
3123
|
if field_type in {"number", "amount", "date", "datetime"}:
|
|
2927
|
-
|
|
3124
|
+
# date/datetime 补 belongs_to/not_belongs_to:后端 judgeType=13(JUDGE_BELONG_TO)
|
|
3125
|
+
# 对时间字段的存量语义是「属于时间范围」(judgeValues 形如
|
|
3126
|
+
# "2025-01-30 14:20:59~2026-01-30 14:20:59"),与组织字段的属于语义
|
|
3127
|
+
# 共用同一 judgeType——存量实证(如 82d9379e 申请时间筛选)。
|
|
3128
|
+
allowed = ordered_ops | ({"belongs_to", "not_belongs_to"} if field_type in {"date", "datetime"} else set())
|
|
2928
3129
|
elif field_type in {"member", "department"}:
|
|
2929
3130
|
allowed = organization_ops
|
|
2930
3131
|
else:
|
|
@@ -3202,7 +3403,17 @@ class AppFormReconciler:
|
|
|
3202
3403
|
)
|
|
3203
3404
|
publish_needed = not _published_equal(current, published)
|
|
3204
3405
|
except (QingflowApiError, RuntimeError, AppFormFailure) as error:
|
|
3205
|
-
|
|
3406
|
+
if self._is_published_metadata_gap(error):
|
|
3407
|
+
# published 侧元数据缺行(段落归属/自定义编号配置):发布动作
|
|
3408
|
+
# 会从 draft 侧复制自愈,推定需要发布而不是失败。
|
|
3409
|
+
publish_needed = True
|
|
3410
|
+
warnings.append({"code": "PUBLISHED_METADATA_GAP", "detail": str(error.message)})
|
|
3411
|
+
elif self._is_form_not_published(error):
|
|
3412
|
+
# 应用从未发布(40026):published 域不存在,对比跳过——
|
|
3413
|
+
# draft 一致即达标,不擅自发布从未发布的应用。
|
|
3414
|
+
warnings.append({"code": "APP_NOT_PUBLISHED", "detail": "published comparison skipped: application was never published"})
|
|
3415
|
+
else:
|
|
3416
|
+
raise AppFormFailure("PUBLISHED_FORM_READ_FAILED", str(error), "PRECHECK_PUBLISHED_READ") from error
|
|
3206
3417
|
diagnostics["diff"] = self._diff(current, target)
|
|
3207
3418
|
|
|
3208
3419
|
if not form_changed and not publish_needed:
|
|
@@ -3303,7 +3514,9 @@ class AppFormReconciler:
|
|
|
3303
3514
|
details={"remainingDiff": deepcopy(diagnostics["remainingDiff"])},
|
|
3304
3515
|
)
|
|
3305
3516
|
|
|
3306
|
-
|
|
3517
|
+
# precheck 阶段推定的 publish_needed(published 元数据缺口)必须保留:
|
|
3518
|
+
# 发布自愈前任何一次 published 读都会失败,重读不会需得新证据。
|
|
3519
|
+
publish_needed = created or wrote_form or publish_needed
|
|
3307
3520
|
if not publish_needed:
|
|
3308
3521
|
try:
|
|
3309
3522
|
published = self._published_resource(
|
|
@@ -3315,14 +3528,20 @@ class AppFormReconciler:
|
|
|
3315
3528
|
diagnostics["published"] = _published_equal(draft, published)
|
|
3316
3529
|
publish_needed = not diagnostics["published"]
|
|
3317
3530
|
except (QingflowApiError, RuntimeError, AppFormFailure) as error:
|
|
3318
|
-
self.
|
|
3319
|
-
|
|
3320
|
-
"
|
|
3321
|
-
|
|
3322
|
-
"
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3531
|
+
if self._is_published_metadata_gap(error):
|
|
3532
|
+
publish_needed = True
|
|
3533
|
+
warnings.append({"code": "PUBLISHED_METADATA_GAP", "detail": str(error.message)})
|
|
3534
|
+
elif self._is_form_not_published(error):
|
|
3535
|
+
warnings.append({"code": "APP_NOT_PUBLISHED", "detail": "published comparison skipped: application was never published"})
|
|
3536
|
+
else:
|
|
3537
|
+
self._finish_edit(profile, app_key, edit_version_no, diagnostics)
|
|
3538
|
+
raise AppFormFailure(
|
|
3539
|
+
"PUBLISHED_FORM_READ_FAILED",
|
|
3540
|
+
str(error),
|
|
3541
|
+
"AUTHORITATIVE_PUBLISHED_READ",
|
|
3542
|
+
status="partial_success",
|
|
3543
|
+
details=diagnostics,
|
|
3544
|
+
) from error
|
|
3326
3545
|
|
|
3327
3546
|
if publish_needed:
|
|
3328
3547
|
self._publish_and_verify(
|
|
@@ -3704,6 +3923,52 @@ class AppFormReconciler:
|
|
|
3704
3923
|
diagnostics["publishResponseAccepted"] = True
|
|
3705
3924
|
diagnostics["publishCount"] += 1
|
|
3706
3925
|
|
|
3926
|
+
@staticmethod
|
|
3927
|
+
def _is_published_metadata_gap(error: Exception) -> bool:
|
|
3928
|
+
"""Published 读面因 published 侧元数据缺行而无法归一化的已知形态。
|
|
3929
|
+
|
|
3930
|
+
后端发布链路(FormSvcImpl.publish)把 draft 侧元数据复制到 published 侧
|
|
3931
|
+
(section_question 先删后建、form_custom_serial_num_set 按 id 三分离)。
|
|
3932
|
+
published 侧对应表缺行时(dataset 闭包漏导/迁移中间态),published GET 呈现:
|
|
3933
|
+
- 段落(queType=24)与段内字段同行、innerQuestions 为空
|
|
3934
|
+
→ UNSUPPORTED_FIELD_TYPE queType=24(段落归属信息在 published 侧丢失);
|
|
3935
|
+
- serialNumType=2 但 serialNumConfig 无组件
|
|
3936
|
+
→ UNSUPPORTED_FIELD_CONFIG "custom serial number has no components"。
|
|
3937
|
+
属数据缺口而非声明不可表达:推定需要发布,由发布动作自愈元数据
|
|
3938
|
+
(draft 读面不受影响——draft 形态段落恒为独立行)。
|
|
3939
|
+
"""
|
|
3940
|
+
if not isinstance(error, AppFormFailure) or error.phase != "NORMALIZE":
|
|
3941
|
+
return False
|
|
3942
|
+
details = error.details if isinstance(error.details, dict) else {}
|
|
3943
|
+
if error.error_code == "UNSUPPORTED_FIELD_TYPE" and _positive_int(details.get("queType")) == 24:
|
|
3944
|
+
return True
|
|
3945
|
+
return error.error_code == "UNSUPPORTED_FIELD_CONFIG" and "custom serial number has no components" in str(error.message)
|
|
3946
|
+
|
|
3947
|
+
@staticmethod
|
|
3948
|
+
def _is_form_not_published(error: Exception) -> bool:
|
|
3949
|
+
"""published 读面因应用从未发布而 40026。
|
|
3950
|
+
|
|
3951
|
+
后端语义(FormSvcImpl.getFormInfo):appPublishStatus == PUBLISH_STATUS_CREATED
|
|
3952
|
+
时 published GET 恒抛 FORM_NOT_PUBLISHED(40026)——合法存量形态(应用创建后
|
|
3953
|
+
从未发布),draft 域恒可读、published 域不存在。此时 published 对比应跳过:
|
|
3954
|
+
draft 一致即达标,apply 不擅自发布从未发布的应用(发布是独立产品动作,
|
|
3955
|
+
由使用者显式决定)。"""
|
|
3956
|
+
if isinstance(error, QingflowApiError):
|
|
3957
|
+
return str(error.backend_code) == "40026"
|
|
3958
|
+
if isinstance(error, AppFormFailure):
|
|
3959
|
+
details = error.details if isinstance(error.details, dict) else {}
|
|
3960
|
+
transport = details.get("transportError") if isinstance(details.get("transportError"), dict) else details
|
|
3961
|
+
return str(transport.get("backend_code")) == "40026" or str(transport.get("code")) == "40026"
|
|
3962
|
+
if isinstance(error, RuntimeError):
|
|
3963
|
+
# tools 层经 raise_tool_error 把 QingflowApiError 包成 RuntimeError,
|
|
3964
|
+
# message 是 as_json 字符串(含 backend_code);解析识别,不吞其他错误。
|
|
3965
|
+
try:
|
|
3966
|
+
payload = json.loads(str(error))
|
|
3967
|
+
except ValueError:
|
|
3968
|
+
return False
|
|
3969
|
+
return isinstance(payload, dict) and str(payload.get("backend_code")) == "40026"
|
|
3970
|
+
return False
|
|
3971
|
+
|
|
3707
3972
|
def _published_matches(
|
|
3708
3973
|
self,
|
|
3709
3974
|
profile: str,
|
|
@@ -4130,7 +4395,7 @@ def _snake_code_block(value: JSONObject) -> JSONObject:
|
|
|
4130
4395
|
|
|
4131
4396
|
|
|
4132
4397
|
def _backend_question_candidates(form: JSONObject) -> list[JSONObject]:
|
|
4133
|
-
result = []
|
|
4398
|
+
result: list[JSONObject] = []
|
|
4134
4399
|
for item in form.get("baseQues") or []:
|
|
4135
4400
|
if isinstance(item, dict):
|
|
4136
4401
|
result.append(item)
|
|
@@ -4143,6 +4408,20 @@ def _backend_question_candidates(form: JSONObject) -> list[JSONObject]:
|
|
|
4143
4408
|
result.extend(nested for nested in inner if isinstance(inner, list) and isinstance(nested, dict))
|
|
4144
4409
|
else:
|
|
4145
4410
|
result.append(item)
|
|
4411
|
+
# 表格(queType=18)子字段同样是合法的目标字段:后端 data_relation
|
|
4412
|
+
# 运行时按 queId 解析目标字段并把表格子字段纳入 targetQuestionMap
|
|
4413
|
+
# (DataRelationCalService 先按 queId 查 question、再把表格的
|
|
4414
|
+
# subQuestions 合入)。displayedQueId 指向表格子字段是存量合法
|
|
4415
|
+
# 配置,提取能读出、编译必须能写回(两闸门同口径)。
|
|
4416
|
+
# matchRules 的筛选源字段(sourceField)在存量里带父表名前缀
|
|
4417
|
+
# (「父表·子字段」,_data_relation_field_name 按同形态写回)。
|
|
4418
|
+
if _positive_int(item.get("queType")) == 18:
|
|
4419
|
+
table_title = str(item.get("queTitle") or "")
|
|
4420
|
+
for sub in item.get("subQuestions") or []:
|
|
4421
|
+
if isinstance(sub, dict):
|
|
4422
|
+
prefixed = dict(sub)
|
|
4423
|
+
prefixed["_sup_que_title"] = table_title
|
|
4424
|
+
result.append(prefixed)
|
|
4146
4425
|
return result
|
|
4147
4426
|
|
|
4148
4427
|
|
|
@@ -4158,6 +4437,19 @@ def _canonical_backend_field_type(field: JSONObject) -> str:
|
|
|
4158
4437
|
return QUE_TYPE_TO_CANONICAL.get(que_type or -1, "")
|
|
4159
4438
|
|
|
4160
4439
|
|
|
4440
|
+
def _backend_selector_titles(item: JSONObject) -> list[str]:
|
|
4441
|
+
"""后端匹配规则里该字段可能出现的 queTitle 形态。
|
|
4442
|
+
|
|
4443
|
+
matchRules 的筛选源字段(表格子字段)在存量里带父表名前缀("父表·子字段",
|
|
4444
|
+
FormSvcImpl 组装 judgeQueDetail/queTitle 时拼接),顶层字段无前缀。
|
|
4445
|
+
"""
|
|
4446
|
+
titles = [str(item.get("queTitle") or "")]
|
|
4447
|
+
sup_title = str(item.get("_sup_que_title") or "")
|
|
4448
|
+
if sup_title:
|
|
4449
|
+
titles.append(f"{sup_title}·{titles[0]}")
|
|
4450
|
+
return titles
|
|
4451
|
+
|
|
4452
|
+
|
|
4161
4453
|
def _resolve_backend_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
|
|
4162
4454
|
que_id = _positive_int(selector.get("queId"))
|
|
4163
4455
|
name = str(selector.get("name") or "")
|
|
@@ -4165,7 +4457,7 @@ def _resolve_backend_selector(selector: JSONObject, candidates: list[JSONObject]
|
|
|
4165
4457
|
item
|
|
4166
4458
|
for item in candidates
|
|
4167
4459
|
if (que_id is None or _positive_int(item.get("queId")) == que_id)
|
|
4168
|
-
and (not name or
|
|
4460
|
+
and (not name or name in _backend_selector_titles(item))
|
|
4169
4461
|
]
|
|
4170
4462
|
if len(matches) != 1:
|
|
4171
4463
|
raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one field", "CONTRACT_PRECHECK", details={"selector": selector, "matches": len(matches)})
|