@qingflow-tech/qingflow-cli 1.1.43-beta.5 → 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.
@@ -227,23 +227,33 @@ def _canonical_member_scope(scope: Any, scope_type: Any, *, department: bool = F
227
227
  }
228
228
  if department:
229
229
  result["departments"] = [
230
- _without_none({"deptId": _positive_int(item.get("deptId") or item.get("id")), "deptName": item.get("deptName") or item.get("name")})
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
+ )
231
236
  for item in _scope_items(raw, "depart")
232
237
  if _positive_int(item.get("deptId") or item.get("id")) is not None
233
238
  ]
234
239
  else:
235
240
  result["members"] = [
236
- _without_none({"uid": _positive_int(item.get("uid")), "nickName": item.get("nickName")})
241
+ _without_none({"uid": _positive_int(item.get("uid")), "nickName": _compat_label(item.get("nickName"), "Member", item.get("uid"))})
237
242
  for item in _scope_items(raw, "member")
238
243
  if _positive_int(item.get("uid")) is not None
239
244
  ]
240
245
  result["departments"] = [
241
- _without_none({"deptId": _positive_int(item.get("deptId")), "deptName": item.get("deptName")})
246
+ _without_none(
247
+ {
248
+ "deptId": _positive_int(item.get("deptId")),
249
+ "deptName": _compat_label(item.get("deptName"), "Department", item.get("deptId")),
250
+ }
251
+ )
242
252
  for item in _scope_items(raw, "depart")
243
253
  if _positive_int(item.get("deptId")) is not None
244
254
  ]
245
255
  result["roles"] = [
246
- _without_none({"roleId": _positive_int(item.get("roleId")), "roleName": item.get("roleName")})
256
+ _without_none({"roleId": _positive_int(item.get("roleId")), "roleName": _compat_label(item.get("roleName"), "Role", item.get("roleId"))})
247
257
  for item in _scope_items(raw, "role")
248
258
  if _positive_int(item.get("roleId")) is not None
249
259
  ]
@@ -281,11 +291,12 @@ def _canonical_scope_relation(relation: Any) -> JSONObject | None:
281
291
  return None
282
292
  displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
283
293
  displayed_id = _positive_int(relation.get("displayedQueId") or displayed.get("queId"))
284
- if displayed_id is None or not displayed.get("appKey"):
294
+ target_app_key = str(displayed.get("appKey") or "")
295
+ if displayed_id is None or not target_app_key.strip():
285
296
  return None
286
297
  result: JSONObject = {
287
298
  "displayedField": _selector(displayed_id, displayed.get("queTitle")),
288
- "targetAppKey": displayed.get("appKey"),
299
+ "targetAppKey": target_app_key,
289
300
  "targetAppTitle": displayed.get("appTitle"),
290
301
  "displayedFieldType": displayed.get("queType"),
291
302
  "matchRuleType": relation.get("matchRuleType"),
@@ -334,6 +345,22 @@ def _selector(que_id: Any, name: Any) -> JSONObject:
334
345
  return result
335
346
 
336
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
+
337
364
  def _require_selector(que_id: Any, name: Any, *, message: str, details: JSONObject | None = None) -> JSONObject:
338
365
  result = _selector(que_id, name)
339
366
  if not result:
@@ -620,21 +647,40 @@ class AppFormNormalizer:
620
647
  section_id = _positive_int(section.get("queId")) or _positive_int(section.get("sectionId"))
621
648
  if section_id is None:
622
649
  raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section has no stable ID", "NORMALIZE")
623
- rows = [
624
- {"fields": [self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula) for question in inner if isinstance(question, dict)]}
625
- for inner in section.get("innerQuestions") or []
626
- if isinstance(inner, list) and inner
627
- ]
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})
628
661
  if not rows:
629
662
  raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section is empty", "NORMALIZE")
630
- body.append({"kind": "section", "sectionId": str(section_id), "title": str(section.get("queTitle") or ""), "rows": rows})
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
+ )
631
671
  else:
