@qingflow-tech/qingflow-cli 1.1.50-beta.5 → 1.1.51

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