h3factory 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +241 -2
- package/bin/h3factory.js +87 -0
- package/cli.py +951 -0
- package/config.example.json +8 -0
- package/docs/platform_gotchas.md +118 -0
- package/docs/requirement_checklist.md +54 -0
- package/docs/schema_doc.md +216 -0
- package/docs/workflow.md +146 -0
- package/h3design/__init__.py +0 -0
- package/h3design/controls.py +414 -0
- package/h3design/dsl.py +553 -0
- package/h3design/newbuilder.py +852 -0
- package/h3platform/__init__.py +0 -0
- package/h3platform/api.py +214 -0
- package/h3platform/h3console.py +99 -0
- package/h3platform/settings.py +74 -0
- package/h3verify/__init__.py +0 -0
- package/h3verify/verify.py +217 -0
- package/package.json +51 -4
- package/projects/demo/dicts.json +10 -0
- package/projects/demo/generated/product.cs +82 -0
- package/projects/demo/groups.json +3 -0
- package/projects/demo/sheets/product.json +24 -0
- package/scripts/postinstall.js +109 -0
- package/skills/h3factory/SKILL.md +137 -0
- package/skills/h3yun-dev-assistant/BACKEND_GUIDELINES.md +120 -0
- package/skills/h3yun-dev-assistant/DEV_DOC_ROUTING.md +39 -0
- package/skills/h3yun-dev-assistant/FORM_ACTIONS_GUIDELINES.md +116 -0
- package/skills/h3yun-dev-assistant/FRONTEND_GUIDELINES.md +47 -0
- package/skills/h3yun-dev-assistant/OPENAPI_GUIDELINES.md +8 -0
- package/skills/h3yun-dev-assistant/PLATFORM_CONSTRAINTS.md +160 -0
- package/skills/h3yun-dev-assistant/POST_GUIDELINES.md +47 -0
- package/skills/h3yun-dev-assistant/PRACTICE_NOTES.md +626 -0
- package/skills/h3yun-dev-assistant/SKILL.md +70 -0
- package/skills/h3yun-dev-assistant/SQL_GUIDELINES.md +9 -0
- package/skills/h3yun-dev-assistant/TOOL_FUNCTIONS.md +96 -0
- package/templates/controller.cs.tpl +78 -0
package/h3design/dsl.py
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""表单 DSL:项目定义 → 与奥驰 forms.build 同构的控件结构。
|
|
3
|
+
|
|
4
|
+
项目目录约定(projects/<name>/):
|
|
5
|
+
config.json 连接配置(缺省用工作区/用户配置;含凭据,勿提交/分享)
|
|
6
|
+
dicts.json 枚举分类,下拉/单选/复选的选项源
|
|
7
|
+
sheets/*.json 每表单一个 JSON 定义(文件名 = 表单 key)—— 推荐模式
|
|
8
|
+
forms.py 旧版 python 定义(legacy 兼容,见 examples/aochi 迁移说明)
|
|
9
|
+
|
|
10
|
+
load_project() 返回 {key: loader},loader() 产出
|
|
11
|
+
{key, title, name_schema, fields, layout, assoc, useOwner}
|
|
12
|
+
其中 fields/layout 直接可喂给 newbuilder.build_schema_str / build_biz_sheet;
|
|
13
|
+
useOwner 供 build 期望字段数补 OwnerId/OwnerDeptId 两系统字段。
|
|
14
|
+
JSON 完整格式见 docs/schema_doc.md。"""
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from . import controls as C
|
|
21
|
+
|
|
22
|
+
# =====================================================================
|
|
23
|
+
# 系统拥有者字段(OwnerId/OwnerDeptId):Layout 专用伪控件,
|
|
24
|
+
# 不进 fields/Properties(系统字段),Options 逐字对照线上回读样本。
|
|
25
|
+
# useOwner=true 后可在 layout 行内以字符串 "OwnerId"/"OwnerDeptId" 引用。
|
|
26
|
+
# =====================================================================
|
|
27
|
+
_OWNER_OPT = {
|
|
28
|
+
"Key": "OwnerId", "Type": 203, "ChildControls": None, "Options": {
|
|
29
|
+
"DataField": "OwnerId", "DisplayName": "申请人", "Description": "",
|
|
30
|
+
"ControlKey": "FormUser", "Summary": "", "DisplayRule": {"Rule": ""},
|
|
31
|
+
"UnitSelectionRange": "", "IsRelatedMember": False, "ShowUnActive": False,
|
|
32
|
+
"UseDataCache": True, "CurrentUserId": False,
|
|
33
|
+
"MappingControls": '{"ParentId":"OwnerDeptId"}',
|
|
34
|
+
"OrgUnitVisible": False, "UserVisible": True, "OwnDepVisible": False,
|
|
35
|
+
"OwnDepUsersVisble": True, "IsMultiple": False, "ShowCurUser": False}}
|
|
36
|
+
_OWNER_DEPT_OPT = {
|
|
37
|
+
"Key": "OwnerDeptId", "Type": 204, "ChildControls": None, "Options": {
|
|
38
|
+
"DataField": "OwnerDeptId", "DisplayName": "申请部门", "Description": "",
|
|
39
|
+
"ControlKey": "FormDepartment", "Summary": "", "DisplayRule": {"Rule": ""},
|
|
40
|
+
"UnitSelectionRange": "", "IsRelatedMember": False, "ShowUnActive": False,
|
|
41
|
+
"UseDataCache": True, "CurrentUserId": False, "MappingControls": "",
|
|
42
|
+
"OrgUnitVisible": True, "UserVisible": False, "OwnDepVisible": True,
|
|
43
|
+
"OwnDepUsersVisble": False, "IsMultiple": False}}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def owner_presets(applicant_label="申请人", dept_label="申请部门"):
|
|
47
|
+
"""两个系统拥有者布局项(拷贝后改显示名)"""
|
|
48
|
+
o, d = _OWNER_OPT, _OWNER_DEPT_OPT
|
|
49
|
+
if applicant_label != "申请人":
|
|
50
|
+
o = json.loads(json.dumps(o, ensure_ascii=False))
|
|
51
|
+
o["Options"]["DisplayName"] = applicant_label
|
|
52
|
+
if dept_label != "申请部门":
|
|
53
|
+
d = json.loads(json.dumps(d, ensure_ascii=False))
|
|
54
|
+
d["Options"]["DisplayName"] = dept_label
|
|
55
|
+
return o, d
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _read_json(path, what=""):
|
|
59
|
+
with open(path, encoding="utf-8") as f:
|
|
60
|
+
return json.load(f)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _dict_entries(proj_dir, cat):
|
|
64
|
+
"""dicts.json 中分类枚举 → [{"code":..., "name":...}]。
|
|
65
|
+
条目简写(纯字符串)或 {code?, name, enabled?};数组顺序即排序。"""
|
|
66
|
+
dp = os.path.join(proj_dir, "dicts.json")
|
|
67
|
+
if not os.path.isfile(dp):
|
|
68
|
+
raise ValueError("引用字典分类 %r 但项目缺 %s" % (cat, dp))
|
|
69
|
+
d = _read_json(dp)
|
|
70
|
+
items = d.get(cat)
|
|
71
|
+
if items is None:
|
|
72
|
+
raise ValueError("dicts.json 缺分类 %r(现有 %s)" % (cat, list(d)))
|
|
73
|
+
out = []
|
|
74
|
+
for it in items:
|
|
75
|
+
if isinstance(it, str):
|
|
76
|
+
out.append({"code": it, "name": it})
|
|
77
|
+
elif isinstance(it, dict):
|
|
78
|
+
if it.get("enabled") is False:
|
|
79
|
+
continue
|
|
80
|
+
name = it["name"]
|
|
81
|
+
out.append({"code": it.get("code") or name, "name": name})
|
|
82
|
+
else:
|
|
83
|
+
raise ValueError("分类 %r 条目须为字符串或对象: %r" % (cat, it))
|
|
84
|
+
if not out:
|
|
85
|
+
raise ValueError("分类 %r 无可启用条目" % cat)
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _options(proj_dir, spec):
|
|
90
|
+
"""控件选项源:dict=分类名引用 dicts.json;options=字面量数组"""
|
|
91
|
+
if spec.get("dict"):
|
|
92
|
+
return [e["name"] for e in _dict_entries(proj_dir, spec["dict"])]
|
|
93
|
+
if spec.get("options"):
|
|
94
|
+
return [str(x) for x in spec["options"]]
|
|
95
|
+
raise ValueError("%s.%s 需 dict 或 options" % (spec.get("key"), spec.get("type")))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _rule(spec):
|
|
99
|
+
return spec.get("hideWhen") or ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# 联动下拉(选项来自另一张表)过滤规则序列化。声明形态(schema_doc 联动下拉章节):
|
|
103
|
+
# {field: 源表字段编码, op: "="(缺省) | "contains", value: 常量字符串 | ref: 本表字段key}
|
|
104
|
+
# 实证语法(2026-09-09 类型试验 三处载荷逐字):常量等值 {源.字段}=="值"、
|
|
105
|
+
# 动态引用 {源.字段}=={本表字段}、多值列包含 CONTAINS({源.字段},{本表字段}),
|
|
106
|
+
# 条件间 " AND " 连接。发 clean 文本(== 无空格、无 NBSP)——引擎直接接受
|
|
107
|
+
# (第一轮无 NBSP 样本保存成功);NBSP/空格是 UI 重存才插入的漂移。
|
|
108
|
+
def _filter_text(spec, src_code, key):
|
|
109
|
+
entries = spec.get("filter")
|
|
110
|
+
if entries in (None, [], ""):
|
|
111
|
+
return ""
|
|
112
|
+
if not isinstance(entries, list):
|
|
113
|
+
raise ValueError("%s: filter 须为条件数组({field,op,value|ref})" % key)
|
|
114
|
+
parts = []
|
|
115
|
+
for it in entries:
|
|
116
|
+
if not isinstance(it, dict):
|
|
117
|
+
raise ValueError("%s: filter 条目须为对象: %r" % (key, it))
|
|
118
|
+
f = it.get("field")
|
|
119
|
+
if not isinstance(f, str) or not f:
|
|
120
|
+
raise ValueError("%s: filter 条目缺 field(源表字段编码): %r" % (key, it))
|
|
121
|
+
op, ref, value = it.get("op", "="), it.get("ref"), it.get("value")
|
|
122
|
+
lhs = "{%s.%s}" % (src_code, f)
|
|
123
|
+
if op == "contains":
|
|
124
|
+
if ref is None or "value" in it:
|
|
125
|
+
raise ValueError("%s: contains 条件须且只须 ref(本表字段 key,"
|
|
126
|
+
"CONTAINS 按分号串包含匹配源表该列): %r" % (key, it))
|
|
127
|
+
parts.append("CONTAINS(%s,{%s})" % (lhs, ref))
|
|
128
|
+
elif op == "=":
|
|
129
|
+
if (ref is None) == ("value" not in it):
|
|
130
|
+
raise ValueError("%s: 等值条件须且只须 value(常量)或 ref"
|
|
131
|
+
"(本表字段动态引用)其一: %r" % (key, it))
|
|
132
|
+
if "value" in it:
|
|
133
|
+
if not isinstance(value, str):
|
|
134
|
+
raise ValueError("%s: 常量 value 须为字符串(数字常量带引号"
|
|
135
|
+
"比较未实证): %r" % (key, it))
|
|
136
|
+
parts.append("%s==%s" % (lhs, json.dumps(value, ensure_ascii=False)))
|
|
137
|
+
else:
|
|
138
|
+
parts.append("%s=={%s}" % (lhs, ref))
|
|
139
|
+
else:
|
|
140
|
+
raise ValueError("%s: filter op 仅支持 =/contains(实证集合),得 %r"
|
|
141
|
+
% (key, op))
|
|
142
|
+
return " AND ".join(parts)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _assoc_dropdown(spec, registry, app_code):
|
|
146
|
+
"""dropdown spec(带 assoc)→ C.assoc_dropdown 控件。assoc 引 registry 里
|
|
147
|
+
已注册的源表单 key(先建被引用表,同 query);存值列与过滤条件在此校验。"""
|
|
148
|
+
key, label = spec["key"], spec.get("label") or spec["key"]
|
|
149
|
+
if spec.get("dict") or spec.get("options"):
|
|
150
|
+
raise ValueError("%s: assoc 下拉选项来自另一张表,不能再给 dict/options" % key)
|
|
151
|
+
if spec.get("default") not in (None, ""):
|
|
152
|
+
raise ValueError("%s: assoc 下拉不支持 default(存值/回显语义未实证)" % key)
|
|
153
|
+
mf = spec.get("assocField")
|
|
154
|
+
if not isinstance(mf, str) or not mf:
|
|
155
|
+
raise ValueError("%s: assoc 下拉须声明 assocField(存值列 = 源表字段编码,"
|
|
156
|
+
"如 \"code\" 存代码、\"name\" 存名称)" % key)
|
|
157
|
+
src = _assoc_code(spec["assoc"], registry, key)
|
|
158
|
+
return C.assoc_dropdown(key, label, src, mf,
|
|
159
|
+
filter_rule=_filter_text(spec, src, key),
|
|
160
|
+
app_package=app_code, rule=_rule(spec))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# 子表列支持的控件类型(UI 实证范围:文本/数字/日期/开关/单选/复选/下拉;成员/部门/
|
|
164
|
+
# 关联/嵌套子表暂不开放 —— 平台 104 列节点 Options 结构未实证)
|
|
165
|
+
GRID_COL_TYPES = ("text", "textarea", "number", "date", "switch",
|
|
166
|
+
"radio", "dropdown", "checkbox_list",
|
|
167
|
+
# 2026-09-11 起开放附件/图片列 —— 与主表同款 Options 形状,
|
|
168
|
+
# 线上实证见 projects/_types7_online trial8(列节点回读逐键比对)
|
|
169
|
+
"attachment", "image")
|
|
170
|
+
|
|
171
|
+
# 布局项:只占版面、不是字段(不进 SchemaStr.Properties、不进 verify 的字段集)
|
|
172
|
+
# group_title —— 分组标题(type 101,Options 只有 DisplayName/Title/Alignment)
|
|
173
|
+
# description —— 描述/说明文字(type 103,Content 为富文本 HTML)
|
|
174
|
+
LAYOUT_TYPES = ("group_title", "description")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _build_layout_item(spec):
|
|
178
|
+
"""布局项 spec → controls.group_title / controls.description 控件。"""
|
|
179
|
+
t = spec["type"]
|
|
180
|
+
key = spec["key"]
|
|
181
|
+
label = spec.get("label") or spec.get("title") or key
|
|
182
|
+
if t == "group_title":
|
|
183
|
+
align = spec.get("align") or "left"
|
|
184
|
+
if align not in ("left", "center", "right"):
|
|
185
|
+
raise ValueError("%s: group_title 的 align 只支持 left/center/right" % key)
|
|
186
|
+
return C.group_title(key, label, alignment=align)
|
|
187
|
+
content = spec.get("content")
|
|
188
|
+
if content is None:
|
|
189
|
+
content = "<p>%s</p>" % label # 简写:没给 content 就用 label 兜底
|
|
190
|
+
if not isinstance(content, str):
|
|
191
|
+
raise ValueError("%s: description 的 content 须为 HTML 字符串" % key)
|
|
192
|
+
return C.description(key, content, display_name=spec.get("title") or "描述说明",
|
|
193
|
+
rule=_rule(spec))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _assoc_code(assoc, registry, label=""):
|
|
197
|
+
if not assoc:
|
|
198
|
+
return ""
|
|
199
|
+
rec = (registry or {}).get(assoc) or {}
|
|
200
|
+
code = rec.get("code")
|
|
201
|
+
if not code:
|
|
202
|
+
raise ValueError("%s关联表单 %r 未注册(registry 无 code,先建被引用表)"
|
|
203
|
+
% (label, assoc))
|
|
204
|
+
return code
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
SEQNO_KEY = "SeqNo" # 流水号字段编码固定 SeqNo(线上样本同款),定义里可省
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _build_control(proj_dir, spec, registry, app_code):
|
|
211
|
+
t = spec["type"]
|
|
212
|
+
key = spec.get("key") or (SEQNO_KEY if t in ("seq_no", "seqno") else None)
|
|
213
|
+
if not key:
|
|
214
|
+
raise ValueError("控件缺 key(type=%r);只有流水号可省,编码恒为 %s"
|
|
215
|
+
% (t, SEQNO_KEY))
|
|
216
|
+
if spec.get("assoc") and t not in ("dropdown", "query"):
|
|
217
|
+
raise ValueError("%s: %s 不支持 assoc(选项来自另一张表只有下拉实证过,"
|
|
218
|
+
"radio/checkbox_list 不能联动)" % (key, t))
|
|
219
|
+
label = spec.get("label") or ("流水号" if t in ("seq_no", "seqno") else key)
|
|
220
|
+
common = {"required": spec.get("required", False),
|
|
221
|
+
"readonly": spec.get("readonly", False)}
|
|
222
|
+
if t == "text":
|
|
223
|
+
return C.text(key, label, default=spec.get("default", ""),
|
|
224
|
+
placeholder=spec.get("placeholder", ""),
|
|
225
|
+
rule=_rule(spec))
|
|
226
|
+
if t == "textarea":
|
|
227
|
+
return C.textarea(key, label, rows=spec.get("rows", 3),
|
|
228
|
+
default=spec.get("default", ""))
|
|
229
|
+
if t == "number":
|
|
230
|
+
return C.number(key, label, decimal=spec.get("decimal", 0),
|
|
231
|
+
default=spec.get("default"), rule=_rule(spec))
|
|
232
|
+
if t == "date":
|
|
233
|
+
return C.date_field(key, label,
|
|
234
|
+
datetime_mode=spec.get("datetime", "yyyy-MM-dd"),
|
|
235
|
+
rule=_rule(spec))
|
|
236
|
+
if t == "switch":
|
|
237
|
+
return C.switch_field(key, label, checked=bool(spec.get("checked", False)))
|
|
238
|
+
if t == "radio":
|
|
239
|
+
return C.radio(key, label, _options(proj_dir, spec),
|
|
240
|
+
default=spec.get("default", ""))
|
|
241
|
+
if t == "dropdown":
|
|
242
|
+
if spec.get("assoc"):
|
|
243
|
+
return _assoc_dropdown(spec, registry, app_code)
|
|
244
|
+
return C.dropdown(key, label, _options(proj_dir, spec),
|
|
245
|
+
default=spec.get("default", ""), rule=_rule(spec))
|
|
246
|
+
if t == "checkbox_list":
|
|
247
|
+
return C.checkbox_list(key, label, _options(proj_dir, spec),
|
|
248
|
+
default_items=spec.get("defaults", ()))
|
|
249
|
+
if t == "member":
|
|
250
|
+
return C.member(key, label, multi=bool(spec.get("multi", False)),
|
|
251
|
+
rule=_rule(spec))
|
|
252
|
+
if t == "department":
|
|
253
|
+
return C.department(key, label, multi=bool(spec.get("multi", False)),
|
|
254
|
+
rule=_rule(spec))
|
|
255
|
+
if t == "query":
|
|
256
|
+
code = _assoc_code(spec.get("assoc"), registry, key)
|
|
257
|
+
return C.query(key, label, code, app_package=app_code,
|
|
258
|
+
multi=bool(spec.get("multi", False)), rule=_rule(spec))
|
|
259
|
+
if t in ("seq_no", "seqno"):
|
|
260
|
+
if spec.get("default") or spec.get("dict") or spec.get("options"):
|
|
261
|
+
raise ValueError("%s: 流水号不能给 default/dict/options(值由平台按规则发号)"
|
|
262
|
+
% key)
|
|
263
|
+
try:
|
|
264
|
+
inc = int(spec.get("increment", 8))
|
|
265
|
+
except (TypeError, ValueError):
|
|
266
|
+
raise ValueError("%s: 流水号 increment 须为整数(位数,如 8)" % key)
|
|
267
|
+
mode = spec.get("datetime", "YYYY")
|
|
268
|
+
if not isinstance(mode, str) or not mode:
|
|
269
|
+
raise ValueError("%s: 流水号 datetime 须为字符串(如 YYYY/YYYYMM/YYYYMMDD)"
|
|
270
|
+
% key)
|
|
271
|
+
return C.seqno(key, label, prefix=spec.get("prefix", ""),
|
|
272
|
+
datetime_mode=mode, increment=inc)
|
|
273
|
+
if t in ("image", "photo"):
|
|
274
|
+
return C.photo(key, label, multiple=bool(spec.get("multiple", False)),
|
|
275
|
+
camera_only=bool(spec.get("cameraOnly", False)),
|
|
276
|
+
watermark=bool(spec.get("watermark", False)),
|
|
277
|
+
compression=bool(spec.get("compression", False)),
|
|
278
|
+
rule=_rule(spec))
|
|
279
|
+
if t == "attachment":
|
|
280
|
+
try:
|
|
281
|
+
mb = int(spec.get("maxSize", 10))
|
|
282
|
+
except (TypeError, ValueError):
|
|
283
|
+
raise ValueError("%s: 附件的 maxSize 须为整数(MB)" % key)
|
|
284
|
+
return C.attachment(key, label, max_upload_size=mb, rule=_rule(spec))
|
|
285
|
+
if t in ("location", "map"):
|
|
286
|
+
try:
|
|
287
|
+
meters = int(spec.get("meters", 500))
|
|
288
|
+
except (TypeError, ValueError):
|
|
289
|
+
raise ValueError("%s: 位置的 meters 须为整数(米)" % key)
|
|
290
|
+
return C.location(key, label, meters=meters,
|
|
291
|
+
pc_enabled=bool(spec.get("pcEnabled", True)),
|
|
292
|
+
editable=bool(spec.get("editable", False)),
|
|
293
|
+
rule=_rule(spec))
|
|
294
|
+
if t == "address":
|
|
295
|
+
mode = spec.get("areaMode", "P-C-T")
|
|
296
|
+
if mode != "P-C-T":
|
|
297
|
+
raise ValueError("%s: 地址的 areaMode 目前只实证过 P-C-T(省-市-县)" % key)
|
|
298
|
+
return C.area(key, label, area_mode=mode,
|
|
299
|
+
show_detail=bool(spec.get("showDetail", True)),
|
|
300
|
+
rule=_rule(spec))
|
|
301
|
+
raise ValueError("未知控件类型 %r(%s)" % (t, key))
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _resolve_layout(sheet, seq_default, fields_by_key, use_owner,
|
|
305
|
+
extras_by_key=None, sub_keys=()):
|
|
306
|
+
"""layout 定义 → newbuilder.build_biz_sheet 的行序列。
|
|
307
|
+
元素 = 控件 dict(整行单个)或控件 list(一行多列 ≤4);
|
|
308
|
+
"OwnerId"/"OwnerDeptId" 字符串须 useOwner=true 才可用。
|
|
309
|
+
布局项(分组标题/描述)**只能单独成行**,且与 UI 样本一致作顶层条目
|
|
310
|
+
(extras_by_key 按定义侧短码 DefKey 索引;key 用 GUID 见 controls.layout_key)。
|
|
311
|
+
子表 key 不可进 layout:子表整行自动排在表单底部。"""
|
|
312
|
+
extras_by_key = extras_by_key or {}
|
|
313
|
+
owner = None
|
|
314
|
+
if use_owner:
|
|
315
|
+
owner = dict(zip(("OwnerId", "OwnerDeptId"), owner_presets()))
|
|
316
|
+
lay = sheet.get("layout")
|
|
317
|
+
if lay is None:
|
|
318
|
+
if use_owner:
|
|
319
|
+
raise ValueError("%s: useOwner=true 时必须显式 layout(行内写 OwnerId/OwnerDeptId)"
|
|
320
|
+
% sheet.get("key"))
|
|
321
|
+
return list(seq_default) # 全 flat:按定义顺序每控件一行
|
|
322
|
+
if lay == "auto4": # 每 4 个控件一行;布局项独占一行
|
|
323
|
+
rows, cur = [], []
|
|
324
|
+
|
|
325
|
+
def flush():
|
|
326
|
+
if cur:
|
|
327
|
+
rows.append(list(cur))
|
|
328
|
+
del cur[:]
|
|
329
|
+
|
|
330
|
+
if use_owner:
|
|
331
|
+
cur.extend([owner["OwnerId"], owner["OwnerDeptId"]])
|
|
332
|
+
for item in seq_default:
|
|
333
|
+
if item.get("DefKey") in extras_by_key: # 布局项:独占一行且是**顶层条目**
|
|
334
|
+
flush()
|
|
335
|
+
rows.append(item)
|
|
336
|
+
continue
|
|
337
|
+
cur.append(item)
|
|
338
|
+
if len(cur) == 4:
|
|
339
|
+
flush()
|
|
340
|
+
flush()
|
|
341
|
+
return rows
|
|
342
|
+
|
|
343
|
+
def resolve_one(item):
|
|
344
|
+
if isinstance(item, str):
|
|
345
|
+
if item in fields_by_key:
|
|
346
|
+
return fields_by_key[item]
|
|
347
|
+
if item in extras_by_key:
|
|
348
|
+
return extras_by_key[item]
|
|
349
|
+
if item in sub_keys:
|
|
350
|
+
raise ValueError("%s: 子表 %r 不占一行多列,自动排在表单底部(勿写进 layout)"
|
|
351
|
+
% (sheet.get("key"), item))
|
|
352
|
+
if owner and item in owner:
|
|
353
|
+
return owner[item]
|
|
354
|
+
raise ValueError("%s: layout 行内未知 key %r" % (sheet.get("key"), item))
|
|
355
|
+
if isinstance(item, dict):
|
|
356
|
+
if "owner" in item:
|
|
357
|
+
if not use_owner:
|
|
358
|
+
raise ValueError("layout 含 owner 元素但 useOwner 缺省")
|
|
359
|
+
if str(item["owner"]).lower() in ("user", "owner"):
|
|
360
|
+
return owner["OwnerId"]
|
|
361
|
+
return owner["OwnerDeptId"]
|
|
362
|
+
if "key" in item and "type" not in item: # 行内控件对象(仅 key 定位)
|
|
363
|
+
return fields_by_key[item["key"]]
|
|
364
|
+
raise ValueError("layout 行内只能放字段 key 字符串: %r" % (item,))
|
|
365
|
+
|
|
366
|
+
seq = []
|
|
367
|
+
for row in lay:
|
|
368
|
+
if not isinstance(row, list):
|
|
369
|
+
raise ValueError("layout 须为行数组 [[k1,k2,...], ...] 或 'auto4'")
|
|
370
|
+
if len(row) > 4:
|
|
371
|
+
raise ValueError("一行多列最多 4 个控件: %r" % row)
|
|
372
|
+
row_ctl = [resolve_one(i) for i in row]
|
|
373
|
+
if len(row_ctl) > 1 and any(c.get("DefKey") in extras_by_key for c in row_ctl):
|
|
374
|
+
raise ValueError("%s: 布局项(分组标题/描述)只能单独成行,不能进一行多列: %r"
|
|
375
|
+
% (sheet.get("key"), row))
|
|
376
|
+
# 布局项与 UI 样本一致作 **顶层条目**(不是 1 子项的 102 行)——布局项
|
|
377
|
+
# 本来就只能独占一行,故这里只影响布局项;普通单控件行保持既有 102 行包裹
|
|
378
|
+
# (已建成的表就是这么建的,改了会让 verify 行组期望对不上)
|
|
379
|
+
seq.append(row_ctl[0] if len(row_ctl) == 1
|
|
380
|
+
and row_ctl[0].get("DefKey") in extras_by_key else row_ctl)
|
|
381
|
+
return seq
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _build_subtable(sheet_key, spec, proj_dir, registry, app_code):
|
|
385
|
+
"""子表 spec → {key,label,code,fixed,cols}。
|
|
386
|
+
code:registry[sheet_key]["subs"][subkey]["code"](child schema 编码,
|
|
387
|
+
cli build 前由 sheet_sub_specs 预注册;离线 check 未注册时留空占位)。"""
|
|
388
|
+
sk = spec["key"]
|
|
389
|
+
label = spec.get("label") or sk
|
|
390
|
+
if not isinstance(spec.get("columns"), list) or not spec["columns"]:
|
|
391
|
+
raise ValueError("子表 %r.%s 缺 columns 列表" % (sheet_key, sk))
|
|
392
|
+
try:
|
|
393
|
+
fixed = int(spec.get("fixed", 1))
|
|
394
|
+
except (TypeError, ValueError):
|
|
395
|
+
raise ValueError("子表 %r.%s 的 fixed 须为数字" % (sheet_key, sk))
|
|
396
|
+
cols = []
|
|
397
|
+
for csp in spec["columns"]:
|
|
398
|
+
ct, ck = csp.get("type"), csp.get("key")
|
|
399
|
+
if ct not in GRID_COL_TYPES:
|
|
400
|
+
raise ValueError("子表 %r.%s 列 %r: 类型 %r 不支持(子表列支持 %s)"
|
|
401
|
+
";成员/部门/关联/嵌套子表/位置/地址/流水号/布局项作列未实证"
|
|
402
|
+
"——列节点照抄主表同款的 Options 形状,这几类没有可照抄的实证样本"
|
|
403
|
+
% (sheet_key, sk, ck, ct, "/".join(GRID_COL_TYPES)))
|
|
404
|
+
if csp.get("assoc"):
|
|
405
|
+
raise ValueError("子表 %r.%s 列 %r: 列内 assoc(联动下拉)未实证,暂不支持"
|
|
406
|
+
% (sheet_key, sk, ck))
|
|
407
|
+
cols.append(_build_control(proj_dir, csp, registry, app_code))
|
|
408
|
+
code = (((registry or {}).get(sheet_key) or {}).get("subs") or {}
|
|
409
|
+
).get(sk, {}).get("code", "")
|
|
410
|
+
return {"key": sk, "label": label, "code": code, "fixed": fixed, "cols": cols}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _group_specs(proj_dir):
|
|
414
|
+
"""groups.json(可选)→ 有序分组清单 [{name, icon}];无文件 → None。
|
|
415
|
+
文件为数组或 {"groups": [...]};条目 = 字符串或 {name}。顺序 = 应用菜单顺序。"""
|
|
416
|
+
gp = os.path.join(proj_dir, "groups.json")
|
|
417
|
+
if not os.path.isfile(gp):
|
|
418
|
+
return None
|
|
419
|
+
d = _read_json(gp)
|
|
420
|
+
lst = d if isinstance(d, list) else (d or {}).get("groups")
|
|
421
|
+
if not isinstance(lst, list):
|
|
422
|
+
raise ValueError("groups.json 须为数组或 {\"groups\": [...]}: %r" % d)
|
|
423
|
+
out = []
|
|
424
|
+
for it in lst:
|
|
425
|
+
if isinstance(it, str) and it.strip():
|
|
426
|
+
out.append({"name": it, "icon": ""})
|
|
427
|
+
elif isinstance(it, dict) and str(it.get("name") or "").strip():
|
|
428
|
+
out.append({"name": str(it["name"]).strip(),
|
|
429
|
+
"icon": str(it.get("icon") or "")})
|
|
430
|
+
else:
|
|
431
|
+
raise ValueError("groups.json 条目须为字符串或 {name}: %r" % (it,))
|
|
432
|
+
names = [g["name"] for g in out]
|
|
433
|
+
if len(set(names)) != len(names):
|
|
434
|
+
raise ValueError("groups.json 分组名重复: %s" % names)
|
|
435
|
+
return out
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def group_order(proj_dir):
|
|
439
|
+
"""有序分组名清单;无 groups.json → None(分组不校验、按表单出现顺序归组)。"""
|
|
440
|
+
gs = _group_specs(proj_dir)
|
|
441
|
+
return None if gs is None else [g["name"] for g in gs]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def sheet_sub_specs(path):
|
|
445
|
+
"""扫 sheets/<key>.json 顶层子表 spec 列表(cli build 预注册 child 编码用,
|
|
446
|
+
不加载完整控件树、不联网)。"""
|
|
447
|
+
return [c for c in _read_json(path).get("controls", [])
|
|
448
|
+
if isinstance(c, dict) and c.get("type") == "subtable"]
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _build_json_sheet(path, proj_dir, app_code, registry, group_specs=None):
|
|
452
|
+
sheet = _read_json(path)
|
|
453
|
+
key = os.path.splitext(os.path.basename(path))[0]
|
|
454
|
+
use_owner = bool(sheet.get("useOwner", False))
|
|
455
|
+
group = str(sheet.get("group") or "").strip()
|
|
456
|
+
if group and group_specs is not None \
|
|
457
|
+
and group not in [g["name"] for g in group_specs]:
|
|
458
|
+
raise ValueError("%s: 分组 %r 未在 groups.json 声明(现有 %s)"
|
|
459
|
+
% (key, group, [g["name"] for g in group_specs]))
|
|
460
|
+
controls, subs, extras, seq_default, fields_by_key, assoc = [], [], [], [], {}, {}
|
|
461
|
+
extras_by_key = {}
|
|
462
|
+
for spec in sheet.get("controls", []):
|
|
463
|
+
if spec.get("type") == "subtable":
|
|
464
|
+
subs.append(_build_subtable(key, spec, proj_dir, registry, app_code))
|
|
465
|
+
continue
|
|
466
|
+
if spec.get("type") in LAYOUT_TYPES: # 分组标题 / 描述:布局项
|
|
467
|
+
ctl = _build_layout_item(spec)
|
|
468
|
+
# 布局项的 Key 是 uuid5(短码)(见 controls.layout_key),定义侧短码在 DefKey:
|
|
469
|
+
# layout 行/去重都按短码走,回读与 verify 比对按 GUID key 走(同线上)
|
|
470
|
+
if ctl["DefKey"] in fields_by_key or ctl["DefKey"] in extras_by_key:
|
|
471
|
+
raise ValueError("%s: 控件 key 重复 %r" % (key, ctl["DefKey"]))
|
|
472
|
+
extras.append(ctl)
|
|
473
|
+
extras_by_key[ctl["DefKey"]] = ctl
|
|
474
|
+
seq_default.append(ctl)
|
|
475
|
+
continue
|
|
476
|
+
ctl = _build_control(proj_dir, spec, registry, app_code)
|
|
477
|
+
if ctl["Key"] in ("OwnerId", "OwnerDeptId"):
|
|
478
|
+
raise ValueError("OwnerId/OwnerDeptId 是系统字段,用 useOwner + layout 引用,"
|
|
479
|
+
"不要放进 controls")
|
|
480
|
+
if ctl["Key"] in fields_by_key or ctl["Key"] in extras_by_key:
|
|
481
|
+
raise ValueError("%s: 控件 key 重复 %r" % (key, ctl["Key"]))
|
|
482
|
+
controls.append(ctl)
|
|
483
|
+
seq_default.append(ctl)
|
|
484
|
+
fields_by_key[ctl["Key"]] = ctl
|
|
485
|
+
if spec.get("type") in ("query", "dropdown") and spec.get("assoc"):
|
|
486
|
+
# 注册 assoc 关系(check/verify 展示用);构建载荷侧 query 走 kwargs、
|
|
487
|
+
# 联动下拉走 Options.BOSchemaCode,此处只需登记不重复解析
|
|
488
|
+
assoc[ctl["Key"]] = {"assoc_schema_code":
|
|
489
|
+
ctl["Options"].get("BOSchemaCode", "")}
|
|
490
|
+
# 联动下拉的 filter 动态引用(ref)必须指向本表已有字段(级联链上源)
|
|
491
|
+
for spec in sheet.get("controls", []):
|
|
492
|
+
if spec.get("type") != "dropdown" or not spec.get("assoc"):
|
|
493
|
+
continue
|
|
494
|
+
for it in spec.get("filter") or []:
|
|
495
|
+
ref = (it or {}).get("ref")
|
|
496
|
+
if ref is not None and ref not in fields_by_key:
|
|
497
|
+
raise ValueError("%s: filter 动态引用 %r 不是本表字段"
|
|
498
|
+
"(ref 写本表单 controls 的 key)" % (key, ref))
|
|
499
|
+
layout = _resolve_layout(sheet, seq_default, fields_by_key, use_owner,
|
|
500
|
+
extras_by_key=extras_by_key,
|
|
501
|
+
sub_keys=tuple(s["key"] for s in subs))
|
|
502
|
+
return {"key": key,
|
|
503
|
+
"title": sheet.get("title") or key,
|
|
504
|
+
"name_schema": sheet.get("nameSchema") or "",
|
|
505
|
+
"fields": controls, "layout": layout, "assoc": assoc,
|
|
506
|
+
"useOwner": use_owner, "group": group, "extras": extras,
|
|
507
|
+
"subs": subs, "_source": os.path.relpath(path, proj_dir)}
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _legacy_loaders(proj_dir, app_code, registry):
|
|
511
|
+
"""旧版 forms.py(奥驰样式)→ {key: loader}。forms.py 保持自身约定:
|
|
512
|
+
BUILDERS 表 + build(key, app_code, rule_table_code=, apply_table_code=)"""
|
|
513
|
+
forms_path = os.path.join(proj_dir, "forms.py")
|
|
514
|
+
# legacy forms.py 顶层 import controls —— 新位置在 h3design/;兼容旧工程同目录写法
|
|
515
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # h3design/
|
|
516
|
+
sys.path.insert(0, proj_dir)
|
|
517
|
+
try:
|
|
518
|
+
mod_name = "_legacy_forms_" + str(abs(hash(forms_path)))
|
|
519
|
+
spec = importlib.util.spec_from_file_location(mod_name, forms_path)
|
|
520
|
+
mod = importlib.util.module_from_spec(spec)
|
|
521
|
+
spec.loader.exec_module(mod)
|
|
522
|
+
finally:
|
|
523
|
+
sys.path.pop(0)
|
|
524
|
+
sys.path.pop(0)
|
|
525
|
+
builders = getattr(mod, "BUILDERS")
|
|
526
|
+
t1 = ((registry or {}).get("table1") or {}).get("code", "")
|
|
527
|
+
t4 = ((registry or {}).get("table4") or {}).get("code", "")
|
|
528
|
+
out = {}
|
|
529
|
+
for key in builders:
|
|
530
|
+
def loader(key=key):
|
|
531
|
+
return mod.build(key, app_code, rule_table_code=t1,
|
|
532
|
+
apply_table_code=t4)
|
|
533
|
+
out[key] = loader
|
|
534
|
+
return out
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def load_project(proj_dir, app_code, registry):
|
|
538
|
+
"""项目全部表单 loader。JSON(sheets/*.json) 优先,legacy forms.py 合并。"""
|
|
539
|
+
out = {}
|
|
540
|
+
sj = os.path.join(proj_dir, "sheets")
|
|
541
|
+
gspecs = _group_specs(proj_dir)
|
|
542
|
+
if os.path.isdir(sj):
|
|
543
|
+
for fn in sorted(os.listdir(sj)):
|
|
544
|
+
if fn.endswith(".json"):
|
|
545
|
+
path = os.path.join(sj, fn)
|
|
546
|
+
out[os.path.splitext(fn)[0]] = (
|
|
547
|
+
lambda p=path: _build_json_sheet(p, proj_dir, app_code,
|
|
548
|
+
registry, gspecs))
|
|
549
|
+
if os.path.isfile(os.path.join(proj_dir, "forms.py")):
|
|
550
|
+
out.update(_legacy_loaders(proj_dir, app_code, registry))
|
|
551
|
+
if not out:
|
|
552
|
+
raise ValueError("项目 %r 没有定义:缺 sheets/*.json 或 forms.py" % proj_dir)
|
|
553
|
+
return out
|