632
- body.append({"kind": "row", "fields": [self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula) for question in row if isinstance(question, dict)]})
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})
633
679
  resource = {
634
680
  "apiVersion": context.api_version,
635
681
  "appKey": app_key,
636
682
  "spec": {
637
- "appName": str(form.get("formTitle") or ""),
683
+ "appName": _compat_label(form.get("formTitle"), "App", app_key),
638
684
  "appDescription": form.get("formDesc") or None,
639
685
  "dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None else None,
640
686
  "dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None else None,
@@ -676,18 +722,27 @@ class AppFormNormalizer:
676
722
  details={"queId": que_id, "queType": que_type},
677
723
  )
678
724
  field: JSONObject = {
679
- "name": str(question.get("queTitle") or ""),
725
+ "name": _compat_label(question.get("queTitle"), "Field", que_id),
680
726
  "questionType": field_type,
681
727
  "beingRequired": bool(question.get("required")),
682
728
  "questionDescription": question.get("queHint") or None,
683
729
  }
684
730
  default_type = _positive_int(question.get("queDefaultType"))
685
- if default_type is not None and (
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 (
686
742
  question.get("queDefaultValue") is not None
687
743
  or (default_type != 1 and field_type != "relation")
688
744
  ):
689
745
  field["defaultType"] = default_type
690
- raw_default_value = question.get("queDefaultValue")
691
746
  if isinstance(raw_default_value, (dict, list)) and field_type not in {"address", "attachment", "relation", "subtable"}:
692
747
  raise AppFormFailure(
693
748
  "UNSUPPORTED_FIELD_CONFIG",
@@ -695,9 +750,14 @@ class AppFormNormalizer:
695
750
  "NORMALIZE",
696
751
  details={"queId": que_id, "type": field_type},
697
752
  )
698
- if "queDefaultValue" in question and raw_default_value is not None and not isinstance(raw_default_value, (dict, list)):
699
- default_value = question.get("queDefaultValue")
700
- field["defaultValue"] = _decode_formula_default(default_value)
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
701
761
  if que_id is not None:
702
762
  field["queId"] = que_id
703
763
  width = _positive_int(question.get("queWidth"))
@@ -735,9 +795,20 @@ class AppFormNormalizer:
735
795
  if config:
736
796
  field["config"] = config
737
797
  elif field_type in CHOICE_TYPES:
738
- options = [str(item.get("optValue")) for item in question.get("options") or [] if isinstance(item, dict) and item.get("optValue") is not None]
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
+ ]
739
803
  if not options and field_type == "single_choice":
740
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
+ )
741
812
  field["config"] = _without_none({
742
813
  "options": options,
743
814
  "minOpts": _canonical_unlimited(question.get("minOpts")),
@@ -745,7 +816,14 @@ class AppFormNormalizer:
745
816
  "optDirection": question.get("optDirection") if question.get("optDirection") not in {None, 0} else None,
746
817
  "optSelectMode": question.get("optSelectMode"),
747
818
  "beingShowColor": question.get("beingShowColor"),
748
- "defaultOptions": [str(item.get("optValue")) for item in question.get("options") or [] if isinstance(item, dict) and item.get("beingDefault") is True],
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
+ ],
749
827
  })
750
828
  if not field["config"].get("defaultOptions"):
751
829
  field["config"].pop("defaultOptions", None)
@@ -766,7 +844,12 @@ class AppFormNormalizer:
766
844
  scope = question.get("deptSelectScope") if isinstance(question.get("deptSelectScope"), dict) else {}
767
845
  departments = scope.get("depart") if isinstance(scope.get("depart"), list) else []
768
846
  canonical_departments = [
769
- _without_none({"deptId": _positive_int(item.get("deptId") or item.get("id")), "deptName": item.get("deptName") or item.get("name")})
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
+ )
770
853
  for item in departments
771
854
  if isinstance(item, dict) and _positive_int(item.get("deptId") or item.get("id")) is not None
772
855
  ]
@@ -791,7 +874,10 @@ class AppFormNormalizer:
791
874
  else:
792
875
  field["config"] = {"departmentScope": {"mode": "all", "includeSubDeparts": bool(scope.get("includeSubDeparts"))}}
793
876
  if question.get("deptDftValue") is not None:
794
- field["config"]["defaultDepartments"] = _canonical_member_scope(question.get("deptDftValue"), 2, department=True)
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
795
881
  relation = next((item for item in select_scope_relations if _positive_int(item.get("queId")) == que_id), None)
796
882
  scope_relation = _canonical_scope_relation(relation)
797
883
  if scope_relation is not None:
@@ -800,9 +886,14 @@ class AppFormNormalizer:
800
886
  if question.get(key) is not None:
801
887
  field["config"][key] = question[key]
802
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
803
894
  field["config"] = _without_none({
804
895
  "fileSize": question.get("fileSize"),
805
- "fileType": question.get("fileType"),
896
+ "fileType": file_types,
806
897
  "fileBeingCaptureOnly": question.get("fileBeingCaptureOnly"),
807
898
  "imageCompress": question.get("imageCompress"),
808
899
  "pluginStatus": question.get("pluginStatus"),
@@ -826,14 +917,22 @@ class AppFormNormalizer:
826
917
  display_id = _positive_int(reference.get("referQueId"))
827
918
  display = next((item for item in refer_questions if _positive_int(item.get("queId")) == display_id), None)
828
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
+ )
829
928
  relation_config: JSONObject = {
830
- "targetAppKey": reference.get("referAppKey"),
929
+ "targetAppKey": target_app_key,
831
930
  "relationMode": "multiple" if _positive_int(reference.get("optionalDataNum")) not in {None, 1} else "single",
832
931
  "displayField": _require_selector(
833
932
  display_id,
834
933
  (display or {}).get("queTitle"),
835
934
  message="relation display field cannot be normalized",
836
- details={"queId": que_id},
935
+ details={"queId": que_id, "path": "/config/displayField"},
837
936
  ),
838
937
  "visibleFields": [
839
938
  _require_selector(
@@ -849,7 +948,8 @@ class AppFormNormalizer:
849
948
  "defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
850
949
  }
851
950
  if supports_reference_default_formula:
852
- relation_config["defaultValueFormula"] = reference.get("defaultValueFormula")
951
+ formula = reference.get("defaultValueFormula")
952
+ relation_config["defaultValueFormula"] = formula if not isinstance(formula, str) or formula.strip() else None
853
953
  field["config"] = _without_none(relation_config)
854
954
  elif field_type == "q_linker":
855
955
  field["config"] = {"qLinkerBinding": self._q_linker_binding(question, relations, field_names)}
@@ -858,34 +958,54 @@ class AppFormNormalizer:
858
958
  elif field_type == "data_relation":
859
959
  field["config"] = self._data_relation_config(question, relations)
860
960
  elif field_type == "subtable":
861
- field["subfields"] = [
961
+ subfields = [
862
962
  self._field(item, relations, select_scope_relations, field_names, supports_reference_default_formula)
863
963
  for item in question.get("subQuestions") or []
864
964
  if isinstance(item, dict)
865
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
866
974
  for subfield in field["subfields"]:
867
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
868
979
  field["config"] = _without_none({
869
980
  "tableRowsLimit": question.get("tableRowsLimit"),
870
981
  "tableNoRepeatStatus": question.get("tableNoRepeatStatus"),
871
982
  "tableNoRepeatQuestion": [_selector(item, field_names.get(_positive_int(item))) for item in question.get("tableNoRepeatQuestion") or [] if _positive_int(item) is not None],
872
- "tableRowHeight": question.get("tableRowHeight"),
983
+ "tableRowHeight": row_height,
873
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,
874
985
  })
875
986
  return field
876
987
 
877
988
  @staticmethod
878
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
+
879
997
  result = [
880
998
  _without_none(
881
999
  {
882
1000
  "attachId": _positive_int(item.get("attachId") or item.get("id")),
883
- "fileName": item.get("fileName") or item.get("name"),
884
- "fileUrl": item.get("fileUrl") or item.get("url"),
1001
+ "fileName": attachment_text(item, "fileName", "name"),
1002
+ "fileUrl": attachment_text(item, "fileUrl", "url"),
885
1003
  }
886
1004
  )
887
1005
  for item in value or []
888
- if isinstance(item, dict) and (item.get("fileName") or item.get("name")) and (item.get("fileUrl") or item.get("url"))
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()
889
1009
  ]
890
1010
  return sorted(result, key=lambda item: (item.get("attachId", 0), item["fileName"], item["fileUrl"]))
891
1011
 
@@ -907,12 +1027,28 @@ class AppFormNormalizer:
907
1027
  elif kind == "submit_datetime":
908
1028
  component["dateFormat"] = date_names.get(item.get("dateFormat"), "year_month_day")
909
1029
  elif kind == "fixed_text":
910
- component["value"] = str(item.get("customTitle") or "")
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
+ )
911
1035
  else:
912
- component["field"] = _selector(item.get("queId"), item.get("queTitle"))
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
+ )
913
1042
  if item.get("dateFormat") is not None:
914
1043
  component["dateFormat"] = date_names.get(item.get("dateFormat"), "year_month_day")
915
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
+ )
916
1052
  return {"mode": "custom", "components": components}
917
1053
 
918
1054
  @staticmethod
@@ -926,9 +1062,11 @@ class AppFormNormalizer:
926
1062
  for inner in item.get("innerQuestions") or []:
927
1063
  for nested in inner if isinstance(inner, list) else []:
928
1064
  if isinstance(nested, dict) and _positive_int(nested.get("queId")):
929
- result[_positive_int(nested.get("queId")) or 0] = str(nested.get("queTitle") or "")
1065
+ nested_id = _positive_int(nested.get("queId")) or 0
1066
+ result[nested_id] = _compat_label(nested.get("queTitle"), "Field", nested_id)
930
1067
  elif _positive_int(item.get("queId")):
931
- result[_positive_int(item.get("queId")) or 0] = str(item.get("queTitle") or "")
1068
+ item_id = _positive_int(item.get("queId")) or 0
1069
+ result[item_id] = _compat_label(item.get("queTitle"), "Field", item_id)
932
1070
  return result
933
1071
 
934
1072
  @staticmethod
@@ -939,7 +1077,16 @@ class AppFormNormalizer:
939
1077
  for alias in config.get("resultFormatPath") or []:
940
1078
  if not isinstance(alias, dict):
941
1079
  continue
942
- name = str(alias.get("aliasName") or "")
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
+ )
943
1090
  relation = next(
944
1091
  (
945
1092
  item
@@ -953,7 +1100,7 @@ class AppFormNormalizer:
953
1100
  _without_none(
954
1101
  {
955
1102
  "alias": name,
956
- "path": str(alias.get("aliasPath") or ""),
1103
+ "path": path,
957
1104
  "aliasId": _positive_int(alias.get("aliasId")) or _positive_int((relation.get("aliasConfig") or {}).get("aliasId")),
958
1105
  "targetField": _require_selector(
959
1106
  relation.get("queId"),
@@ -973,15 +1120,30 @@ class AppFormNormalizer:
973
1120
  continue
974
1121
  if _positive_int(item.get("type")) == 2:
975
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
+ )
976
1128
  inputs.append(
977
1129
  {
978
- "field": _selector(input_que_id, field_names.get(input_que_id or -1)),
979
- "key": str(item.get("key") or ""),
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,
980
1137
  "source": source,
981
1138
  }
982
1139
  )
983
1140
  else:
984
- static[key].append({"key": str(item.get("key") or ""), "value": item.get("value")})
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")})
985
1147
  request_values = {
986
1148
  "url": config.get("url"),
987
1149
  "jsonValue": config.get("jsonValue"),
@@ -1013,9 +1175,14 @@ class AppFormNormalizer:
1013
1175
 
1014
1176
  request_values[request_key] = Q_LINKER_VALUE_REFERENCE_RE.sub(replace_reference, raw_value)
1015
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
+ )
1016
1183
  request = _without_none(
1017
1184
  {
1018
- "url": request_values["url"],
1185
+ "url": request_url,
1019
1186
  "method": config.get("method") or "GET",
1020
1187
  **static,
1021
1188
  "bodyType": {1: "url_encoded", 2: "json", 3: "xml"}.get(_positive_int(config.get("bodyType")) or 1, "url_encoded"),
@@ -1029,6 +1196,13 @@ class AppFormNormalizer:
1029
1196
  "hideOnForm": bool(config.get("beingHideOnForm")),
1030
1197
  }
1031
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
+ )
1032
1206
  return {"inputs": inputs, "request": request, "outputs": outputs}
1033
1207
 
1034
1208
  @staticmethod
@@ -1039,7 +1213,16 @@ class AppFormNormalizer:
1039
1213
  for alias in config.get("resultAliasPath") or []:
1040
1214
  if not isinstance(alias, dict):
1041
1215
  continue
1042
- name = str(alias.get("aliasName") or "")
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
+ )
1043
1226
  relation = next(
1044
1227
  (
1045
1228
  item
@@ -1051,7 +1234,7 @@ class AppFormNormalizer:
1051
1234
  )
1052
1235
  output = {
1053
1236
  "alias": name,
1054
- "path": str(alias.get("aliasPath") or ""),
1237
+ "path": path,
1055
1238
  "targetField": _require_selector(
1056
1239
  relation.get("queId"),
1057
1240
  relation.get("queTitle") or field_names.get(_positive_int(relation.get("queId")) or -1),
@@ -1069,11 +1252,29 @@ class AppFormNormalizer:
1069
1252
  line = lines.pop(0).strip()
1070
1253
  var = line[6:].split("=", 1)[0].strip()
1071
1254
  name = line.split("qf_field.{", 1)[1].split("$$", 1)[0]
1072
- inputs.append({"field": {"name": name}, "var": var})
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
+ )
1073
1274
  return _without_none(
1074
1275
  {
1075
1276
  "inputs": inputs,
1076
- "code": "\n".join(lines).lstrip("\n"),
1277
+ "code": code,
1077
1278
  "autoTrigger": question.get("autoTrigger"),
1078
1279
  "customButtonTextEnabled": question.get("customBtnTextStatus"),
1079
1280
  "customButtonText": question.get("customBtnText"),
@@ -1092,7 +1293,8 @@ class AppFormNormalizer:
1092
1293
  displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
1093
1294
  alias = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
1094
1295
  if relation_type == 1:
1095
- if not str(displayed.get("appKey") or "").strip():
1296
+ source_app_key = str(displayed.get("appKey") or "")
1297
+ if not source_app_key.strip():
1096
1298
  raise AppFormFailure(
1097
1299
  "UNSUPPORTED_FIELD_CONFIG",
1098
1300
  "data_relation source app cannot be normalized",
@@ -1101,7 +1303,7 @@ class AppFormNormalizer:
1101
1303
  )
1102
1304
  source = {
1103
1305
  "kind": "app",
1104
- "appKey": displayed.get("appKey"),
1306
+ "appKey": source_app_key,
1105
1307
  "field": _require_selector(
1106
1308
  relation.get("displayedQueId"),
1107
1309
  displayed.get("queTitle"),
@@ -1143,7 +1345,9 @@ class AppFormNormalizer:
1143
1345
  for group in relation.get("matchRules") or []:
1144
1346
  if not isinstance(group, list):
1145
1347
  raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation match rules are malformed", "NORMALIZE", details={"queId": que_id})
1146
- groups.append([AppFormNormalizer._data_relation_condition(item, que_id=que_id) for item in group if isinstance(item, dict)])
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)
1147
1351
  result["filters"] = {"mode": "simple", "groups": groups}
1148
1352
  sort = relation.get("sortConfig") if isinstance(relation.get("sortConfig"), dict) else None
1149
1353
  if sort is not None: