@josephyan/qingflow-app-builder-mcp 0.2.0-beta.49 → 0.2.0-beta.50

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.
@@ -85,11 +85,20 @@ class FormField:
85
85
  raw: JSONObject
86
86
 
87
87
 
88
+ @dataclass(slots=True)
89
+ class SubtableLeafRef:
90
+ field: FormField
91
+ parent_field: FormField
92
+
93
+
88
94
  @dataclass(slots=True)
89
95
  class FieldIndex:
90
96
  by_id: dict[str, FormField]
91
97
  by_title: dict[str, list[FormField]]
92
98
  by_alias: dict[str, list[FormField]]
99
+ subtable_leaf_by_id: dict[str, list[SubtableLeafRef]]
100
+ subtable_leaf_by_title: dict[str, list[SubtableLeafRef]]
101
+ subtable_leaf_by_alias: dict[str, list[SubtableLeafRef]]
93
102
 
94
103
 
95
104
  @dataclass(slots=True)
@@ -351,6 +360,7 @@ class RecordTools(ToolBase):
351
360
  description=(
352
361
  "Write Qingflow records with a SQL-like JSON DSL. "
353
362
  "Use record_schema_get first, then choose operation=insert|update|delete. "
363
+ "Insert follows applicant-node write scope; update requires an explicit view selector and is constrained by that view plus real edit permission. "
354
364
  "This tool performs internal preflight validation before any write is applied. "
355
365
  "This route does not accept raw SQL strings or free-form WHERE clauses."
356
366
  )
@@ -363,6 +373,10 @@ class RecordTools(ToolBase):
363
373
  record_ids: list[int] | None = None,
364
374
  values: list[JSONObject] | None = None,
365
375
  set: list[JSONObject] | None = None,
376
+ view_id: str | None = None,
377
+ list_type: int | None = None,
378
+ view_key: str | None = None,
379
+ view_name: str | None = None,
366
380
  submit_type: str | int = "submit",
367
381
  verify_write: bool = True,
368
382
  output_profile: str = "normal",
@@ -375,6 +389,10 @@ class RecordTools(ToolBase):
375
389
  record_ids=record_ids or [],
376
390
  values=values or [],
377
391
  set=set or [],
392
+ view_id=view_id,
393
+ list_type=list_type,
394
+ view_key=view_key,
395
+ view_name=view_name,
378
396
  submit_type=submit_type,
379
397
  verify_write=verify_write,
380
398
  output_profile=output_profile,
@@ -401,6 +419,7 @@ class RecordTools(ToolBase):
401
419
  def runner(session_profile, context):
402
420
  warnings: list[JSONObject] = []
403
421
  resolved_view: AccessibleViewRoute | None = None
422
+ browse_scope: JSONObject | None = None
404
423
  if normalized_schema_mode == "applicant":
405
424
  if any(item is not None for item in (view_id, list_type, view_key, view_name)):
406
425
  raise_tool_error(
@@ -430,11 +449,19 @@ class RecordTools(ToolBase):
430
449
  ),
431
450
  }
432
451
  )
452
+ browse_scope = self._build_browse_write_scope(
453
+ profile,
454
+ context,
455
+ app_key,
456
+ resolved_view,
457
+ force_refresh=False,
458
+ )
433
459
  index = (
434
- self._get_field_index(profile, context, app_key, force_refresh=False)
460
+ self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=False)
435
461
  if normalized_schema_mode == "applicant"
436
- else self._get_browse_field_index(profile, context, app_key, resolved_view, force_refresh=False)
462
+ else cast(FieldIndex, browse_scope["index"])
437
463
  )
464
+ browse_writable_field_ids = cast(set[int], browse_scope["writable_field_ids"]) if isinstance(browse_scope, dict) else set()
438
465
  fields = [
439
466
  self._schema_field_payload(
440
467
  profile,
@@ -443,6 +470,7 @@ class RecordTools(ToolBase):
443
470
  workflow_node_id=None,
444
471
  ws_id=session_profile.selected_ws_id,
445
472
  schema_mode=normalized_schema_mode,
473
+ browse_writable=field.que_id in browse_writable_field_ids if normalized_schema_mode == "browse" else None,
446
474
  )
447
475
  for field in self._schema_fields_for_mode(
448
476
  profile,
@@ -965,14 +993,18 @@ class RecordTools(ToolBase):
965
993
  *,
966
994
  profile: str,
967
995
  app_key: str,
968
- operation: str,
969
- record_id: int | None,
970
- record_ids: list[int],
971
- values: list[JSONObject],
972
- set: list[JSONObject],
973
- submit_type: str | int,
974
- verify_write: bool,
975
- output_profile: str,
996
+ operation: str = "insert",
997
+ record_id: int | None = None,
998
+ record_ids: list[int] | None = None,
999
+ values: list[JSONObject] | None = None,
1000
+ set: list[JSONObject] | None = None,
1001
+ view_id: str | None = None,
1002
+ list_type: int | None = None,
1003
+ view_key: str | None = None,
1004
+ view_name: str | None = None,
1005
+ submit_type: str | int = "submit",
1006
+ verify_write: bool = True,
1007
+ output_profile: str = "normal",
976
1008
  mode: str | None = None,
977
1009
  ) -> JSONObject:
978
1010
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
@@ -987,15 +1019,24 @@ class RecordTools(ToolBase):
987
1019
  )
988
1020
  if not app_key:
989
1021
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
990
- normalized_record_ids = [item for item in record_ids if isinstance(item, int) and item > 0]
1022
+ normalized_values = list(values or [])
1023
+ normalized_set = list(set or [])
1024
+ normalized_record_ids = [item for item in (record_ids or []) if isinstance(item, int) and item > 0]
991
1025
  submit_type_value = self._normalize_record_write_submit_type(submit_type)
1026
+ uses_view_scope = any(item is not None for item in (view_id, list_type, view_key, view_name))
992
1027
 
993
1028
  if normalized_operation == "insert":
1029
+ if uses_view_scope:
1030
+ raise_tool_error(
1031
+ QingflowApiError.config_error(
1032
+ "insert strictly follows applicant-node write scope and does not accept view selectors; use record_schema_get(schema_mode='applicant') first"
1033
+ )
1034
+ )
994
1035
  if record_id is not None or normalized_record_ids:
995
1036
  raise_tool_error(QingflowApiError.config_error("insert must not include record_id or record_ids"))
996
- if set:
1037
+ if normalized_set:
997
1038
  raise_tool_error(QingflowApiError.config_error("insert must use values, not set"))
998
- normalized_answers = self._normalize_record_write_clauses(values, location="values")
1039
+ normalized_answers = self._normalize_record_write_clauses(normalized_values, location="values")
999
1040
  raw_preflight = self._preflight_record_write(
1000
1041
  profile=profile,
1001
1042
  operation="create",
@@ -1004,6 +1045,10 @@ class RecordTools(ToolBase):
1004
1045
  answers=normalized_answers,
1005
1046
  fields={},
1006
1047
  force_refresh_form=False,
1048
+ view_id=view_id,
1049
+ list_type=list_type,
1050
+ view_key=view_key,
1051
+ view_name=view_name,
1007
1052
  )
1008
1053
  preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
1009
1054
  normalized_payload: JSONObject = self._record_write_normalized_payload(
@@ -1012,6 +1057,7 @@ class RecordTools(ToolBase):
1012
1057
  record_ids=[],
1013
1058
  normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
1014
1059
  submit_type=submit_type_value,
1060
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
1015
1061
  )
1016
1062
  if preflight_data.get("blockers"):
1017
1063
  return self._record_write_blocked_response(
@@ -1022,15 +1068,25 @@ class RecordTools(ToolBase):
1022
1068
  human_review=False,
1023
1069
  target_resource={"type": "record", "app_key": app_key, "record_id": None, "record_ids": []},
1024
1070
  )
1025
- raw_apply = self.record_create(
1026
- profile=profile,
1027
- app_key=app_key,
1028
- answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
1029
- fields={},
1030
- submit_type=submit_type_value,
1031
- verify_write=verify_write,
1032
- force_refresh_form=False,
1033
- )
1071
+ try:
1072
+ raw_apply = self.record_create(
1073
+ profile=profile,
1074
+ app_key=app_key,
1075
+ answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
1076
+ fields={},
1077
+ submit_type=submit_type_value,
1078
+ verify_write=verify_write,
1079
+ force_refresh_form=False,
1080
+ )
1081
+ except QingflowApiError as exc:
1082
+ self._raise_record_write_permission_error(
1083
+ exc,
1084
+ operation="insert",
1085
+ app_key=app_key,
1086
+ record_id=None,
1087
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
1088
+ )
1089
+ raise
1034
1090
  return self._record_write_apply_response(
1035
1091
  raw_apply,
1036
1092
  operation="insert",
@@ -1041,13 +1097,19 @@ class RecordTools(ToolBase):
1041
1097
  )
1042
1098
 
1043
1099
  if normalized_operation == "update":
1100
+ if not uses_view_scope:
1101
+ raise_tool_error(
1102
+ QingflowApiError.config_error(
1103
+ "update requires view_id/view_key/view_name/list_type; call app_get first to inspect accessible_views and choose a write scope"
1104
+ )
1105
+ )
1044
1106
  if record_id is None or record_id <= 0:
1045
1107
  raise_tool_error(QingflowApiError.config_error("update requires record_id"))
1046
1108
  if normalized_record_ids:
1047
1109
  raise_tool_error(QingflowApiError.config_error("update does not support record_ids"))
1048
- if values:
1110
+ if normalized_values:
1049
1111
  raise_tool_error(QingflowApiError.config_error("update must use set, not values"))
1050
- normalized_answers = self._normalize_record_write_clauses(set, location="set")
1112
+ normalized_answers = self._normalize_record_write_clauses(normalized_set, location="set")
1051
1113
  raw_preflight = self._preflight_record_write(
1052
1114
  profile=profile,
1053
1115
  operation="update",
@@ -1056,6 +1118,10 @@ class RecordTools(ToolBase):
1056
1118
  answers=normalized_answers,
1057
1119
  fields={},
1058
1120
  force_refresh_form=False,
1121
+ view_id=view_id,
1122
+ list_type=list_type,
1123
+ view_key=view_key,
1124
+ view_name=view_name,
1059
1125
  )
1060
1126
  preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
1061
1127
  normalized_payload = self._record_write_normalized_payload(
@@ -1064,6 +1130,7 @@ class RecordTools(ToolBase):
1064
1130
  record_ids=[],
1065
1131
  normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
1066
1132
  submit_type=submit_type_value,
1133
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
1067
1134
  )
1068
1135
  if preflight_data.get("blockers"):
1069
1136
  return self._record_write_blocked_response(
@@ -1074,16 +1141,26 @@ class RecordTools(ToolBase):
1074
1141
  human_review=True,
1075
1142
  target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
1076
1143
  )
1077
- raw_apply = self.record_update(
1078
- profile=profile,
1079
- app_key=app_key,
1080
- apply_id=record_id,
1081
- answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
1082
- fields={},
1083
- role=1,
1084
- verify_write=verify_write,
1085
- force_refresh_form=False,
1086
- )
1144
+ try:
1145
+ raw_apply = self.record_update(
1146
+ profile=profile,
1147
+ app_key=app_key,
1148
+ apply_id=record_id,
1149
+ answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
1150
+ fields={},
1151
+ role=1,
1152
+ verify_write=verify_write,
1153
+ force_refresh_form=False,
1154
+ )
1155
+ except QingflowApiError as exc:
1156
+ self._raise_record_write_permission_error(
1157
+ exc,
1158
+ operation="update",
1159
+ app_key=app_key,
1160
+ record_id=record_id,
1161
+ selection=cast(JSONObject | None, preflight_data.get("selection") if isinstance(preflight_data.get("selection"), dict) else None),
1162
+ )
1163
+ raise
1087
1164
  return self._record_write_apply_response(
1088
1165
  raw_apply,
1089
1166
  operation="update",
@@ -1093,7 +1170,13 @@ class RecordTools(ToolBase):
1093
1170
  preflight=raw_preflight,
1094
1171
  )
1095
1172
 
1096
- if values or set:
1173
+ if uses_view_scope:
1174
+ raise_tool_error(
1175
+ QingflowApiError.config_error(
1176
+ "delete does not accept view selectors yet; resolve target record_ids from the selected view first, then call delete by record_id/record_ids"
1177
+ )
1178
+ )
1179
+ if normalized_values or normalized_set:
1097
1180
  raise_tool_error(QingflowApiError.config_error("delete must not include values or set"))
1098
1181
  delete_ids = normalized_record_ids or ([record_id] if record_id is not None and record_id > 0 else [])
1099
1182
  if not delete_ids:
@@ -1124,10 +1207,15 @@ class RecordTools(ToolBase):
1124
1207
  workflow_node_id: int | None,
1125
1208
  ws_id: int | None,
1126
1209
  schema_mode: str = "applicant",
1210
+ browse_writable: bool | None = None,
1127
1211
  ) -> JSONObject: # type: ignore[no-untyped-def]
1128
1212
  write_hints = self._schema_write_hints(field)
1129
- writable = write_hints["writable"] if schema_mode == "applicant" else False
1130
- supported_write_ops = write_hints["supported_write_ops"] if schema_mode == "applicant" else []
1213
+ if schema_mode == "applicant":
1214
+ writable = write_hints["writable"]
1215
+ supported_write_ops = write_hints["supported_write_ops"]
1216
+ else:
1217
+ writable = bool(browse_writable)
1218
+ supported_write_ops = ["update"] if writable else []
1131
1219
  payload = {
1132
1220
  "field_id": field.que_id,
1133
1221
  "title": field.que_title,
@@ -2426,10 +2514,27 @@ class RecordTools(ToolBase):
2426
2514
  answers: list[JSONObject] | None = None,
2427
2515
  fields: JSONObject | None = None,
2428
2516
  force_refresh_form: bool = False,
2517
+ view_id: str | None = None,
2518
+ list_type: int | None = None,
2519
+ view_key: str | None = None,
2520
+ view_name: str | None = None,
2429
2521
  ) -> JSONObject:
