@qingflow-tech/qingflow-cli 1.1.51 → 1.1.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +4 -4
  2. package/npm/lib/runtime.mjs +29 -13
  3. package/package.json +1 -1
  4. package/pyproject.toml +8 -2
  5. package/skills/qingflow-cli/SKILL.md +93 -322
  6. package/skills/qingflow-cli/manifest.yaml +1 -1
  7. package/skills/qingflow-cli/reference/00-INDEX.md +4 -4
  8. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  9. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_VIEWS_WORKFLOW.md +3 -9
  10. package/skills/qingflow-cli/reference/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +3 -137
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +14 -14
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  15. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md +7 -12
  16. package/skills/qingflow-cli/reference/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  17. package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +3 -2
  18. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +9 -5
  19. package/skills/qingflow-cli/reference/builder/50-views.md +192 -191
  20. package/skills/qingflow-cli/reference/builder/80-buttons-associated-resources.md +2 -0
  21. package/skills/qingflow-cli/reference/builder/README.md +1 -1
  22. package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +6 -6
  23. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md +1 -1
  24. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md +76 -95
  25. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +6 -4
  26. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +13 -13
  27. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md +20 -19
  28. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md +18 -14
  29. package/skills/qingflow-cli/reference/data/README.md +32 -0
  30. package/skills/qingflow-cli/reference/examples/README.md +1 -1
  31. package/skills/qingflow-cli/reference/examples/views/views_apply_v1alpha1.example.json +14 -0
  32. package/skills/qingflow-cli/reference/examples/views/views_upsert_table_minimal.example.json +30 -36
  33. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_SPECIALIZED_WORKFLOW.md +50 -0
  34. package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md +2 -1
  35. package/skills/qingflow-cli/reference/record/analysis/README.md +21 -113
  36. package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +10 -10
  37. package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +9 -9
  38. package/skills/qingflow-cli/reference/record/analysis/business-context.md +1 -1
  39. package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +10 -10
  40. package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +52 -80
  41. package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +2 -2
  42. package/skills/qingflow-common/SKILL.md +7 -4
  43. package/src/qingflow_mcp/app_form_compat.py +3 -0
  44. package/src/qingflow_mcp/app_form_validator.py +69 -17
  45. package/src/qingflow_mcp/builder_facade/app_form.py +497 -100
  46. package/src/qingflow_mcp/builder_facade/models.py +227 -17
  47. package/src/qingflow_mcp/builder_facade/schemas/app_form/manifest.json +1 -0
  48. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +64 -30
  49. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +4 -8
  50. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +4 -8
  51. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +6 -10
  52. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +6 -10
  53. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +11 -18
  54. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +4 -8
  55. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +4 -8
  56. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +6 -10
  57. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +4 -8
  58. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +4 -8
  59. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +4 -8
  60. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +6 -10
  61. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +4 -8
  62. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +4 -8
  63. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +4 -8
  64. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +6 -10
  65. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +46 -15
  66. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +146 -0
  67. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +4 -8
  68. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +4 -8
  69. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +61 -27
  70. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +4 -8
  71. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +43 -0
  72. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +7 -12
  73. package/src/qingflow_mcp/builder_facade/schemas/views/manifest.json +42 -0
  74. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/complete.json +1245 -0
  75. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/button-config.json +53 -0
  76. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/data-permission.json +60 -0
  77. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/display.json +73 -0
  78. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/operation-permission.json +48 -0
  79. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/query-condition.json +47 -0
  80. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/summary.json +319 -0
  81. package/src/qingflow_mcp/builder_facade/service.py +1709 -518
  82. package/src/qingflow_mcp/builder_facade/views_extract.py +595 -0
  83. package/src/qingflow_mcp/builder_facade/views_schema.py +400 -0
  84. package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
  85. package/src/qingflow_mcp/cli/commands/builder.py +76 -55
  86. package/src/qingflow_mcp/cli/commands/data.py +134 -0
  87. package/src/qingflow_mcp/cli/commands/record.py +1 -1
  88. package/src/qingflow_mcp/cli/context.py +3 -0
  89. package/src/qingflow_mcp/cli/formatters.py +97 -1
  90. package/src/qingflow_mcp/public_surface.py +8 -0
  91. package/src/qingflow_mcp/response_trim.py +11 -2
  92. package/src/qingflow_mcp/server.py +2 -0
  93. package/src/qingflow_mcp/server_app_builder.py +34 -10
  94. package/src/qingflow_mcp/server_app_user.py +4 -0
  95. package/src/qingflow_mcp/solution/compiler/view_compiler.py +2 -1
  96. package/src/qingflow_mcp/solution/executor.py +96 -8
  97. package/src/qingflow_mcp/solution/spec_models.py +1 -0
  98. package/src/qingflow_mcp/tools/ai_builder_tools.py +218 -398
  99. package/src/qingflow_mcp/tools/csv_export.py +168 -0
  100. package/src/qingflow_mcp/tools/data_tools.py +284 -0
  101. package/src/qingflow_mcp/tools/record_tools.py +11 -46
  102. package/src/qingflow_mcp/tools/solution_tools.py +2 -0
  103. package/src/qingflow_mcp/tools/view_tools.py +0 -6
  104. package/src/qingflow_mcp/tools/view_writer.py +44 -0
  105. package/skills/qingflow-cli/reference/examples/views/views_batch_full.example.json +0 -159
  106. package/skills/qingflow-cli/reference/views_upsert_table_minimal.example.json +0 -42
@@ -0,0 +1,595 @@
1
+ """ViewgraphInfo → 公开视图声明的反向编译器(round-trip 提取器,eval 与 `views get --schema-kind complete` 共用)。
2
+
3
+ 设计对称性:本模块的每个映射与 `service.py` 正向编译(`_hydrate_view_backend_payload` 族)一一对应;
4
+ 「提不出来」的配置面进入 coverage gaps,由调用方汇总进覆盖度报告(evaluation.md §6)。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from .service import (
12
+ ASSOCIATED_RESOURCE_LIMIT_TYPE_ALL,
13
+ _coerce_any_int,
14
+ _coerce_positive_int,
15
+ _is_known_system_view_que_id,
16
+ _extract_view_button_entries,
17
+ _public_view_filter_groups_from_match_rules,
18
+ _public_visibility_from_member_auth,
19
+ _resolve_system_view_button_logical_id,
20
+ )
21
+
22
+ # 后端 viewgraphType → 公开 type(ViewUpsertPatch/PublicViewType)
23
+ _VIEW_TYPE_BY_BACKEND = {
24
+ "tableView": "table",
25
+ "cardView": "card",
26
+ "boardView": "board",
27
+ "ganttView": "gantt",
28
+ "hierarchyView": "hierarchy",
29
+ "calendarView": "calendar",
30
+ }
31
+
32
+ # 后端 dataScope → 公开 data_permission.scope(与 _DATA_PERMISSION_SCOPE_MAP 对称)
33
+ _DATA_SCOPE_TO_PUBLIC = {
34
+ "ALL": "all",
35
+ "ONESELF": "oneself",
36
+ "DEPT": "dept",
37
+ "SUBORDINATE": "subordinate",
38
+ "CUSTOM": "custom",
39
+ }
40
+
41
+ # 后端视图列中可直接引用的系统字段 ID。它们不需要出现在表单业务字段索引中。
42
+ _SYSTEM_COLUMN_NAMES = {
43
+ 0: "apply_num",
44
+ 1: "apply_user",
45
+ 2: "apply_time",
46
+ 3: "update_time",
47
+ 4: "current_node",
48
+ 5: "current_status",
49
+ 6: "current_user",
50
+ -1: "data_source",
51
+ -2: "user_dept",
52
+ -3: "audit_node",
53
+ -4: "audit_node_user",
54
+ -5: "audit_status",
55
+ -6: "audit_time",
56
+ -7: "timeout_time",
57
+ -8: "audit_table",
58
+ -18: "error_info",
59
+ -19: "delete_time",
60
+ -20: "deleter",
61
+ -21: "processing_node",
62
+ -22: "cc_node",
63
+ -23: "processing_time",
64
+ -24: "cc_time",
65
+ -25: "receive_time",
66
+ -30: "app_key",
67
+ -31: "ws_id",
68
+ }
69
+
70
+ # complete 契约不覆盖、读面存在的配置键(进入 SKIP 账本,evaluation.md §4.3)
71
+ _UNCOVERED_READBACK_KEYS = (
72
+ "attachVisibleOnlyConfig",
73
+ "beingRemarkStatus",
74
+ "querySearchStatus",
75
+ "beingDefaultPrintTplStatus",
76
+ "usages",
77
+ )
78
+
79
+
80
+ def _gap(reason_path: str, kind: str, message: str, **details: Any) -> dict[str, Any]:
81
+ gap: dict[str, Any] = {"reason_path": reason_path, "kind": kind, "message": message}
82
+ if details:
83
+ gap["details"] = details
84
+ return gap
85
+
86
+
87
+ def _extract_filters(
88
+ view_info: dict[str, Any],
89
+ *,
90
+ fields_by_que_id: dict[int, dict[str, Any]],
91
+ reason_path: str,
92
+ gaps: list[dict[str, Any]],
93
+ ) -> list[Any] | None:
94
+ """viewgraphLimit → filters。
95
+
96
+ 简易模式(limitType=1):规则组是唯一真源(viewgraphLimitFormula 是后端从规则组派生的存储态,
97
+ 提取时忽略);单组 → 扁平 [rule,...](组内 AND,向后兼容),多组 → 嵌套 [[rule,...],...]
98
+ (组间 OR、组内 AND,与 visible_when/root_filter 的嵌组形态对齐)。
99
+ 公式模式(limitType!=1):用户手写公式串,规则组不含其语义,真不可表达 → gap。
100
+ """
101
+ limit_type = view_info.get("viewgraphLimitType")
102
+ if limit_type not in (None, 1):
103
+ gaps.append(
104
+ _gap(
105
+ f"{reason_path}",
106
+ "formula_filters",
107
+ "viewgraphLimitType!=1 (formula mode) is not expressible in v1alpha1; view is not extractable",
108
+ viewgraphLimitType=limit_type,
109
+ )
110
+ )
111
+ return None
112
+ groups = view_info.get("viewgraphLimit")
113
+ if not groups:
114
+ return None
115
+ raw_groups = [groups] if isinstance(groups, list) and all(isinstance(rule, dict) for rule in groups) else groups
116
+ if not isinstance(raw_groups, list) or any(
117
+ not isinstance(group, list) or not group or any(not isinstance(rule, dict) for rule in group)
118
+ for group in raw_groups
119
+ ):
120
+ gaps.append(_gap(reason_path, "unresolved_filter_rules", "filter groups contain an invalid or empty group"))
121
+ return None
122
+ public_groups = _public_view_filter_groups_from_match_rules(groups, fields_by_que_id=fields_by_que_id)
123
+ if [len(group) for group in public_groups] != [len(group) for group in raw_groups]:
124
+ gaps.append(_gap(reason_path, "unresolved_filter_rules", "filter rules could not be completely extracted"))
125
+ return None
126
+ normalized_groups: list[list[dict[str, Any]]] = []
127
+ for group_index, raw_rules in enumerate(public_groups):
128
+ normalized_group: list[dict[str, Any]] = []
129
+ for rule in raw_rules:
130
+ operator = str(rule.get("operator") or "")
131
+ if operator.startswith("judge_type:"):
132
+ gaps.append(
133
+ _gap(
134
+ f"{reason_path}[{group_index}]",
135
+ "unsupported_operator",
136
+ "filter operator not in the v1alpha1 operator set",
137
+ operator=operator,
138
+ )
139
+ )
140
+ continue
141
+ field_que_id = _coerce_positive_int(rule.get("que_id"))
142
+ if field_que_id is None or field_que_id not in fields_by_que_id:
143
+ gaps.append(
144
+ _gap(
145
+ f"{reason_path}[{group_index}]",
146
+ "unresolved_filter_field",
147
+ "filter rule que_id does not resolve to a form field",
148
+ que_id=rule.get("que_id"),
149
+ )
150
+ )
151
+ continue
152
+ entry: dict[str, Any] = {"que_id": field_que_id, "operator": operator}
153
+ if operator in {"is_empty", "not_empty"}:
154
+ entry["values"] = []
155
+ elif operator == "in" and not (rule.get("values") or []) and rule.get("value") is None:
156
+ # ANY 判型+空值 = "未选任何值"合法形态,judgeType 保真回写
157
+ entry["values"] = []
158
+ elif isinstance(rule.get("values"), list) and rule["values"]:
159
+ entry["values"] = rule["values"]
160
+ elif rule.get("value") is not None:
161
+ entry["values"] = [rule["value"]]
162
+ else:
163
+ gaps.append(
164
+ _gap(
165
+ f"{reason_path}[{group_index}]",
166
+ "empty_filter_values",
167
+ "filter rule has no resolvable values",
168
+ rule=rule,
169
+ )
170
+ )
171
+ continue
172
+ normalized_group.append(entry)
173
+ if normalized_group:
174
+ normalized_groups.append(normalized_group)
175
+ if not normalized_groups:
176
+ return None
177
+ # 单组 → 扁平(兼容既有声明);多组 → 嵌套(OR of AND)
178
+ return normalized_groups[0] if len(normalized_groups) == 1 else normalized_groups
179
+
180
+
181
+ def _extract_sort(
182
+ view_info: dict[str, Any],
183
+ *,
184
+ fields_by_que_id: dict[int, dict[str, Any]],
185
+ reason_path: str,
186
+ gaps: list[dict[str, Any]],
187
+ ) -> list[dict[str, Any]] | None:
188
+ """viewgraphSorts + sortType → sort;defaultSort(含 queId=0 占位)不产出。"""
189
+ if str(view_info.get("sortType") or "").strip() != "customSort":
190
+ return None
191
+ sorts: list[dict[str, Any]] = []
192
+ for index, raw in enumerate(view_info.get("viewgraphSorts") or []):
193
+ if not isinstance(raw, dict):
194
+ continue
195
+ que_id = raw.get("queId")
196
+ if _coerce_positive_int(que_id) in (None, 0):
197
+ continue
198
+ parsed_sort = _coerce_positive_int(que_id)
199
+ if parsed_sort in (None, 0):
200
+ continue
201
+ if parsed_sort not in fields_by_que_id:
202
+ gaps.append(_gap(f"{reason_path}[{index}]", "unresolved_que_id", "sort queId not in form field schema", que_id=parsed_sort))
203
+ continue
204
+ sorts.append({"que_id": parsed_sort, "ascending": bool(raw.get("beingSortAscend", True))})
205
+ return sorts or None
206
+
207
+
208
+ def _extract_buttons(
209
+ view_info: dict[str, Any],
210
+ *,
211
+ fields_by_que_id: dict[int, dict[str, Any]],
212
+ reason_path: str,
213
+ gaps: list[dict[str, Any]],
214
+ ) -> list[dict[str, Any]]:
215
+ """从完整按钮读面提取绑定;DTO 列表仅作为旧读面的回退。"""
216
+ bindings: list[dict[str, Any]] = []
217
+ entries, entry_source = _extract_view_button_entries(view_info)
218
+ if entry_source in {"buttonConfigVO", "buttonConfigDTOList"} and len(entries) != len(view_info[entry_source]):
219
+ gaps.append(_gap(reason_path, "unresolved_button_entry", "button list contains invalid entries"))
220
+ for index, raw in enumerate(entries):
221
+ button_type = str(raw.get("buttonType") or "").upper()
222
+ if not button_type and entry_source == "buttonConfigDTOList":
223
+ button_type = "CUSTOM" if raw.get("buttonId") is not None else "SYSTEM"
224
+ if button_type not in {"SYSTEM", "CUSTOM"}:
225
+ gaps.append(_gap(f"{reason_path}[{index}].button_type", "unresolved_button_type", "button type could not be resolved"))
226
+ continue
227
+ button_id = _coerce_positive_int(raw.get("buttonId"))
228
+ if button_type == "SYSTEM":
229
+ # 系统按钮会由后端重建;自定义按钮必须使用实体 ID,不能使用配置行 ID。
230
+ button_id = _resolve_system_view_button_logical_id(raw) or button_id or _coerce_positive_int(raw.get("id"))
231
+ if button_id is None:
232
+ gaps.append(_gap(f"{reason_path}[{index}].button_id", "button_without_id", "button binding has no resolvable button ID", button_type=button_type))
233
+ continue
234
+ unsupported = [key for key in ("targetFormId", "triggerAddDataConfig", "triggerLinkUrl", "triggerWingsConfig", "httpRequestConfig") if raw.get(key) not in (None, "", [], {})]
235
+ if button_type == "SYSTEM" and unsupported:
236
+ gaps.append(_gap(f"{reason_path}[{index}]", "unsupported_button_config", "button action configuration cannot be represented by a binding", keys=unsupported))
237
+ continue
238
+ binding: dict[str, Any] = {
239
+ "button_id": button_id,
240
+ "config_type": str(raw.get("configType") or "TOP"),
241
+ }
242
+ binding["button_type"] = button_type
243
+ for source_key, target in (
244
+ ("buttonText", "button_text"),
245
+ ("buttonIcon", "button_icon"),
246
+ ("backgroundColor", "background_color"),
247
+ ("textColor", "text_color"),
248
+ ("triggerAction", "trigger_action"),
249
+ ):
250
+ if raw.get(source_key) is not None:
251
+ binding[target] = raw.get(source_key)
252
+ if raw.get("stylePreset"):
253
+ binding["style_preset"] = raw.get("stylePreset")
254
+ binding["being_main"] = bool(raw.get("beingMain"))
255
+ if raw.get("printTpls"):
256
+ binding["print_tpls"] = raw.get("printTpls")
257
+ if raw.get("buttonFormula") is not None:
258
+ binding["button_formula"] = raw.get("buttonFormula")
259
+ binding["button_formula_type"] = raw.get("buttonFormulaType") or 1
260
+ if raw.get("buttonLimit"):
261
+ public_limits = _extract_filters(
262
+ {"viewgraphLimit": raw["buttonLimit"], "viewgraphLimitType": 1},
263
+ fields_by_que_id=fields_by_que_id,
264
+ reason_path=f"{reason_path}[{index}].button_limit",
265
+ gaps=gaps,
266
+ )
267
+ if public_limits and isinstance(public_limits[0], dict):
268
+ public_limits = [public_limits]
269
+ binding["button_limit"] = public_limits or []
270
+ bindings.append(binding)
271
+ return bindings
272
+
273
+
274
+ def _optionally(target: dict[str, Any], key: str, value: Any) -> None:
275
+ if value is None:
276
+ return
277
+ if isinstance(value, str) and not value.strip():
278
+ return
279
+ target[key] = value
280
+
281
+
282
+ def extract_complete_view_declaration(
283
+ view_info: dict[str, Any],
284
+ *,
285
+ fields_by_que_id: dict[int, dict[str, Any]],
286
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
287
+ """把后端 ViewgraphInfo(GET /view/{key}/viewConfig 的 data)反向编译为 complete 档声明。
288
+
289
+ 返回 (declaration, coverage_gaps)。声明可再经 `views validate` 离线校验;
290
+ gaps 覆盖:v1alpha1 契约外的配置面(公式/OR 组/系统列/打印模板等)与解析失败项。
291
+ """
292
+ gaps: list[dict[str, Any]] = []
293
+ name_by_que_id: dict[int, str] = {}
294
+ for que_id, field in fields_by_que_id.items():
295
+ name = str(field.get("name") or "").strip() if isinstance(field, dict) else ""
296
+ if name:
297
+ name_by_que_id[que_id] = name
298
+
299
+ raw_type = str(view_info.get("viewgraphType") or "").strip()
300
+ view_type = _VIEW_TYPE_BY_BACKEND.get(raw_type)
301
+ if view_type is None:
302
+ gaps.append(_gap("type", "unsupported_view_type", "viewgraphType is not a public v1alpha1 type", viewgraphType=raw_type))
303
+ return {}, gaps
304
+
305
+ declaration: dict[str, Any] = {"type": view_type, "name": str(view_info.get("viewgraphName") or "").strip()}
306
+ view_key = str(view_info.get("viewgraphKey") or "").strip()
307
+ if view_key:
308
+ declaration["view_key"] = view_key
309
+
310
+ columns: list[int] = []
311
+ for index, que_id in enumerate(view_info.get("viewgraphQueIds") or []):
312
+ parsed = _coerce_any_int(que_id)
313
+ if parsed in _SYSTEM_COLUMN_NAMES:
314
+ columns.append(parsed)
315
+ continue
316
+ if parsed is None or parsed <= 0:
317
+ gaps.append(
318
+ _gap(f"columns[{index}]", "system_column", "unknown system column is not supported by the public contract", que_id=que_id)
319
+ )
320
+ continue
321
+ if parsed not in fields_by_que_id:
322
+ gaps.append(
323
+ _gap(f"columns[{index}]", "unresolved_que_id", "queId does not exist in the form field schema", que_id=parsed)
324
+ )
325
+ continue
326
+ columns.append(parsed)
327
+ if columns:
328
+ declaration["columns"] = columns
329
+ elif view_type in {"table", "card"}:
330
+ # table/card 至少要有一列;系统列也属于后端可展示列。
331
+ gaps.append(
332
+ _gap("columns", "no_resolvable_columns", "table/card view has no columns resolvable to field names; view is not extractable")
333
+ )
334
+ return {}, gaps
335
+
336
+ group_que_id = _coerce_positive_int(view_info.get("groupQueId"))
337
+ if view_type == "board":
338
+ if group_que_id and group_que_id not in fields_by_que_id:
339
+ gaps.append(_gap("group_que_id", "unresolved_que_id", "board group queId not in form field schema", que_id=group_que_id))
340
+ group_que_id = None
341
+ if group_que_id:
342
+ declaration["group_que_id"] = group_que_id
343
+ else:
344
+ gaps.append(
345
+ _gap("group_by", "unresolved_board_group", "board view groupQueId does not resolve; view is not extractable")
346
+ )
347
+ return {}, gaps
348
+
349
+ gantt_raw = view_info.get("viewgraphGanttConfigVO") if isinstance(view_info.get("viewgraphGanttConfigVO"), dict) else {}
350
+ if view_type == "gantt":
351
+ start_que_id = _coerce_positive_int(gantt_raw.get("startTimeQueId"))
352
+ end_que_id = _coerce_positive_int(gantt_raw.get("endTimeQueId"))
353
+ title_que_id = _coerce_positive_int(gantt_raw.get("titleQueId")) or _coerce_positive_int(view_info.get("titleQue"))
354
+ # 系统列(queId<=5)或已删字段的 queId 不在 fields_by_que_id——不可引用,丢弃+gap
355
+ if start_que_id and start_que_id not in fields_by_que_id:
356
+ gaps.append(_gap("gantt.start_que_id", "unresolved_que_id", "start queId not in form field schema", que_id=start_que_id))
357
+ start_que_id = None
358
+ if end_que_id and end_que_id not in fields_by_que_id:
359
+ gaps.append(_gap("gantt.end_que_id", "unresolved_que_id", "end queId not in form field schema", que_id=end_que_id))
360
+ end_que_id = None
361
+ if title_que_id and title_que_id not in fields_by_que_id:
362
+ gaps.append(_gap("gantt.title_que_id", "unresolved_que_id", "title queId not in form field schema", que_id=title_que_id))
363
+ title_que_id = None
364
+ if not (start_que_id and end_que_id):
365
+ # 契约要求 gantt 必须有 start/end;存量存在配置一半的视图(无起止字段),
366
+ # 判不可提取入账本,而非产出缺字段的非法声明
367
+ gaps.append(_gap("gantt", "unresolved_gantt_config", "gantt start/end fields do not resolve; view is not extractable"))
368
+ return {}, gaps
369
+ declaration["start_que_id"] = start_que_id
370
+ declaration["end_que_id"] = end_que_id
371
+ _optionally(declaration, "title_que_id", title_que_id)
372
+ gantt: dict[str, Any] = {}
373
+ _optionally(gantt, "title_que_id", title_que_id)
374
+ _optionally(gantt, "start_que_id", start_que_id)
375
+ _optionally(gantt, "end_que_id", end_que_id)
376
+ _optionally(gantt, "default_time_dimension", gantt_raw.get("defaultTimeDimension"))
377
+ groups: list[dict[str, Any]] = []
378
+ for raw_group in gantt_raw.get("ganttGroupVOList") or []:
379
+ if not isinstance(raw_group, dict):
380
+ continue
381
+ group_que = _coerce_positive_int(raw_group.get("groupQueId"))
382
+ if group_que:
383
+ entry: dict[str, Any] = {"que_id": group_que}
384
+ _optionally(entry, "ordinal", raw_group.get("ordinal"))
385
+ groups.append(entry)
386
+ if groups:
387
+ gantt["groups"] = groups
388
+ dependency_raw = gantt_raw.get("ganttDependencyVO") if isinstance(gantt_raw.get("ganttDependencyVO"), dict) else {}
389
+ if dependency_raw.get("dependencyQueId") is not None or dependency_raw.get("predecessorTaskQueId") is not None:
390
+ dependency: dict[str, Any] = {}
391
+ dep_que = _coerce_positive_int(dependency_raw.get("dependencyQueId"))
392
+ pred_que = _coerce_positive_int(dependency_raw.get("predecessorTaskQueId"))
393
+ if dep_que and pred_que:
394
+ dependency = {"dependency_que_id": dep_que, "predecessor_que_id": pred_que}
395
+ _optionally(dependency, "start_start_option_id", dependency_raw.get("startStartOptionId"))
396
+ _optionally(dependency, "start_end_option_id", dependency_raw.get("startEndOptionId"))
397
+ _optionally(dependency, "end_start_option_id", dependency_raw.get("endStartOptionId"))
398
+ _optionally(dependency, "end_end_option_id", dependency_raw.get("endEndOptionId"))
399
+ gantt["dependencies"] = [dependency]
400
+ else:
401
+ gaps.append(_gap("gantt.dependencies", "unresolved_gantt_dependency", "gantt dependency queIds do not resolve", raw=dependency_raw))
402
+ if gantt:
403
+ declaration["gantt"] = gantt
404
+
405
+ hierarchy_raw = view_info.get("viewgraphHierarchyConfigVO") if isinstance(view_info.get("viewgraphHierarchyConfigVO"), dict) else {}
406
+ if view_type == "hierarchy":
407
+ title_que = _coerce_positive_int(hierarchy_raw.get("titleQueId"))
408
+ children_que = _coerce_positive_int(hierarchy_raw.get("childrenQueId"))
409
+ if not (title_que and children_que):
410
+ gaps.append(_gap("hierarchy", "unresolved_hierarchy_config", "hierarchy title/children queIds do not resolve; view is not extractable"))
411
+ return {}, gaps
412
+ hierarchy: dict[str, Any] = {"title_que_id": title_que, "children_que_id": children_que}
413
+ root_groups = _public_view_filter_groups_from_match_rules(
414
+ hierarchy_raw.get("rootNodeFilter"), fields_by_que_id=fields_by_que_id
415
+ )
416
+ if root_groups:
417
+ hierarchy["root_filter"] = root_groups
418
+ declaration["hierarchy"] = hierarchy
419
+ _optionally(declaration, "title_que_id", title_que)
420
+
421
+ if view_type == "calendar":
422
+ calendar_raw = view_info.get("viewgraphCalendarConfigVO") if isinstance(view_info.get("viewgraphCalendarConfigVO"), dict) else {}
423
+ title_que = _coerce_positive_int(calendar_raw.get("titleQueId"))
424
+ date_que = _coerce_any_int(calendar_raw.get("dateQueId"))
425
+ color_que = _coerce_positive_int(calendar_raw.get("colorQueId"))
426
+ if title_que is None or title_que not in fields_by_que_id:
427
+ gaps.append(_gap("calendar.title_que_id", "unresolved_calendar_config", "calendar title queId does not resolve; view is not extractable", que_id=title_que))
428
+ return {}, gaps
429
+ if date_que is None or (date_que not in {2, 3} and date_que not in fields_by_que_id):
430
+ gaps.append(_gap("calendar.date_que_id", "unresolved_calendar_config", "calendar date queId does not resolve; view is not extractable", que_id=date_que))
431
+ return {}, gaps
432
+ calendar: dict[str, Any] = {"title_que_id": title_que, "date_que_id": date_que}
433
+ if color_que is not None:
434
+ if color_que not in fields_by_que_id:
435
+ gaps.append(_gap("calendar.color_que_id", "unresolved_que_id", "calendar color queId does not resolve", que_id=color_que))
436
+ return {}, gaps
437
+ calendar["color_que_id"] = color_que
438
+ declaration["calendar"] = calendar
439
+ declaration["title_que_id"] = title_que
440
+
441
+ if view_type in {"card", "board"}:
442
+ _optionally(declaration, "title_que_id", _coerce_positive_int(view_info.get("titleQue")))
443
+
444
+ if view_type == "card":
445
+ card: dict[str, Any] = {}
446
+ for raw_key, gap_key in (("titleQue", "title_que_id"), ("frontCoverQueId", "cover_que_id")):
447
+ raw_val = _coerce_positive_int(view_info.get(raw_key))
448
+ if raw_val and raw_val not in fields_by_que_id:
449
+ gaps.append(_gap(f"card.{gap_key}", "unresolved_que_id", f"card {gap_key} not in form field schema", que_id=raw_val))
450
+ else:
451
+ _optionally(card, gap_key, raw_val)
452
+ _optionally(card, "clipping_mode", view_info.get("clippingMode"))
453
+ _optionally(card, "image_adaption", view_info.get("beingImageAdaption"))
454
+ _optionally(card, "show_field_title", view_info.get("beingShowQueTitle"))
455
+ _optionally(card, "show_title", view_info.get("beingShowTitleQue"))
456
+ _optionally(card, "show_cover", view_info.get("beingShowCover"))
457
+ if group_que_id and group_que_id not in fields_by_que_id:
458
+ gaps.append(_gap("card.group_que_id", "unresolved_que_id", "card group queId not in form field schema", que_id=group_que_id))
459
+ else:
460
+ _optionally(card, "group_que_id", group_que_id)
461
+ _optionally(card, "group_color", view_info.get("beingGroupColor"))
462
+ if card:
463
+ declaration["card"] = card
464
+
465
+ filter_gap_count = len(gaps)
466
+ filters = _extract_filters(view_info, fields_by_que_id=fields_by_que_id, reason_path="filters", gaps=gaps)
467
+ if len(gaps) > filter_gap_count:
468
+ return {}, gaps
469
+ if filters is not None:
470
+ declaration["filters"] = filters
471
+
472
+ data_permission_type = str(view_info.get("dataPermissionType") or "").strip().upper()
473
+ data_scope = _DATA_SCOPE_TO_PUBLIC.get(str(view_info.get("dataScope") or "").strip().upper())
474
+ if data_permission_type or data_scope:
475
+ declaration["data_permission"] = {
476
+ "type": "config" if data_permission_type == "CONFIG" else "custom",
477
+ "scope": data_scope or "all",
478
+ }
479
+
480
+ sort = _extract_sort(view_info, fields_by_que_id=fields_by_que_id, reason_path="sort", gaps=gaps)
481
+ if sort is not None:
482
+ declaration["sort"] = sort
483
+
484
+ auth = view_info.get("auth")
485
+ if auth is not None:
486
+ visibility = _public_visibility_from_member_auth(auth)
487
+ # mode="specific" + 空 selectors 是合法形态(auth=2 但未配置成员);
488
+ # apply 回写 auth=2 + 空成员列表,语义保真,不降级。
489
+ declaration["visibility"] = visibility
490
+
491
+ button_gap_count = len(gaps)
492
+ buttons = _extract_buttons(view_info, fields_by_que_id=fields_by_que_id, reason_path="buttons", gaps=gaps)
493
+ if len(gaps) > button_gap_count:
494
+ return {}, gaps
495
+ if _extract_view_button_entries(view_info)[1] is not None:
496
+ declaration["buttons"] = buttons
497
+
498
+ if view_info.get("queryConditionStatus") is not None:
499
+ rows: list[list[str]] = []
500
+ for raw_row in view_info.get("queryCondition") or []:
501
+ if not isinstance(raw_row, dict):
502
+ continue
503
+ row_names: list[str] = []
504
+ for index, que_id in enumerate(raw_row.get("queIds") or []):
505
+ parsed = _coerce_any_int(que_id)
506
+ if parsed is None:
507
+ continue
508
+ if _is_known_system_view_que_id(parsed) or parsed in fields_by_que_id:
509
+ row_names.append(parsed)
510
+ if row_names:
511
+ rows.append(row_names)
512
+ if view_info.get("queryConditionStatus") and not rows:
513
+ gaps.append(_gap("query_conditions", "unresolved_query_condition", "queryConditionStatus=true but no queIds resolve to field names"))
514
+ else:
515
+ declaration["query_conditions"] = {
516
+ "enabled": bool(view_info.get("queryConditionStatus")),
517
+ "exact": bool(view_info.get("queryConditionExact")),
518
+ "hide_before_query": bool(view_info.get("hideBeforeQueryCondition")),
519
+ "rows": rows,
520
+ }
521
+
522
+ asos_raw = view_info.get("asosChartConfig") if isinstance(view_info.get("asosChartConfig"), dict) else {}
523
+ asos_visible = view_info.get("asosChartVisible")
524
+ if asos_visible is not None or asos_raw:
525
+ associated: dict[str, Any] = {"visible": bool(asos_visible)}
526
+ if associated["visible"]:
527
+ limit_type = asos_raw.get("limitType")
528
+ if limit_type is not None:
529
+ associated["limit_type"] = "all" if int(limit_type) == ASSOCIATED_RESOURCE_LIMIT_TYPE_ALL else "select"
530
+ item_ids = [item for item in asos_raw.get("asosChartIdList") or [] if item is not None]
531
+ if item_ids:
532
+ associated["associated_item_ids"] = item_ids
533
+ declaration["associated_resources"] = associated
534
+
535
+ display: dict[str, Any] = {}
536
+ _optionally(display, "row_height", view_info.get("defaultRowHigh"))
537
+ _optionally(display, "pin_navigate", view_info.get("beingPinNavigate"))
538
+ _optionally(display, "remark", view_info.get("remark"))
539
+ if display:
540
+ declaration["display"] = display
541
+
542
+ record: dict[str, Any] = {}
543
+ for source, target in (
544
+ ("beingAuditRecordVisible", "audit_visible"),
545
+ ("beingDataLogVisible", "data_log_visible"),
546
+ ("beingQrobotRecordVisible", "qrobot_record_visible"),
547
+ ("beingWorkflowNodeFutureListVisible", "workflow_future_visible"),
548
+ ("beingCommentStatus", "comment"),
549
+ ):
550
+ if view_info.get(source) is not None:
551
+ record[target] = bool(view_info.get(source))
552
+ if record:
553
+ declaration["record"] = record
554
+
555
+ share: dict[str, Any] = {}
556
+ if view_info.get("beingNeedPass") is not None:
557
+ share["need_pass"] = bool(view_info.get("beingNeedPass"))
558
+ # 历史数据保全项(legacy_data_preserved):存量密码不符合当前安全策略(49603)——
559
+ # 视图配置本身合法,后端策略演进导致无法原样回写。提取不回传敏感值,
560
+ # apply 显式清空触发后端 isNotBlank(空)保留语义,原密码/开关零丢失。
561
+ # 这类不是失败,单列统计。
562
+ if view_info.get("beingNeedPass") and str(view_info.get("viewgraphPass") or "").strip():
563
+ gaps.append(_gap("share.password", "legacy_password_preserved", "legacy password not round-tripped (backend policy); original preserved via isNotBlank semantics"))
564
+ if view_info.get("beingForceLogin") is not None:
565
+ share["force_login"] = bool(view_info.get("beingForceLogin"))
566
+ if view_info.get("beingPrintStatus") is not None:
567
+ share["print_enabled"] = bool(view_info.get("beingPrintStatus"))
568
+ if share:
569
+ declaration["share"] = share
570
+
571
+ if view_info.get("printTpls"):
572
+ gaps.append(_gap("share.print", "print_templates", "printTpls details are deferred to v1alpha2", count=len(view_info.get("printTpls") or [])))
573
+ for key in _UNCOVERED_READBACK_KEYS:
574
+ value = view_info.get(key)
575
+ if value in (None, [], {}, "", False):
576
+ continue
577
+ if key == "querySearchStatus" and value is True and view_info.get("queryConditionStatus") is None:
578
+ continue
579
+ gaps.append(_gap(key, "uncovered_readback_key", "read-back key present in backend payload but outside the v1alpha1 contract", value_preview=str(value)[:80]))
580
+
581
+ return declaration, gaps
582
+
583
+
584
+ def summarize_gaps(gaps: list[dict[str, Any]]) -> dict[str, int]:
585
+ """按 kind 聚合 coverage gaps,供覆盖度报告使用。"""
586
+ summary: dict[str, int] = {}
587
+ for gap in gaps:
588
+ summary[gap.get("kind") or "unknown"] = summary.get(gap.get("kind") or "unknown", 0) + 1
589
+ return summary
590
+
591
+
592
+ __all__ = [
593
+ "extract_complete_view_declaration",
594
+ "summarize_gaps",
595
+ ]