2430
2522
  if not app_key:
2431
2523
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
2432
2524
  inferred_operation = operation if operation in {"create", "update"} else ("update" if apply_id else "create")
2525
+ uses_view_scope = any(item is not None for item in (view_id, list_type, view_key, view_name))
2526
+ if inferred_operation == "create" and uses_view_scope:
2527
+ raise_tool_error(
2528
+ QingflowApiError.config_error(
2529
+ "insert/create strictly follows applicant-node write scope and does not accept view selectors; use record_schema_get(schema_mode='applicant') first"
2530
+ )
2531
+ )
2532
+ if inferred_operation == "update" and not uses_view_scope:
2533
+ raise_tool_error(
2534
+ QingflowApiError.config_error(
2535
+ "update requires view_id/view_key/view_name/list_type; call app_get first to inspect accessible_views and choose a write scope"
2536
+ )
2537
+ )
2433
2538
 
2434
2539
  return self._preflight_record_write(
2435
2540
  profile=profile,
@@ -2439,6 +2544,10 @@ class RecordTools(ToolBase):
2439
2544
  answers=answers or [],
2440
2545
  fields=fields or {},
2441
2546
  force_refresh_form=force_refresh_form,
2547
+ view_id=view_id,
2548
+ list_type=list_type,
2549
+ view_key=view_key,
2550
+ view_name=view_name,
2442
2551
  )
2443
2552
 
2444
2553
  def _preflight_record_write(
@@ -2451,6 +2560,10 @@ class RecordTools(ToolBase):
2451
2560
  answers: list[JSONObject],
2452
2561
  fields: JSONObject,
2453
2562
  force_refresh_form: bool,
2563
+ view_id: str | None,
2564
+ list_type: int | None,
2565
+ view_key: str | None,
2566
+ view_name: str | None,
2454
2567
  ) -> JSONObject:
2455
2568
  if not app_key:
2456
2569
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
@@ -2465,6 +2578,10 @@ class RecordTools(ToolBase):
2465
2578
  answers=answers,
2466
2579
  fields=fields,
2467
2580
  force_refresh_form=force_refresh_form,
2581
+ view_id=view_id,
2582
+ list_type=list_type,
2583
+ view_key=view_key,
2584
+ view_name=view_name,
2468
2585
  )
2469
2586
  return {
2470
2587
  "profile": profile,
@@ -2487,25 +2604,75 @@ class RecordTools(ToolBase):
2487
2604
  answers: list[JSONObject],
2488
2605
  fields: JSONObject,
2489
2606
  force_refresh_form: bool,
2607
+ view_id: str | None,
2608
+ list_type: int | None,
2609
+ view_key: str | None,
2610
+ view_name: str | None,
2490
2611
  ) -> JSONObject:
2491
2612
  schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
2492
- index = _build_field_index(schema)
2613
+ index = _build_applicant_top_level_field_index(schema)
2493
2614
  normalized_fields = fields or {}
2494
2615
  normalized_answers_input = answers or []
2495
- resolved_fields = self._collect_write_plan_field_refs(fields=normalized_fields, answers=normalized_answers_input, index=index)
2616
+ resolved_view: AccessibleViewRoute | None = None
2617
+ selector_index = index
2618
+ browse_writable_field_ids: set[int] = set()
2619
+ visible_question_ids: set[int] = set()
2620
+ if any(item is not None for item in (view_id, list_type, view_key, view_name)):
2621
+ resolved_view, compatibility_warnings = self._resolve_accessible_view_route(
2622
+ profile,
2623
+ context,
2624
+ app_key,
2625
+ view_id=view_id,
2626
+ list_type=list_type,
2627
+ view_key=view_key,
2628
+ view_name=view_name,
2629
+ allow_default=False,
2630
+ )
2631
+ browse_scope = self._build_browse_write_scope(
2632
+ profile,
2633
+ context,
2634
+ app_key,
2635
+ resolved_view,
2636
+ force_refresh=force_refresh_form,
2637
+ )
2638
+ selector_index = cast(FieldIndex, browse_scope["index"])
2639
+ browse_writable_field_ids = cast(set[int], browse_scope["writable_field_ids"])
2640
+ visible_question_ids = cast(set[int], browse_scope["visible_question_ids"])
2641
+ else:
2642
+ compatibility_warnings = []
2643
+ resolved_fields = self._collect_write_plan_field_refs(fields=normalized_fields, answers=normalized_answers_input, index=selector_index)
2496
2644
  support_matrix = _summarize_write_support(resolved_fields)
2497
2645
  invalid_fields: list[JSONObject] = []
2498
2646
  normalized_answers: list[JSONObject] = []
2499
2647
  validation_warnings = [
2500
2648
  "record_write performs static preflight from form metadata before apply; runtime visibility and dynamic linkage can still reject writes."
2501
2649
  ]
2650
+ if resolved_view is not None:
2651
+ validation_warnings.append(
2652
+ "view-scoped writes only narrow selectable fields; they do not grant record edit permission."
2653
+ )
2654
+ validation_warnings.extend(
2655
+ str(item.get("message"))
2656
+ for item in compatibility_warnings
2657
+ if isinstance(item, dict) and item.get("message")
2658
+ )
2659
+ validation_warnings.extend(
2660
+ str(item.get("message"))
2661
+ for item in _view_filter_trust_warnings(resolved_view)
2662
+ if isinstance(item, dict) and item.get("message")
2663
+ )
2502
2664
  try:
2665
+ scoped_answers_input, scoped_fields = self._canonicalize_write_scope_selectors(
2666
+ answers=normalized_answers_input,
2667
+ fields=normalized_fields,
2668
+ selector_index=selector_index,
2669
+ )
2503
2670
  normalized_answers = self._resolve_answers(
2504
2671
  profile,
2505
2672
  context,
2506
2673
  app_key,
2507
- answers=normalized_answers_input,
2508
- fields=normalized_fields,
2674
+ answers=scoped_answers_input,
2675
+ fields=scoped_fields,
2509
2676
  force_refresh_form=force_refresh_form,
2510
2677
  )
2511
2678
  except RecordInputError as error:
@@ -2538,10 +2705,35 @@ class RecordTools(ToolBase):
2538
2705
  "system": entry.get("system"),
2539
2706
  "source": entry.get("source"),
2540
2707
  "requested": entry.get("requested"),
2708
+ "reason_code": (
2709
+ "system"
2710
+ if bool(entry.get("system"))
2711
+ else "view_readonly"
2712
+ if resolved_view is not None
2713
+ else "readonly"
2714
+ ),
2541
2715
  }
2542
2716
  for entry in resolved_fields
2543
- if bool(entry.get("resolved")) and (bool(entry.get("readonly")) or bool(entry.get("system")))
2717
+ if bool(entry.get("resolved"))
2718
+ and (
2719
+ bool(entry.get("system"))
2720
+ or (
2721
+ resolved_view is not None
2722
+ and _coerce_count(entry.get("que_id")) is not None
2723
+ and _coerce_count(entry.get("que_id")) not in browse_writable_field_ids
2724
+ )
2725
+ or (resolved_view is None and bool(entry.get("readonly")))
2726
+ )
2544
2727
  ]
2728
+ if resolved_view is not None and visible_question_ids:
2729
+ invalid_fields.extend(
2730
+ self._validate_view_scoped_subtable_answers(
2731
+ normalized_answers=normalized_answers,
2732
+ full_index=index,
2733
+ selector_index=selector_index,
2734
+ visible_question_ids=visible_question_ids,
2735
+ )
2736
+ )
2545
2737
  provided_field_ids = {
2546
2738
  str(answer.get("queId"))
2547
2739
  for answer in validation_answers
@@ -2606,7 +2798,11 @@ class RecordTools(ToolBase):
2606
2798
  if missing_required_fields:
2607
2799
  blockers.append("required fields are missing")
2608
2800
  if readonly_or_system_fields:
2609
- blockers.append("payload writes readonly or system-managed fields")
2801
+ blockers.append(
2802
+ "payload writes fields that are not writable in the selected view"
2803
+ if resolved_view is not None
2804
+ else "payload writes readonly or system-managed fields"
2805
+ )
2610
2806
  actions = ["Use record_schema_get when field titles or field ids are ambiguous."]
2611
2807
  if support_matrix["restricted"]:
2612
2808
  actions.append("Review write_format.required_presteps for restricted fields before submit.")
@@ -2615,7 +2811,11 @@ class RecordTools(ToolBase):
2615
2811
  if missing_required_fields:
2616
2812
  actions.append("Fill missing required fields before applying the write.")
2617
2813
  if readonly_or_system_fields:
2618
- actions.append("Remove readonly/system fields from payload before applying the write.")
2814
+ actions.append(
2815
+ "Remove fields that the selected view does not allow for update, or choose a view that exposes those fields as writable."
2816
+ if resolved_view is not None
2817
+ else "Remove readonly/system fields from payload before applying the write."
2818
+ )
2619
2819
  if question_relations:
2620
2820
  actions.append("Treat static preflight as conservative only because linked fields can still appear at runtime.")
2621
2821
  return {
@@ -2632,6 +2832,9 @@ class RecordTools(ToolBase):
2632
2832
  "question_relations": question_relations,
2633
2833
  "option_links": option_links,
2634
2834
  },
2835
+ "selection": {
2836
+ "view": _accessible_view_payload(resolved_view),
2837
+ },
2635
2838
  "ready_to_submit": validation["valid"],
2636
2839
  "blockers": blockers,
2637
2840
  "recommended_next_actions": actions,
@@ -2653,6 +2856,49 @@ class RecordTools(ToolBase):
2653
2856
  answers = record.get("answers") if isinstance(record, dict) else None
2654
2857
  return [item for item in answers if isinstance(item, dict)] if isinstance(answers, list) else []
2655
2858
 
2859
+ def _validate_view_scoped_subtable_answers(
2860
+ self,
2861
+ *,
2862
+ normalized_answers: list[JSONObject],
2863
+ full_index: FieldIndex,
2864
+ selector_index: FieldIndex,
2865
+ visible_question_ids: set[int],
2866
+ ) -> list[JSONObject]:
2867
+ invalid_fields: list[JSONObject] = []
2868
+ if not visible_question_ids:
2869
+ return invalid_fields
2870
+ for answer in normalized_answers:
2871
+ table_values = answer.get("tableValues") if isinstance(answer.get("tableValues"), list) else []
2872
+ if not table_values:
2873
+ continue
2874
+ parent_que_id = _coerce_count(answer.get("queId"))
2875
+ if parent_que_id is None:
2876
+ continue
2877
+ parent_field = full_index.by_id.get(str(parent_que_id)) or selector_index.by_id.get(str(parent_que_id))
2878
+ if parent_field is None:
2879
+ continue
2880
+ subtable_index = self._subtable_field_index_optional(parent_field)
2881
+ for row_ordinal, row in enumerate(table_values, start=1):
2882
+ row_cells = [item for item in row if isinstance(item, dict)] if isinstance(row, list) else []
2883
+ for cell in row_cells:
2884
+ que_id = _coerce_count(cell.get("queId"))
2885
+ if que_id is None or que_id in visible_question_ids:
2886
+ continue
2887
+ subfield = subtable_index.by_id.get(str(que_id)) if subtable_index is not None else None
2888
+ invalid_fields.append(
2889
+ {
2890
+ "location": f"{parent_field.que_title}[{row_ordinal}].{subfield.que_title if subfield is not None else que_id}",
2891
+ "message": (
2892
+ f"subtable field '{subfield.que_title if subfield is not None else que_id}' is not visible in the selected view"
2893
+ ),
2894
+ "error_code": "VIEW_SCOPE_FIELD_HIDDEN",
2895
+ "field": _field_ref_payload(subfield) if subfield is not None else {"que_id": que_id},
2896
+ "expected_format": _write_format_for_field(parent_field),
2897
+ "received_value": cell.get("values"),
2898
+ }
2899
+ )
2900
+ return invalid_fields
2901
+
2656
2902
  def _merge_record_answers(
2657
2903
  self,
2658
2904
  existing_answers: list[JSONObject],
@@ -3387,6 +3633,18 @@ class RecordTools(ToolBase):
3387
3633
  def _get_field_index(self, profile: str, context, app_key: str, *, force_refresh: bool) -> FieldIndex: # type: ignore[no-untyped-def]
3388
3634
  return _build_field_index(self._get_form_schema(profile, context, app_key, force_refresh=force_refresh))
3389
3635
 
3636
+ def _get_applicant_top_level_field_index(
3637
+ self,
3638
+ profile: str,
3639
+ context, # type: ignore[no-untyped-def]
3640
+ app_key: str,
3641
+ *,
3642
+ force_refresh: bool,
3643
+ ) -> FieldIndex:
3644
+ return _build_applicant_top_level_field_index(
3645
+ self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
3646
+ )
3647
+
3390
3648
  def _resolve_applicant_node(self, profile: str, context, app_key: str, *, force_refresh: bool) -> WorkflowNodeRef: # type: ignore[no-untyped-def]
3391
3649
  cache_key = (profile, app_key)
3392
3650
  if not force_refresh and cache_key in self._applicant_node_cache:
@@ -3512,17 +3770,112 @@ class RecordTools(ToolBase):
3512
3770
  *,
3513
3771
  force_refresh: bool,
3514
3772
  ) -> FieldIndex:
3773
+ return self._build_browse_write_scope(
3774
+ profile,
3775
+ context,
3776
+ app_key,
3777
+ resolved_view,
3778
+ force_refresh=force_refresh,
3779
+ )["index"]
3780
+
3781
+ def _build_browse_write_scope(
3782
+ self,
3783
+ profile: str,
3784
+ context, # type: ignore[no-untyped-def]
3785
+ app_key: str,
3786
+ resolved_view: AccessibleViewRoute | None,
3787
+ *,
3788
+ force_refresh: bool,
3789
+ ) -> JSONObject:
3790
+ applicant_index: FieldIndex | None
3791
+ applicant_writable_field_ids: set[int]
3792
+ try:
3793
+ applicant_index = self._get_applicant_top_level_field_index(profile, context, app_key, force_refresh=force_refresh)
3794
+ except QingflowApiError as exc:
3795
+ if exc.backend_code != 40002:
3796
+ raise
3797
+ applicant_index = None
3798
+ applicant_writable_field_ids = set()
3799
+ else:
3800
+ applicant_writable_field_ids = {
3801
+ field.que_id
3802
+ for field in applicant_index.by_id.values()
3803
+ if bool(self._schema_write_hints(field)["writable"])
3804
+ }
3515
3805
  if resolved_view is not None and resolved_view.kind == "custom" and resolved_view.view_selection is not None:
3516
- return self._get_view_field_index(profile, context, resolved_view.view_selection.view_key, force_refresh=force_refresh)
3517
- if resolved_view is not None and resolved_view.kind == "system" and resolved_view.list_type is not None:
3518
- return self._get_system_browse_field_index(
3806
+ schema = self._get_view_form_schema(profile, context, resolved_view.view_selection.view_key, force_refresh=force_refresh)
3807
+ index = _build_top_level_field_index(schema)
3808
+ visible_question_ids = self._get_view_question_ids(profile, context, resolved_view.view_selection.view_key)
3809
+ if not visible_question_ids:
3810
+ visible_question_ids = _question_ids_from_schema(schema)
3811
+ elif resolved_view is not None and resolved_view.kind == "system" and resolved_view.list_type is not None:
3812
+ schema = self._get_system_browse_schema(
3519
3813
  profile,
3520
3814
  context,
3521
3815
  app_key,
3522
3816
  list_type=resolved_view.list_type,
3523
3817
  force_refresh=force_refresh,
3524
3818
  )
3525
- return self._get_field_index(profile, context, app_key, force_refresh=force_refresh)
3819
+ index = _build_top_level_field_index(schema)
3820
+ visible_question_ids = _question_ids_from_schema(schema)
3821
+ else:
3822
+ index = applicant_index or _build_top_level_field_index(
3823
+ self._get_form_schema(profile, context, app_key, force_refresh=force_refresh)
3824
+ )
3825
+ visible_question_ids = {field.que_id for field in index.by_id.values()}
3826
+ return {
3827
+ "index": index,
3828
+ "writable_field_ids": (
3829
+ set(applicant_writable_field_ids)
3830
+ if applicant_index is not None
3831
+ else {
3832
+ field.que_id
3833
+ for field in index.by_id.values()
3834
+ if bool(self._schema_write_hints(field)["writable"])
3835
+ }
3836
+ ),
3837
+ "visible_question_ids": set(visible_question_ids),
3838
+ }
3839
+
3840
+ if applicant_index is None:
3841
+ return {
3842
+ "index": index,
3843
+ "writable_field_ids": {
3844
+ field.que_id
3845
+ for field in index.by_id.values()
3846
+ if bool(self._schema_write_hints(field)["writable"])
3847
+ },
3848
+ "visible_question_ids": visible_question_ids,
3849
+ }
3850
+
3851
+ augmented_fields = [
3852
+ _clone_form_field(applicant_index.by_id.get(str(field.que_id)) or field)
3853
+ for field in index.by_id.values()
3854
+ ]
3855
+ augmented_field_ids = {field.que_id for field in augmented_fields}
3856
+ writable_field_ids = {
3857
+ field_id
3858
+ for field_id in visible_question_ids
3859
+ if field_id in applicant_writable_field_ids
3860
+ }
3861
+ for field in applicant_index.by_id.values():
3862
+ descendant_ids = _subtable_descendant_ids(field)
3863
+ field_visible = field.que_id in visible_question_ids
3864
+ descendant_visible = bool(descendant_ids and (descendant_ids & visible_question_ids))
3865
+ if not field_visible and not descendant_visible:
3866
+ continue
3867
+ if field.que_id not in augmented_field_ids:
3868
+ augmented_fields.append(_clone_form_field(field))
3869
+ augmented_field_ids.add(field.que_id)
3870
+ if descendant_visible:
3871
+ visible_question_ids.add(field.que_id)
3872
+ if field.que_id in applicant_writable_field_ids and (field_visible or descendant_visible):
3873
+ writable_field_ids.add(field.que_id)
3874
+ return {
3875
+ "index": _build_top_level_field_index({"formQues": [[_form_field_to_question(field) for field in augmented_fields]]}),
3876
+ "writable_field_ids": writable_field_ids,
3877
+ "visible_question_ids": visible_question_ids,
3878
+ }
3526
3879
 
3527
3880
  def _get_view_question_ids(self, profile: str, context, view_key: str) -> set[int]: # type: ignore[no-untyped-def]
3528
3881
  try:
@@ -3552,14 +3905,7 @@ class RecordTools(ToolBase):
3552
3905
  schema_mode: str,
3553
3906
  resolved_view: AccessibleViewRoute | None,
3554
3907
  ) -> list[FormField]:
3555
- fields = list(index.by_id.values())
3556
- if schema_mode != "browse" or resolved_view is None or resolved_view.kind != "custom" or resolved_view.view_selection is None:
3557
- return fields
3558
- question_ids = self._get_view_question_ids(profile, context, resolved_view.view_selection.view_key)
3559
- if not question_ids:
3560
- return fields
3561
- filtered = [field for field in fields if field.que_id in question_ids]
3562
- return filtered or fields
3908
+ return list(index.by_id.values())
3563
3909
 
3564
3910
  def _probe_list_type_access(
3565
3911
  self,
@@ -4583,14 +4929,45 @@ class RecordTools(ToolBase):
4583
4929
  record_ids: list[int],
4584
4930
  normalized_answers: list[JSONObject],
4585
4931
  submit_type: int,
4932
+ selection: JSONObject | None = None,
4586
4933
  ) -> JSONObject:
4587
- return {
4934
+ payload: JSONObject = {
4588
4935
  "operation": operation,
4589
4936
  "record_id": record_id,
4590
4937
  "record_ids": record_ids,
4591
4938
  "answers": normalized_answers,
4592
4939
  "submit_type": submit_type,
4593
4940
  }
4941
+ if selection:
4942
+ payload["selection"] = selection
4943
+ return payload
4944
+
4945
+ def _canonicalize_write_scope_selectors(
4946
+ self,
4947
+ *,
4948
+ answers: list[JSONObject],
4949
+ fields: JSONObject,
4950
+ selector_index: FieldIndex,
4951
+ ) -> tuple[list[JSONObject], JSONObject]:
4952
+ canonical_answers: list[JSONObject] = []
4953
+ for item in answers:
4954
+ if not isinstance(item, dict):
4955
+ raise RecordInputError(
4956
+ message="answer item must be an object",
4957
+ error_code="INVALID_ANSWER_ITEM",
4958
+ fix_hint="Provide each answer as an object with a field selector and value.",
4959
+ )
4960
+ field = self._resolve_field_from_answer_item(item, selector_index)
4961
+ payload = dict(item)
4962
+ payload["queId"] = field.que_id
4963
+ for key in ("field_id", "fieldId", "que_id", "queTitle", "que_title"):
4964
+ payload.pop(key, None)
4965
+ canonical_answers.append(payload)
4966
+ canonical_fields: JSONObject = {}
4967
+ for requested_key, value in fields.items():
4968
+ field = self._resolve_field_selector(cast(str | int, requested_key), selector_index, location="fields")
4969
+ canonical_fields[str(field.que_id)] = value
4970
+ return canonical_answers, canonical_fields
4594
4971
 
4595
4972
  def _record_write_human_review_payload(self, operation: str, *, enabled: bool) -> JSONObject | None:
4596
4973
  if not enabled:
@@ -4601,6 +4978,39 @@ class RecordTools(ToolBase):
4601
4978
  "message": "Read the current record first and confirm the exact target before applying this high-risk write.",
4602
4979
  }
4603
4980
 
4981
+ def _raise_record_write_permission_error(
4982
+ self,
4983
+ exc: QingflowApiError,
4984
+ *,
4985
+ operation: str,
4986
+ app_key: str,
4987
+ record_id: int | None,
4988
+ selection: JSONObject | None,
4989
+ ) -> None:
4990
+ if exc.backend_code != 40002:
4991
+ raise exc
4992
+ raise_tool_error(
4993
+ QingflowApiError(
4994
+ category="permission",
4995
+ message="record_write was blocked because the current user does not have permission to edit this record.",
4996
+ backend_code=exc.backend_code,
4997
+ request_id=exc.request_id,
4998
+ http_status=exc.http_status,
4999
+ details={
5000
+ "error_code": "WRITE_PERMISSION_DENIED",
5001
+ "operation": operation,
5002
+ "app_key": app_key,
5003
+ "record_id": record_id,
5004
+ "selection": selection,
5005
+ "fix_hint": (
5006
+ "View visibility only narrows field scope and does not grant edit rights. "
5007
+ "If this is workflow work, prefer task_list -> task_get -> task_action_execute; "
5008
+ "otherwise ask an operator/admin account with record edit permission to perform the update."
5009
+ ),
5010
+ },
5011
+ )
5012
+ )
5013
+
4604
5014
  def _record_write_blocked_response(
4605
5015
  self,
4606
5016
  raw_preflight: JSONObject,
@@ -4734,10 +5144,20 @@ class RecordTools(ToolBase):
4734
5144
  }
4735
5145
  )
4736
5146
  for item in readonly_or_system_fields:
5147
+ reason_code = _normalize_optional_text(item.get("reason_code")) or "readonly"
5148
+ if reason_code == "view_readonly":
5149
+ error_code = "VIEW_FIELD_READONLY"
5150
+ message = "field is not writable in the selected view"
5151
+ elif reason_code == "system":
5152
+ error_code = "READONLY_OR_SYSTEM_FIELD"
5153
+ message = "field is system-managed"
5154
+ else:
5155
+ error_code = "READONLY_OR_SYSTEM_FIELD"
5156
+ message = "field is readonly or system-managed"
4737
5157
  errors.append(
4738
5158
  {
4739
- "error_code": "READONLY_OR_SYSTEM_FIELD",
4740
- "message": "field is readonly or system-managed",
5159
+ "error_code": error_code,
5160
+ "message": message,
4741
5161
  "field": {
4742
5162
  "que_id": item.get("que_id"),
4743
5163
  "que_title": item.get("que_title"),
@@ -4796,6 +5216,9 @@ class RecordTools(ToolBase):
4796
5216
  field = index.by_id.get(str(int(requested)))
4797
5217
  if field is not None:
4798
5218
  return field
5219
+ leaf_matches = index.subtable_leaf_by_id.get(str(int(requested)), [])
5220
+ if leaf_matches:
5221
+ raise self._subtable_leaf_field_error(location=location, requested=requested, matches=leaf_matches, matched_via="id")
4799
5222
  raise RecordInputError(
4800
5223
  message=f"{location} references unknown queId '{requested}'",
4801
5224
  error_code="FIELD_NOT_FOUND",
@@ -4818,6 +5241,9 @@ class RecordTools(ToolBase):
4818
5241
  "candidates": [_field_ref_payload(item) for item in matches],
4819
5242
  },
4820
5243
  )
5244
+ leaf_matches = index.subtable_leaf_by_title.get(requested_key, [])
5245
+ if leaf_matches:
5246
+ raise self._subtable_leaf_field_error(location=location, requested=requested, matches=leaf_matches, matched_via="title")
4821
5247
  alias_matches = index.by_alias.get(requested_key, [])
4822
5248
  if len(alias_matches) == 1:
4823
5249
  return alias_matches[0]
@@ -4834,6 +5260,9 @@ class RecordTools(ToolBase):
4834
5260
  "candidates": [_field_ref_payload(item) for item in alias_matches],
4835
5261
  },
4836
5262
  )
5263
+ leaf_alias_matches = index.subtable_leaf_by_alias.get(requested_key, [])
5264
+ if leaf_alias_matches:
5265
+ raise self._subtable_leaf_field_error(location=location, requested=requested, matches=leaf_alias_matches, matched_via="alias")
4837
5266
  raise RecordInputError(
4838
5267
  message=f"{location} cannot resolve field '{requested}'",
4839
5268
  error_code="FIELD_NOT_FOUND",
@@ -4846,6 +5275,57 @@ class RecordTools(ToolBase):
4846
5275
  },
4847
5276
  )
4848
5277
 
5278
+ def _subtable_leaf_field_error(
5279
+ self,
5280
+ *,
5281
+ location: str,
5282
+ requested: str,
5283
+ matches: list[SubtableLeafRef],
5284
+ matched_via: str,
5285
+ ) -> RecordInputError:
5286
+ if len(matches) > 1:
5287
+ return RecordInputError(
5288
+ message=f"{location} field '{requested}' matches subtable leaf fields under multiple parent tables",
5289
+ error_code="AMBIGUOUS_SUBTABLE_LEAF_FIELD",
5290
+ fix_hint="Use the parent subtable field with rows/tableValues, or inspect record_schema_get to choose the correct parent table.",
5291
+ details={
5292
+ "location": location,
5293
+ "requested": requested,
5294
+ "matched_via": matched_via,
5295
+ "candidates": [
5296
+ {
5297
+ "parent_field": _field_ref_payload(item.parent_field),
5298
+ "subfield": _field_ref_payload(item.field),
5299
+ }
5300
+ for item in matches
5301
+ ],
5302
+ },
5303
+ )
5304
+ match = matches[0]
5305
+ return RecordInputError(
5306
+ message=(
5307
+ f"{location} field '{requested}' is a subtable leaf field and cannot be written at the top level; "
5308
+ f"use parent field '{match.parent_field.que_title}' with rows/tableValues instead"
5309
+ ),
5310
+ error_code="SUBTABLE_LEAF_REQUIRES_PARENT_ROWS",
5311
+ fix_hint=(
5312
+ f"Write subtable leaf '{match.field.que_title}' under parent field '{match.parent_field.que_title}', "
5313
+ "for example {'"
5314
+ + match.parent_field.que_title
5315
+ + "': [{'"
5316
+ + match.field.que_title
5317
+ + "': '值'}]}."
5318
+ ),
5319
+ details={
5320
+ "location": location,
5321
+ "requested": requested,
5322
+ "matched_via": matched_via,
5323
+ "field": _field_ref_payload(match.field),
5324
+ "parent_field": _field_ref_payload(match.parent_field),
5325
+ "expected_format": _write_format_for_field(match.parent_field),
5326
+ },
5327
+ )
5328
+
4849
5329
  def _resolve_field_from_answer_item(self, item: JSONObject, index: FieldIndex) -> FormField:
4850
5330
  for key in ("queId", "que_id", "queTitle", "que_title", "field_id", "fieldId"):
4851
5331
  if key in item:
@@ -5862,47 +6342,208 @@ def _compile_view_conditions(config: JSONObject) -> list[list[ViewFilterConditio
5862
6342
  return compiled
5863
6343
 
5864
6344
 
5865
- def _build_field_index(schema: JSONObject) -> FieldIndex:
6345
+ def _collect_top_level_questions(payload: JSONValue) -> list[JSONObject]:
6346
+ questions: list[JSONObject] = []
6347
+ if isinstance(payload, dict):
6348
+ is_question = "queId" in payload or "queTitle" in payload
6349
+ if is_question:
6350
+ que_type = _coerce_count(payload.get("queType"))
6351
+ if _should_index_question(payload):
6352
+ questions.append(payload)
6353
+ if que_type in SUBTABLE_QUE_TYPES:
6354
+ return questions
6355
+ if not _question_is_container_wrapper(payload):
6356
+ return questions
6357
+ for key in ("baseQues", "formQues"):
6358
+ value = payload.get(key)
6359
+ if isinstance(value, list):
6360
+ questions.extend(_collect_top_level_questions(value))
6361
+ for key in ("innerQuestions", "subQues", "subQuestions"):
6362
+ value = payload.get(key)
6363
+ if isinstance(value, list):
6364
+ questions.extend(_collect_top_level_questions(value))
6365
+ return questions
6366
+ if isinstance(payload, list):
6367
+ for item in payload:
6368
+ questions.extend(_collect_top_level_questions(item))
6369
+ return questions
6370
+
6371
+
6372
+ def _question_to_form_field(question: JSONObject, *, is_base_question: bool) -> FormField | None:
6373
+ if not _should_index_question(question):
6374
+ return None
6375
+ que_id = _coerce_count(question.get("queId"))
6376
+ title = _stringify_json(question.get("queTitle")).strip()
6377
+ if que_id is None or que_id < 0 or not title:
6378
+ return None
6379
+ can_edit = question.get("canEdit")
6380
+ field = FormField(
6381
+ que_id=que_id,
6382
+ que_title=title,
6383
+ que_type=_coerce_count(question.get("queType")),
6384
+ required=bool(question.get("required") or question.get("beingRequired")),
6385
+ readonly=bool(question.get("readonly") or question.get("beingReadonly") or is_base_question or can_edit is False),
6386
+ system=bool(question.get("system") or question.get("beingSystem") or is_base_question),
6387
+ options=_extract_question_options(question),
6388
+ aliases=[],
6389
+ target_app_key=_extract_relation_target_app_key(question),
6390
+ target_app_name_hint=_extract_relation_target_app_name_hint(question),
6391
+ member_select_scope_type=_coerce_count(question.get("memberSelectScopeType")),
6392
+ member_select_scope=_normalize_scope_payload(question.get("memberSelectScope")),
6393
+ dept_select_scope_type=_coerce_count(question.get("deptSelectScopeType")),
6394
+ dept_select_scope=_normalize_scope_payload(question.get("deptSelectScope")),
6395
+ raw=question,
6396
+ )
6397
+ field.aliases = sorted(_field_alias_candidates(field))
6398
+ return field
6399
+
6400
+
6401
+ def _add_subtable_leaf_ref(
6402
+ target: dict[str, list[SubtableLeafRef]],
6403
+ key: str,
6404
+ ref: SubtableLeafRef,
6405
+ ) -> None:
6406
+ if not key:
6407
+ return
6408
+ refs = target.setdefault(key, [])
6409
+ if any(existing.parent_field.que_id == ref.parent_field.que_id and existing.field.que_id == ref.field.que_id for existing in refs):
6410
+ return
6411
+ refs.append(ref)
6412
+
6413
+
6414
+ def _subtable_question_payload(question: JSONObject) -> list[JSONObject]:
6415
+ for key in ("subQuestions", "innerQuestions", "subQues"):
6416
+ value = question.get(key)
6417
+ if isinstance(value, list):
6418
+ return [item for item in _flatten_questions(value) if isinstance(item, dict)]
6419
+ return []
6420
+
6421
+
6422
+ def _build_field_index(
6423
+ schema: JSONObject,
6424
+ *,
6425
+ include_subtable_leaf_fields: bool = True,
6426
+ force_subtable_parents_writable: bool = False,
6427
+ ) -> FieldIndex:
5866
6428
  by_id: dict[str, FormField] = {}
5867
6429
  by_title: dict[str, list[FormField]] = {}
5868
6430
  by_alias: dict[str, list[FormField]] = {}
6431
+ subtable_leaf_by_id: dict[str, list[SubtableLeafRef]] = {}
6432
+ subtable_leaf_by_title: dict[str, list[SubtableLeafRef]] = {}
6433
+ subtable_leaf_by_alias: dict[str, list[SubtableLeafRef]] = {}
5869
6434
  all_questions = [
5870
- *[(question, True) for question in _flatten_questions(schema.get("baseQues"))],
5871
- *[(question, False) for question in _flatten_questions(schema.get("formQues"))],
6435
+ *[
6436
+ (question, True)
6437
+ for question in (
6438
+ _flatten_questions(schema.get("baseQues"))
6439
+ if include_subtable_leaf_fields
6440
+ else _collect_top_level_questions(schema.get("baseQues"))
6441
+ )
6442
+ ],
6443
+ *[
6444
+ (question, False)
6445
+ for question in (
6446
+ _flatten_questions(schema.get("formQues"))
6447
+ if include_subtable_leaf_fields
6448
+ else _collect_top_level_questions(schema.get("formQues"))
6449
+ )
6450
+ ],
5872
6451
  ]
5873
6452
  for question, is_base_question in all_questions:
5874
- if not _should_index_question(question):
6453
+ field = _question_to_form_field(question, is_base_question=is_base_question)
6454
+ if field is None:
5875
6455
  continue
5876
- que_id = _coerce_count(question.get("queId"))
5877
- title = _stringify_json(question.get("queTitle")).strip()
5878
- if que_id is None or que_id < 0 or not title:
6456
+ if force_subtable_parents_writable and field.que_type in SUBTABLE_QUE_TYPES and not field.system:
6457
+ field = _clone_form_field(field, readonly=False)
6458
+ if str(field.que_id) in by_id:
5879
6459
  continue
5880
- can_edit = question.get("canEdit")
5881
- field = FormField(
5882
- que_id=que_id,
5883
- que_title=title,
5884
- que_type=_coerce_count(question.get("queType")),
5885
- required=bool(question.get("required") or question.get("beingRequired")),
5886
- readonly=bool(question.get("readonly") or question.get("beingReadonly") or is_base_question or can_edit is False),
5887
- system=bool(question.get("system") or question.get("beingSystem") or is_base_question),
5888
- options=_extract_question_options(question),
5889
- aliases=[],
5890
- target_app_key=_extract_relation_target_app_key(question),
5891
- target_app_name_hint=_extract_relation_target_app_name_hint(question),
5892
- member_select_scope_type=_coerce_count(question.get("memberSelectScopeType")),
5893
- member_select_scope=_normalize_scope_payload(question.get("memberSelectScope")),
5894
- dept_select_scope_type=_coerce_count(question.get("deptSelectScopeType")),
5895
- dept_select_scope=_normalize_scope_payload(question.get("deptSelectScope")),
5896
- raw=question,
5897
- )
5898
- if str(que_id) in by_id:
5899
- continue
5900
- field.aliases = sorted(_field_alias_candidates(field))
5901
- by_id[str(que_id)] = field
5902
- by_title.setdefault(_normalize_field_lookup_key(title), []).append(field)
6460
+ by_id[str(field.que_id)] = field
6461
+ by_title.setdefault(_normalize_field_lookup_key(field.que_title), []).append(field)
5903
6462
  for alias in field.aliases:
5904
6463
  by_alias.setdefault(_normalize_field_lookup_key(alias), []).append(field)
5905
- return FieldIndex(by_id=by_id, by_title=by_title, by_alias=by_alias)
6464
+ if field.que_type not in SUBTABLE_QUE_TYPES:
6465
+ continue
6466
+ for sub_question in _subtable_question_payload(question):
6467
+ sub_field = _question_to_form_field(cast(JSONObject, sub_question), is_base_question=False)
6468
+ if sub_field is None:
6469
+ continue
6470
+ ref = SubtableLeafRef(field=sub_field, parent_field=field)
6471
+ _add_subtable_leaf_ref(subtable_leaf_by_id, str(sub_field.que_id), ref)
6472
+ _add_subtable_leaf_ref(subtable_leaf_by_title, _normalize_field_lookup_key(sub_field.que_title), ref)
6473
+ for alias in sub_field.aliases:
6474
+ _add_subtable_leaf_ref(subtable_leaf_by_alias, _normalize_field_lookup_key(alias), ref)
6475
+ return FieldIndex(
6476
+ by_id=by_id,
6477
+ by_title=by_title,
6478
+ by_alias=by_alias,
6479
+ subtable_leaf_by_id=subtable_leaf_by_id,
6480
+ subtable_leaf_by_title=subtable_leaf_by_title,
6481
+ subtable_leaf_by_alias=subtable_leaf_by_alias,
6482
+ )
6483
+
6484
+
6485
+ def _build_top_level_field_index(schema: JSONObject) -> FieldIndex:
6486
+ return _build_field_index(schema, include_subtable_leaf_fields=False)
6487
+
6488
+
6489
+ def _build_applicant_top_level_field_index(schema: JSONObject) -> FieldIndex:
6490
+ return _build_field_index(
6491
+ schema,
6492
+ include_subtable_leaf_fields=False,
6493
+ force_subtable_parents_writable=True,
6494
+ )
6495
+
6496
+
6497
+ def _clone_form_field(field: FormField, *, readonly: bool | None = None) -> FormField:
6498
+ return FormField(
6499
+ que_id=field.que_id,
6500
+ que_title=field.que_title,
6501
+ que_type=field.que_type,
6502
+ required=field.required,
6503
+ readonly=field.readonly if readonly is None else readonly,
6504
+ system=field.system,
6505
+ options=list(field.options),
6506
+ aliases=list(field.aliases),
6507
+ target_app_key=field.target_app_key,
6508
+ target_app_name_hint=field.target_app_name_hint,
6509
+ member_select_scope_type=field.member_select_scope_type,
6510
+ member_select_scope=field.member_select_scope,
6511
+ dept_select_scope_type=field.dept_select_scope_type,
6512
+ dept_select_scope=field.dept_select_scope,
6513
+ raw=field.raw,
6514
+ )
6515
+
6516
+
6517
+ def _form_field_to_question(field: FormField) -> JSONObject:
6518
+ question = dict(field.raw) if isinstance(field.raw, dict) else {}
6519
+ question.setdefault("queId", field.que_id)
6520
+ question.setdefault("queTitle", field.que_title)
6521
+ if field.que_type is not None:
6522
+ question.setdefault("queType", field.que_type)
6523
+ question["readonly"] = field.readonly
6524
+ question["beingReadonly"] = field.readonly
6525
+ question["required"] = field.required
6526
+ question["beingRequired"] = field.required
6527
+ question["system"] = field.system
6528
+ question["beingSystem"] = field.system
6529
+ return question
6530
+
6531
+
6532
+ def _question_ids_from_schema(schema: JSONObject) -> set[int]:
6533
+ question_ids: set[int] = set()
6534
+ for question in _flatten_questions(schema):
6535
+ que_id = _coerce_count(question.get("queId"))
6536
+ if que_id is not None and que_id >= 0:
6537
+ question_ids.add(que_id)
6538
+ return question_ids
6539
+
6540
+
6541
+ def _subtable_descendant_ids(field: FormField) -> set[int]:
6542
+ return {
6543
+ item["que_id"]
6544
+ for item in _subtable_columns_for_field(field)
6545
+ if isinstance(item.get("que_id"), int)
6546
+ }
5906
6547
 
5907
6548
 
5908
6549
  def _extract_relation_target_app_key(question: JSONObject) -> str | None:
@@ -5952,6 +6593,10 @@ def _flatten_questions(payload: JSONValue) -> list[JSONObject]:
5952
6593
  return flattened
5953
6594
 
5954
6595
 
6596
+ def _question_is_container_wrapper(question: JSONObject) -> bool:
6597
+ return not _should_index_question(question)
6598
+
6599
+
5955
6600
  def _should_index_question(question: JSONObject) -> bool:
5956
6601
  if bool(question.get("beingHide") or question.get("hidden")):
5957
6602
  return False