@dforge-core/dforge-mcp 0.2.45 → 0.2.46-next
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/CHANGELOG.md +12 -0
- package/README.md +2 -1
- package/package.json +48 -46
- package/scripts/install-skills.mjs +1 -0
- package/skills/dforge-1c-import/SKILL.md +374 -0
- package/skills/dforge-1c-import/references/environment.md +97 -0
- package/skills/dforge-1c-import/references/loading.md +161 -0
- package/skills/dforge-1c-import/references/measurements.md +210 -0
- package/skills/dforge-1c-import/references/print-forms.md +148 -0
- package/skills/dforge-1c-import/scripts/1c.py +178 -0
- package/skills/dforge-1c-import/scripts/README.md +110 -0
- package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
- package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
- package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
- package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
- package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
- package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
- package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
- package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
- package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/delta.py +741 -0
- package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/docusage.py +105 -0
- package/skills/dforge-1c-import/scripts/lib/dump1c.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
- package/skills/dforge-1c-import/scripts/lib/external.py +201 -0
- package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
- package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
- package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
- package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
- package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
- package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
- package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
- package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
- package/skills/dforge-1c-import/scripts/lib/meta.py +315 -0
- package/skills/dforge-1c-import/scripts/lib/migrate.py +1299 -0
- package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
- package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/paths.py +90 -0
- package/skills/dforge-1c-import/scripts/lib/peek.py +70 -0
- package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
- package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
- package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
- package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
- package/skills/dforge-1c-import/scripts/lib/recorders.py +94 -0
- package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
- package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
- package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
- package/skills/dforge-1c-import/scripts/lib/seedstate.py +488 -0
- package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
- package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
- package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
- package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
- package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
- package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
- package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
- package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
- package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
- package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
- package/skills/dforge-1c-import/scripts/project-template/sources.py +14 -0
- package/skills/dforge-mcp-author/SKILL.md +5 -0
|
@@ -0,0 +1,1432 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Emit dForge module packages from a compact field spec.
|
|
5
|
+
|
|
6
|
+
Hand-writing entity JSON for 190-odd ported objects would be transcription work with a
|
|
7
|
+
transcription error rate, so the shape of a dForge module is encoded once here and each
|
|
8
|
+
entity is declared as a list of fields. The generated files are the deliverable; edit
|
|
9
|
+
the spec and regenerate rather than patching the JSON.
|
|
10
|
+
|
|
11
|
+
Field spec: (name, type, description[, mods])
|
|
12
|
+
|
|
13
|
+
varchar:N text int:N whole number, numeric(N,0)
|
|
14
|
+
text textarea num:P,S numeric(P,S)
|
|
15
|
+
bool checkbox money numeric(18,2), currency
|
|
16
|
+
date date datetime timestamptz
|
|
17
|
+
user user picker drop:a=A|b dropdown
|
|
18
|
+
drop1c:Enum a dropdown whose options are read from that 1C enum
|
|
19
|
+
ref:entity FK + Reference pair (qualified codes allowed: ref:masterdata.product)
|
|
20
|
+
rawref an unresolvable 1C reference: the GUID, with no link (see autospec.py)
|
|
21
|
+
|
|
22
|
+
mods: "req" mandatory, "ro" not editable
|
|
23
|
+
|
|
24
|
+
python tools/genmodule.py # writes modules/masterdata, fleet and shipping
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import print_function
|
|
27
|
+
|
|
28
|
+
import codecs
|
|
29
|
+
import collections
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
|
|
36
|
+
import paths
|
|
37
|
+
|
|
38
|
+
ROOT = paths.ROOT
|
|
39
|
+
MODULES = paths.MODULES
|
|
40
|
+
|
|
41
|
+
# 1C's own key is `_IDRRef binary(16)` - a GUID. dForge does not require the snowflake
|
|
42
|
+
# `cuid` for module entities (that is only the metadata schema's own rule), and its write
|
|
43
|
+
# pipeline already generates `Guid.NewGuid()` for a uuid identity column, so the ported
|
|
44
|
+
# entities keep the infobase's keys verbatim. That is what lets the data come across as a
|
|
45
|
+
# plain copy: no old-key -> new-key table, and every reference resolves without a lookup.
|
|
46
|
+
KEY_TYPE = "uuid"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --------------------------------------------------------------------------- emitters
|
|
50
|
+
|
|
51
|
+
def named(spec, prefix):
|
|
52
|
+
"""Does this type spec start with `prefix`? A spec handed through as a dict does not.
|
|
53
|
+
|
|
54
|
+
Every place that reads a spec by its keyword has to answer this, and the dict form would
|
|
55
|
+
otherwise crash in each of them with `AttributeError` on `startswith`.
|
|
56
|
+
"""
|
|
57
|
+
return not isinstance(spec, dict) and spec.startswith(prefix)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_type(spec):
|
|
61
|
+
"""Type spec -> the dbDatatype / fieldTypeCd / extras a dForge column needs.
|
|
62
|
+
|
|
63
|
+
A dict is passed through verbatim. The vocabulary below covers what 1C's own types map
|
|
64
|
+
onto, and a register needs two columns 1C has no equivalent for - a generated one, and a
|
|
65
|
+
direction stamp that must be a real `int4` rather than this `int:` shorthand's numeric.
|
|
66
|
+
Inventing a keyword per case would grow the vocabulary for one caller each; handing a
|
|
67
|
+
column spec straight through says plainly that nothing here is being interpreted.
|
|
68
|
+
"""
|
|
69
|
+
if isinstance(spec, dict):
|
|
70
|
+
return dict(spec)
|
|
71
|
+
if named(spec, "varchar:"):
|
|
72
|
+
return {"dbDatatype": "varchar", "fieldTypeCd": "text", "maxLen": int(spec.split(":")[1])}
|
|
73
|
+
if spec == "text":
|
|
74
|
+
return {"dbDatatype": "text", "fieldTypeCd": "textarea"}
|
|
75
|
+
if spec == "bool":
|
|
76
|
+
return {"dbDatatype": "bool", "fieldTypeCd": "checkbox", "formula": "false"}
|
|
77
|
+
if spec.startswith("int:"):
|
|
78
|
+
return {"dbDatatype": "numeric(%s,0)" % spec.split(":")[1], "fieldTypeCd": "number"}
|
|
79
|
+
if spec.startswith("num:"):
|
|
80
|
+
precision, scale = spec.split(":")[1].split(",")
|
|
81
|
+
return {"dbDatatype": "numeric(%s,%s)" % (precision, scale),
|
|
82
|
+
"fieldTypeCd": "number", "precision": int(scale)}
|
|
83
|
+
if spec == "money":
|
|
84
|
+
return {"dbDatatype": "numeric(18,2)", "fieldTypeCd": "currency", "precision": 2}
|
|
85
|
+
if spec == "date":
|
|
86
|
+
return {"dbDatatype": "date", "fieldTypeCd": "date"}
|
|
87
|
+
if spec == "datetime":
|
|
88
|
+
return {"dbDatatype": "timestamptz", "fieldTypeCd": "datetime"}
|
|
89
|
+
if spec == "user":
|
|
90
|
+
return {"dbDatatype": "int8", "fieldTypeCd": "user"}
|
|
91
|
+
if spec == "recorder":
|
|
92
|
+
# Which document wrote this record. 1C calls it the регистратор and gives a
|
|
93
|
+
# subordinate register one; dForge stamps the same thing as a jsonb entity link,
|
|
94
|
+
# which is what lets unposting find its own rows again.
|
|
95
|
+
return {"dbDatatype": "jsonb", "fieldTypeCd": "entitylink"}
|
|
96
|
+
if spec == "blob":
|
|
97
|
+
# 1C's serialization of a value. Nothing here can read it, so it carries no
|
|
98
|
+
# field type and no editor - the bytes are kept and nothing is claimed.
|
|
99
|
+
return {"dbDatatype": "bytea"}
|
|
100
|
+
if spec == "rawref":
|
|
101
|
+
# A 1C reference with nothing on this side to point at - a composite reference, or
|
|
102
|
+
# a target the port does not own. The GUID is kept so the migration does not lose
|
|
103
|
+
# it, but there is no link and no lookup, so it stays off the card.
|
|
104
|
+
#
|
|
105
|
+
# No `fieldTypeCd`: naming one that resolves to the text group while the column
|
|
106
|
+
# stores a uuid makes the platform bind filters and DML against the wrong type,
|
|
107
|
+
# and it says so at install. Letting it resolve the control from `dbDatatype` is
|
|
108
|
+
# the whole of the fix.
|
|
109
|
+
return {"dbDatatype": KEY_TYPE}
|
|
110
|
+
if spec.startswith("drop:"):
|
|
111
|
+
options = []
|
|
112
|
+
for item in spec[len("drop:"):].split("|"):
|
|
113
|
+
value, _, label = item.partition("=")
|
|
114
|
+
options.append({"value": value, "label": label or value})
|
|
115
|
+
width = max(len(o["value"]) for o in options)
|
|
116
|
+
return {"dbDatatype": "varchar", "fieldTypeCd": "dropdown",
|
|
117
|
+
"maxLen": max(width, 20), "params": {"options": options}}
|
|
118
|
+
raise ValueError("unknown field type %r" % spec)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def target_pk(entity):
|
|
122
|
+
"""products.product -> product_id; carrier -> carrier_id"""
|
|
123
|
+
return entity.split(".")[-1] + "_id"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def emit_fields(fields, start=10, step=10, line_of=None, ref_names=None):
|
|
127
|
+
"""Spec list -> (ordered column dict, FK reference dict).
|
|
128
|
+
|
|
129
|
+
`line_of` names the reference back to the owning document on a tabular-section
|
|
130
|
+
entity; that FK is then half of the composite key, as it is in 1C's `_VT` tables.
|
|
131
|
+
"""
|
|
132
|
+
columns, references = {}, {}
|
|
133
|
+
ref_names = ref_names or {}
|
|
134
|
+
order = start
|
|
135
|
+
for item in fields:
|
|
136
|
+
name, spec, description = item[0], item[1], item[2]
|
|
137
|
+
mods = item[3] if len(item) > 3 else ""
|
|
138
|
+
# "calc" is a register resource: the engine owns it, the user never types it.
|
|
139
|
+
# "cache" is a column the platform WRITES - a totals cache's figures - so it is
|
|
140
|
+
# shown and not editable, and unlike "calc" it is not defaulted or made mandatory:
|
|
141
|
+
# a period with no movement of that resource has nothing in it.
|
|
142
|
+
if "cache" in mods:
|
|
143
|
+
flags = "V"
|
|
144
|
+
else:
|
|
145
|
+
flags = "VM" if "calc" in mods else ("VEM" if "req" in mods else "VE")
|
|
146
|
+
if spec == "rawref" or "hidden" in mods:
|
|
147
|
+
flags = flags.replace("V", "")
|
|
148
|
+
|
|
149
|
+
if named(spec, "ref:"):
|
|
150
|
+
entity = spec[len("ref:"):]
|
|
151
|
+
fk = name + "_id"
|
|
152
|
+
columns[fk] = {
|
|
153
|
+
"dbDatatype": KEY_TYPE,
|
|
154
|
+
"flags": "EM" if ("req" in mods or "notnull" in mods) else "E",
|
|
155
|
+
"orderNum": order,
|
|
156
|
+
"description": description + " ID",
|
|
157
|
+
}
|
|
158
|
+
# "notnull" is "req" that reaches the DDL as well: the column is mandatory in
|
|
159
|
+
# the metadata and NOT NULL in the table. Kept apart from "req" because adding
|
|
160
|
+
# NOT NULL to a column already full of rows is a migration that can fail, so it
|
|
161
|
+
# is said where it is true rather than applied to every required reference.
|
|
162
|
+
if "notnull" in mods:
|
|
163
|
+
columns[fk]["isNullable"] = False
|
|
164
|
+
if name == line_of:
|
|
165
|
+
columns[fk]["isPk"] = True
|
|
166
|
+
columns[fk]["isNullable"] = False
|
|
167
|
+
columns[fk] = _ordered_column(columns[fk])
|
|
168
|
+
columns[name] = {
|
|
169
|
+
"columnType": "R",
|
|
170
|
+
"fieldTypeCd": "lookup",
|
|
171
|
+
"flags": flags,
|
|
172
|
+
"orderNum": order + 5,
|
|
173
|
+
"description": description,
|
|
174
|
+
"link": {"entity": entity, "thisKey": fk, "otherKey": target_pk(entity)},
|
|
175
|
+
}
|
|
176
|
+
# The constraint's name is `FK_<Column>` unless the spec names it. An entity
|
|
177
|
+
# already installed carries the name its package was written with, and a
|
|
178
|
+
# renamed reference is a DROP and a re-ADD of the constraint - which on a large
|
|
179
|
+
# table is a validating scan for a cosmetic change.
|
|
180
|
+
references[ref_names.get(fk, "FK_%s" % _camel(fk))] = {
|
|
181
|
+
"from": {"field": fk},
|
|
182
|
+
"to": {"entity": entity, "field": target_pk(entity)},
|
|
183
|
+
}
|
|
184
|
+
else:
|
|
185
|
+
column = {"flags": flags, "orderNum": order, "description": description}
|
|
186
|
+
column.update(parse_type(spec))
|
|
187
|
+
if "req" in mods:
|
|
188
|
+
column["isNullable"] = False
|
|
189
|
+
if "calc" in mods:
|
|
190
|
+
column["formula"] = "0"
|
|
191
|
+
column["isNullable"] = False
|
|
192
|
+
columns[name] = _ordered_column(column)
|
|
193
|
+
order += step
|
|
194
|
+
return columns, references
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
COLUMN_KEY_ORDER = ["columnType", "dbDatatype", "baseDatatypeCd", "fieldTypeCd", "flags",
|
|
198
|
+
"isPk", "isIdentity", "isNullable", "maxLen", "precision", "formula",
|
|
199
|
+
"orderNum", "description", "link", "params"]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _ordered_column(column):
|
|
203
|
+
return dict_in_order(column, COLUMN_KEY_ORDER)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def dict_in_order(source, order):
|
|
207
|
+
out = {}
|
|
208
|
+
for key in order:
|
|
209
|
+
if key in source:
|
|
210
|
+
out[key] = source[key]
|
|
211
|
+
for key in source:
|
|
212
|
+
if key not in out:
|
|
213
|
+
out[key] = source[key]
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _camel(name):
|
|
218
|
+
return "".join(part.capitalize() for part in name.split("_"))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def plural(code):
|
|
222
|
+
"""View codes are the entity code pluralised - haulage_class -> haulage_classes."""
|
|
223
|
+
if code.endswith(("s", "x", "ch", "sh")):
|
|
224
|
+
return code + "es"
|
|
225
|
+
if code.endswith("y") and not code.endswith(("ay", "ey", "oy", "uy")):
|
|
226
|
+
return code[:-1] + "ies"
|
|
227
|
+
return code + "s"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def placeholder_column(spec):
|
|
231
|
+
"""`is_missing` — this row stands in for a reference that pointed at nothing.
|
|
232
|
+
|
|
233
|
+
1C does not enforce referential integrity and dForge does, so a copy always arrives
|
|
234
|
+
carrying references whose target was deleted from the infobase years ago. Nulling
|
|
235
|
+
them would lose what the document actually said, and dropping the foreign key would
|
|
236
|
+
give up the integrity the port is here to gain. The migration inserts the missing row
|
|
237
|
+
instead and raises this flag, so the reference still resolves and the row says what
|
|
238
|
+
it is rather than pretending to be reference data.
|
|
239
|
+
|
|
240
|
+
Only an object that can be the far side of a reference gets it: not a tabular
|
|
241
|
+
section, and not a register.
|
|
242
|
+
"""
|
|
243
|
+
if spec.get("lineOf") or spec.get("family") in ("InfoRg", "AccumRg"):
|
|
244
|
+
return {}
|
|
245
|
+
# Nothing is copied into a derived entity - a totals cache is built from the journal
|
|
246
|
+
# by the platform - so there is no row that could be standing in for one the infobase
|
|
247
|
+
# has and the load has not reached yet.
|
|
248
|
+
if spec.get("derived"):
|
|
249
|
+
return {}
|
|
250
|
+
return {"is_missing": {
|
|
251
|
+
"dbDatatype": "bool",
|
|
252
|
+
"fieldTypeCd": "checkbox",
|
|
253
|
+
"flags": "V",
|
|
254
|
+
"formula": "false",
|
|
255
|
+
"orderNum": 9990,
|
|
256
|
+
"description": "Missing from the infobase",
|
|
257
|
+
}}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def key_columns(code, spec):
|
|
261
|
+
"""The entity's primary key, shaped like the 1C table it was ported from.
|
|
262
|
+
|
|
263
|
+
A catalog or document is keyed by its `_IDRRef`, so the column is a uuid the platform
|
|
264
|
+
fills with a fresh GUID for records created here and the migration fills with the
|
|
265
|
+
infobase's own. A tabular section is keyed by `(<owner>_IDRRef, _LineNo)`; the owner
|
|
266
|
+
half is the FK emitted from the spec, so only the line number is added here.
|
|
267
|
+
|
|
268
|
+
`key` names the columns of an entity that is keyed by its own values and has no
|
|
269
|
+
surrogate at all. A register's `totals` cache is the case: the platform requires its
|
|
270
|
+
PRIMARY KEY to be exactly (dimensions, period), so a minted key would not just be
|
|
271
|
+
redundant - install refuses the cache outright. Nothing is added here; the named
|
|
272
|
+
columns are marked in place by `mark_key`, because a `ref:` field's key is the `_id`
|
|
273
|
+
column beside it rather than the field's own name.
|
|
274
|
+
"""
|
|
275
|
+
if spec.get("key"):
|
|
276
|
+
return {}
|
|
277
|
+
if spec.get("lineOf"):
|
|
278
|
+
return {"line_no": {
|
|
279
|
+
"dbDatatype": "int4",
|
|
280
|
+
"fieldTypeCd": "number",
|
|
281
|
+
"flags": "VM",
|
|
282
|
+
"isPk": True,
|
|
283
|
+
"isNullable": False,
|
|
284
|
+
"orderNum": 12,
|
|
285
|
+
"description": "Line no",
|
|
286
|
+
}}
|
|
287
|
+
return {code + "_id": {
|
|
288
|
+
"dbDatatype": KEY_TYPE,
|
|
289
|
+
"flags": "I",
|
|
290
|
+
"isPk": True,
|
|
291
|
+
"isIdentity": True,
|
|
292
|
+
"isNullable": False,
|
|
293
|
+
"orderNum": 1,
|
|
294
|
+
"description": code.replace("_", " ").capitalize() + " ID",
|
|
295
|
+
}}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _readable(column):
|
|
299
|
+
"""A value a caption can show: not a GUID, and not a surrogate integer key."""
|
|
300
|
+
if column.get("fieldTypeCd") == "user":
|
|
301
|
+
return True
|
|
302
|
+
return not (column.get("dbDatatype") in ("uuid", "cuid")
|
|
303
|
+
or (column.get("isPk") and column.get("dbDatatype") in ("int8", "bigint")))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _first(codes, *patterns):
|
|
307
|
+
for pattern in patterns:
|
|
308
|
+
for found in codes:
|
|
309
|
+
if (found.lower().endswith(pattern[1:]) if pattern.startswith("*")
|
|
310
|
+
else found.lower() == pattern):
|
|
311
|
+
return found
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def caption(code, fields):
|
|
316
|
+
"""The entity's toString, for one whose spec names none.
|
|
317
|
+
|
|
318
|
+
First what the platform would derive itself - a number with its date, a code and a
|
|
319
|
+
name, a name, a code - so the file says what the record will show instead of leaving
|
|
320
|
+
it to a warning on every validate (ToStringTemplate.Generate is the rule copied here).
|
|
321
|
+
|
|
322
|
+
Then what the platform cannot derive: a register record, or a settings row, has no
|
|
323
|
+
number, code or name. 1C lists such a record by what it is about and when, so that is
|
|
324
|
+
its caption here too: the first visible reference, or failing one the first readable
|
|
325
|
+
value, with the period or date after it. A raw GUID is never a caption.
|
|
326
|
+
"""
|
|
327
|
+
visible = [(name, column) for name, column in fields.items()
|
|
328
|
+
if "V" in column.get("flags", "") and _readable(column)]
|
|
329
|
+
scalars = [name for name, column in visible if column.get("columnType") not in ("R", "S", "F")]
|
|
330
|
+
lookups = [name for name, column in visible if column.get("columnType") == "R"]
|
|
331
|
+
|
|
332
|
+
number = _first(scalars, "number", code + "_number", code + "_no")
|
|
333
|
+
date = _first(scalars, "date", code + "_date")
|
|
334
|
+
if number:
|
|
335
|
+
return "{%s} {%s}" % (number, date) if date else "{%s}" % number
|
|
336
|
+
name = _first(scalars, "display_name", "name", "full_name", "title", "subject",
|
|
337
|
+
"*_name", "*_title", "label", "description")
|
|
338
|
+
coded = _first(scalars, "code", code + "_code", code + "_cd")
|
|
339
|
+
if coded and name:
|
|
340
|
+
return u"{%s} — {%s}" % (coded, name)
|
|
341
|
+
if name or coded:
|
|
342
|
+
return "{%s}" % (name or coded)
|
|
343
|
+
|
|
344
|
+
when = _first(scalars, "period", "date", "doc_date", "*_date")
|
|
345
|
+
about = (lookups[0] if lookups else
|
|
346
|
+
next((n for n in scalars if n != when and fields[n].get("fieldTypeCd") != "checkbox"),
|
|
347
|
+
None))
|
|
348
|
+
parts = [p for p in (about, when) if p]
|
|
349
|
+
return " ".join("{%s}" % p for p in parts) or None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def mark_key(columns, key):
|
|
353
|
+
"""Mark the named columns as the entity's own primary key.
|
|
354
|
+
|
|
355
|
+
A name is taken as `<name>_id` when the spec named a reference - the key is the FK,
|
|
356
|
+
not the lookup column beside it - and as written otherwise.
|
|
357
|
+
A name matching neither is an error rather than a column quietly left out of the key,
|
|
358
|
+
which would leave the cache keyed by less than the register demands.
|
|
359
|
+
"""
|
|
360
|
+
for name in key:
|
|
361
|
+
column = name + "_id" if name + "_id" in columns else name
|
|
362
|
+
if column not in columns:
|
|
363
|
+
raise KeyError("key names %r, which this entity has no column for" % name)
|
|
364
|
+
columns[column]["isPk"] = True
|
|
365
|
+
columns[column]["isNullable"] = False
|
|
366
|
+
columns[column] = _ordered_column(columns[column])
|
|
367
|
+
return columns
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def build_entity(code, spec):
|
|
371
|
+
entity = {"description": spec["desc"], "dbObject": code}
|
|
372
|
+
if spec.get("toString"):
|
|
373
|
+
entity["toString"] = spec["toString"]
|
|
374
|
+
elif spec.get("lineOf"):
|
|
375
|
+
# A tabular-section row reads as its document and its line. The platform would
|
|
376
|
+
# derive exactly this, and says so on every validate; written down, it is settled.
|
|
377
|
+
entity["toString"] = "{%s} #{line_no}" % spec["lineOf"]
|
|
378
|
+
# No "identity" trait: that one hard-codes a snowflake `cuid`, and these entities
|
|
379
|
+
# carry the infobase's GUID instead. See key_columns().
|
|
380
|
+
# An entity with no traits carries no key rather than an empty list: a platform-
|
|
381
|
+
# maintained cache is audited by nothing, and saying so with `[]` and with silence
|
|
382
|
+
# are the same thing to the installer.
|
|
383
|
+
if spec.get("traits", ["audit"]):
|
|
384
|
+
entity["traits"] = spec.get("traits", ["audit"])
|
|
385
|
+
if spec.get("numberSequence"):
|
|
386
|
+
entity["numberSequence"] = spec["numberSequence"]
|
|
387
|
+
|
|
388
|
+
columns, references = emit_fields(spec["fields"], line_of=spec.get("lineOf"),
|
|
389
|
+
ref_names=spec.get("refNames", {}))
|
|
390
|
+
columns.update(key_columns(code, spec))
|
|
391
|
+
columns.update(placeholder_column(spec))
|
|
392
|
+
if spec.get("key"):
|
|
393
|
+
mark_key(columns, spec["key"])
|
|
394
|
+
|
|
395
|
+
order = 10 * (len(spec["fields"]) + 2)
|
|
396
|
+
for name, child, label, thiskey, otherkey in spec.get("sets", []):
|
|
397
|
+
columns[name] = {
|
|
398
|
+
"columnType": "S",
|
|
399
|
+
"fieldTypeCd": "grid",
|
|
400
|
+
"flags": "VE",
|
|
401
|
+
"orderNum": order,
|
|
402
|
+
"description": label,
|
|
403
|
+
"link": {"entity": child, "thisKey": thiskey, "otherKey": otherkey},
|
|
404
|
+
}
|
|
405
|
+
order += 10
|
|
406
|
+
|
|
407
|
+
for name, column in spec.get("registry", {}).items():
|
|
408
|
+
column = dict(column)
|
|
409
|
+
column["orderNum"] = 900
|
|
410
|
+
columns[name] = _ordered_column(column)
|
|
411
|
+
|
|
412
|
+
# Written in column order rather than the order the spec happened to build them in,
|
|
413
|
+
# so a generated file reads like the card it produces.
|
|
414
|
+
entity["fields"] = collections.OrderedDict(
|
|
415
|
+
sorted(columns.items(), key=lambda kv: kv[1].get("orderNum", 0)))
|
|
416
|
+
if not entity.get("toString"):
|
|
417
|
+
found = caption(code, entity["fields"])
|
|
418
|
+
if found:
|
|
419
|
+
entity["toString"] = found
|
|
420
|
+
if spec.get("constraints"):
|
|
421
|
+
entity["constraints"] = spec["constraints"]
|
|
422
|
+
if spec.get("indexes"):
|
|
423
|
+
entity["indexes"] = spec["indexes"]
|
|
424
|
+
if references:
|
|
425
|
+
entity["references"] = references
|
|
426
|
+
return entity
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def build_extension(module, target, fields, registry=None, description=None):
|
|
430
|
+
"""Columns this module adds to another module's entity.
|
|
431
|
+
|
|
432
|
+
The column lives here rather than on the entity itself because the reference points
|
|
433
|
+
back at this module: the owning module already depends on us, so declaring it there
|
|
434
|
+
would make the two depend on each other and neither could install first.
|
|
435
|
+
|
|
436
|
+
`registry` adds posting columns, as an entity's own do: a bridge that posts the other
|
|
437
|
+
module's document to a register (the state kept in this module's extension table).
|
|
438
|
+
"""
|
|
439
|
+
columns, references = emit_fields(fields, start=60)
|
|
440
|
+
for name, column in (registry or {}).items():
|
|
441
|
+
column = dict(column)
|
|
442
|
+
column["orderNum"] = 900
|
|
443
|
+
columns[name] = _ordered_column(column)
|
|
444
|
+
other = target.split(".")[0]
|
|
445
|
+
return dict_in_order({
|
|
446
|
+
"extends": target,
|
|
447
|
+
"dbObject": target.split(".")[-1],
|
|
448
|
+
"description": description or (
|
|
449
|
+
"%s extension for %s - added here, not in %s, because the reference points "
|
|
450
|
+
"back at %s." % (module.capitalize(), target, other, module)),
|
|
451
|
+
"fields": columns,
|
|
452
|
+
"references": references,
|
|
453
|
+
}, ["extends", "dbObject", "description", "fields", "references"])
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# The columns 1C gives every object of its family. They identify the record, so they
|
|
457
|
+
# lead the card; everything the integrator added follows behind them.
|
|
458
|
+
IDENTITY = ["code", "name", "deletion_mark", "is_folder", "parent", "owner",
|
|
459
|
+
"doc_no", "doc_date", "posted", "period", "recorder", "line_no",
|
|
460
|
+
"record_kind", "is_active", "is_missing"]
|
|
461
|
+
|
|
462
|
+
CARD_GROUPS_UK = {"main": u"\u0417\u0430\u0433\u0430\u043b\u044c\u043d\u0435",
|
|
463
|
+
"details": u"\u0420\u0435\u043a\u0432\u0456\u0437\u0438\u0442\u0438"}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def card_fields(entity_spec):
|
|
467
|
+
"""The columns a card shows, split into the identifying ones and the rest.
|
|
468
|
+
|
|
469
|
+
A hidden column is hidden on the card too: a raw GUID with nothing to point at has
|
|
470
|
+
nothing to show. A set is not here either - it becomes a tab of its own.
|
|
471
|
+
"""
|
|
472
|
+
head, rest = [], []
|
|
473
|
+
for item in entity_spec["fields"]:
|
|
474
|
+
mods = item[3] if len(item) > 3 else ""
|
|
475
|
+
if item[1] == "rawref" or "hidden" in mods:
|
|
476
|
+
continue
|
|
477
|
+
(head if item[0] in IDENTITY else rest).append(item[0])
|
|
478
|
+
if placeholder_column(entity_spec):
|
|
479
|
+
head.append("is_missing")
|
|
480
|
+
return head, rest
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def build_card_layout(code, entity_spec):
|
|
484
|
+
"""One record's form: what identifies it up top, the rest behind tabs.
|
|
485
|
+
|
|
486
|
+
`Номенклатура` carries 81 attributes and eleven tabular sections, so the card that
|
|
487
|
+
just lists its columns in order is unreadable. 1C solves this with a tabbed form, and
|
|
488
|
+
so does this: the identifying columns stay visible, and everything else - the
|
|
489
|
+
remaining attributes, then one tab per tabular section - goes behind a tab bar.
|
|
490
|
+
"""
|
|
491
|
+
if entity_spec.get("lineOf"):
|
|
492
|
+
return None
|
|
493
|
+
head, rest = card_fields(entity_spec)
|
|
494
|
+
sets = [item[0] for item in entity_spec.get("sets", [])]
|
|
495
|
+
if not head and not rest and not sets:
|
|
496
|
+
return None
|
|
497
|
+
|
|
498
|
+
sections = [{"type": "columnGroup", "code": "main", "label": "General",
|
|
499
|
+
"cols": 2, "columns": head}]
|
|
500
|
+
tabs = []
|
|
501
|
+
if rest:
|
|
502
|
+
sections.append({"type": "columnGroup", "code": "details",
|
|
503
|
+
"label": "Attributes", "cols": 2, "columns": rest})
|
|
504
|
+
if sets:
|
|
505
|
+
tabs.append({"type": "section", "code": "details"})
|
|
506
|
+
for name in sets:
|
|
507
|
+
tabs.append({"type": "set", "code": name})
|
|
508
|
+
if tabs:
|
|
509
|
+
sections.append({"type": "tabGroup", "code": "tabs", "tabs": tabs})
|
|
510
|
+
|
|
511
|
+
return {"entity": code, "isDefault": True, "layout": {"sections": sections}}
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def build_translations(spec):
|
|
515
|
+
"""The `uk` section of a spec -> a dForge translations/uk-UA.json.
|
|
516
|
+
|
|
517
|
+
Hidden FK labels are derived from their Reference sibling ("Перевізник" -> that plus
|
|
518
|
+
" ID"), so only the visible column needs translating by hand. Key columns are derived
|
|
519
|
+
the same way: an entity's own key reads "<entity> ID" and a tabular section's line
|
|
520
|
+
number is always the same word, so neither is worth a spec entry.
|
|
521
|
+
"""
|
|
522
|
+
uk = spec["uk"]
|
|
523
|
+
entities = {}
|
|
524
|
+
for code, entity_uk in uk.get("entities", {}).items():
|
|
525
|
+
entity_spec = spec["entities"][code]
|
|
526
|
+
field_specs = {f[0]: f[1] for f in entity_spec["fields"]}
|
|
527
|
+
fields = {}
|
|
528
|
+
if entity_spec.get("lineOf"):
|
|
529
|
+
fields["line_no"] = {"label": "№ рядка"}
|
|
530
|
+
elif entity_spec.get("key"):
|
|
531
|
+
# Keyed by its own values: there is no surrogate column to label, and naming
|
|
532
|
+
# one makes `validate` report a translation for a field that does not exist.
|
|
533
|
+
pass
|
|
534
|
+
else:
|
|
535
|
+
fields[code + "_id"] = {"label": entity_uk["_"] + " ID"}
|
|
536
|
+
if placeholder_column(entity_spec):
|
|
537
|
+
fields["is_missing"] = {"label": u"Відсутній в інфобазі"}
|
|
538
|
+
for name, label in entity_uk.get("f", {}).items():
|
|
539
|
+
fields[name] = {"label": label}
|
|
540
|
+
if named(field_specs.get(name, ""), "ref:"):
|
|
541
|
+
fields[name + "_id"] = {"label": label + " ID"}
|
|
542
|
+
for name, options in entity_uk.get("o", {}).items():
|
|
543
|
+
fields.setdefault(name, {})["options"] = options
|
|
544
|
+
entry = {"label": entity_uk["_"], "desc": entity_uk.get("desc", entity_uk["_"])}
|
|
545
|
+
if fields:
|
|
546
|
+
entry["fields"] = fields
|
|
547
|
+
# A card's section heading is localized through its column group, under the
|
|
548
|
+
# entity that owns it.
|
|
549
|
+
layout = build_card_layout(code, entity_spec)
|
|
550
|
+
if layout:
|
|
551
|
+
groups = dict(
|
|
552
|
+
(section["code"], {"label": CARD_GROUPS_UK[section["code"]]})
|
|
553
|
+
for section in layout["layout"]["sections"]
|
|
554
|
+
if section["type"] == "columnGroup")
|
|
555
|
+
if groups:
|
|
556
|
+
entry["columnGroups"] = groups
|
|
557
|
+
entities[code] = entry
|
|
558
|
+
|
|
559
|
+
# An extension's columns belong to the other module's entity, so they are translated
|
|
560
|
+
# under its dotted code rather than under an entity of our own.
|
|
561
|
+
for target, extension_fields in spec.get("extends", {}).items():
|
|
562
|
+
translated = dict((column, {"label": label}) for column, label
|
|
563
|
+
in ((spec.get("extendUk") or {}).get(target) or {}).items())
|
|
564
|
+
for item in extension_fields:
|
|
565
|
+
column, kind, label = item[0], item[1], item[2]
|
|
566
|
+
ukrainian = item[3] if len(item) > 3 else None
|
|
567
|
+
if not ukrainian:
|
|
568
|
+
continue
|
|
569
|
+
translated[column] = {"label": ukrainian}
|
|
570
|
+
if named(kind, "ref:"):
|
|
571
|
+
translated[column + "_id"] = {"label": ukrainian + " ID"}
|
|
572
|
+
if translated:
|
|
573
|
+
entities[target] = {"fields": translated}
|
|
574
|
+
|
|
575
|
+
doc = {"entities": entities}
|
|
576
|
+
if uk.get("folder"):
|
|
577
|
+
doc["folders"] = {spec["code"]: {"label": uk["folder"]}}
|
|
578
|
+
if uk.get("views"):
|
|
579
|
+
doc["views"] = {code: {"label": label} for code, label in uk["views"].items()}
|
|
580
|
+
if uk.get("menu"):
|
|
581
|
+
menu = dict(uk["menu"])
|
|
582
|
+
doc["menus"] = {
|
|
583
|
+
"%s_menu" % spec["code"].replace("-", "_"): {
|
|
584
|
+
"label": menu.pop("_"),
|
|
585
|
+
"items": {code: {"label": label} for code, label in menu.items()},
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
if uk.get("roles"):
|
|
589
|
+
doc["roles"] = {code: {"label": label} for code, label in uk["roles"].items()}
|
|
590
|
+
if uk.get("settings"):
|
|
591
|
+
doc["settings"] = {code: {"label": label} for code, label in uk["settings"].items()}
|
|
592
|
+
return doc
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def write_json(path, doc):
|
|
596
|
+
directory = os.path.dirname(path)
|
|
597
|
+
if not os.path.isdir(directory):
|
|
598
|
+
os.makedirs(directory)
|
|
599
|
+
with codecs.open(path, "w", "utf-8") as f:
|
|
600
|
+
f.write(json.dumps(doc, ensure_ascii=False, indent="\t"))
|
|
601
|
+
f.write("\n")
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# Everything under a module directory is written from the spec, so a rename in the spec
|
|
605
|
+
# has to take the old file with it. Without this a renamed entity lingers as a stale
|
|
606
|
+
# JSON file that still validates and would still install.
|
|
607
|
+
OWNED = ["manifest.json", "settings.json", "registers.json",
|
|
608
|
+
os.path.join("entities"), os.path.join("ui"), os.path.join("deps"),
|
|
609
|
+
os.path.join("security"), os.path.join("translations"),
|
|
610
|
+
# Never written from the spec, only copied from the project's `authored/` tree -
|
|
611
|
+
# but owned all the same, so an action dropped there is dropped here too.
|
|
612
|
+
os.path.join("logic"), os.path.join("migrations"), os.path.join("print_templates")]
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def clean_module(base):
|
|
616
|
+
for relative in OWNED:
|
|
617
|
+
path = os.path.join(base, relative)
|
|
618
|
+
if os.path.isdir(path):
|
|
619
|
+
shutil.rmtree(path)
|
|
620
|
+
elif os.path.isfile(path):
|
|
621
|
+
os.remove(path)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def menu_view_codes(items):
|
|
625
|
+
"""Every data view a menu opens, however deep its groups nest."""
|
|
626
|
+
for item in items.values():
|
|
627
|
+
if item.get("dataViewCode"):
|
|
628
|
+
yield item["dataViewCode"]
|
|
629
|
+
for code in menu_view_codes(item.get("children") or {}):
|
|
630
|
+
yield code
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def data_source(code, entity):
|
|
634
|
+
"""A view's source over one entity, with the columns named when none would show.
|
|
635
|
+
|
|
636
|
+
A grid draws an entity's visible scalar columns, and an entity whose every column is
|
|
637
|
+
hidden - a register carrying only composite 1C references, which stay raw GUIDs - draws
|
|
638
|
+
the platform's empty state instead. Naming the columns is what the platform reads as the
|
|
639
|
+
author asking for them, visible or not, so such a view lists them rather than lying
|
|
640
|
+
empty.
|
|
641
|
+
"""
|
|
642
|
+
source = {"entityCode": code, "level": 0}
|
|
643
|
+
fields = entity["fields"]
|
|
644
|
+
scalar = [name for name, column in fields.items() if column.get("columnType") != "S"]
|
|
645
|
+
if not any("V" in fields[name].get("flags", "") for name in scalar):
|
|
646
|
+
shown = [name for name in scalar if not fields[name].get("isPk")]
|
|
647
|
+
source["columns"] = shown or scalar
|
|
648
|
+
return source
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def readable_lookups(roles, built, module):
|
|
652
|
+
"""Each role, plus `S` on whatever a lookup it may fill in points at.
|
|
653
|
+
|
|
654
|
+
A lookup the user cannot read the target of is hidden from the form, and a required
|
|
655
|
+
one then makes the record unsaveable - for a role that was granted the right to write
|
|
656
|
+
it. Which entities a lookup reaches is a fact of the entity files, not a decision, so
|
|
657
|
+
it is not left to each role to repeat: a right the role already names is kept as it is.
|
|
658
|
+
|
|
659
|
+
**A target in THIS module is named bare, whatever the link calls it.** The generator
|
|
660
|
+
writes some same-module links qualified (`production.act_rework`) and some bare, and a
|
|
661
|
+
qualified name does not match the bare key the role already grants - so the read grant
|
|
662
|
+
was added anyway, as a SECOND key for the same entity. The installer resolves both and
|
|
663
|
+
the read-only one wins: 18 of the port's own documents across 7 modules had `U` taken
|
|
664
|
+
off the role that owns them, which is a posting that cannot post and a card that cannot
|
|
665
|
+
be saved. Normalising the name is the whole fix; the rule that a right already named is
|
|
666
|
+
kept then does the rest.
|
|
667
|
+
"""
|
|
668
|
+
out = collections.OrderedDict()
|
|
669
|
+
prefix = module + "."
|
|
670
|
+
for code, role in roles.items():
|
|
671
|
+
rights = collections.OrderedDict(role.get("rights", {}))
|
|
672
|
+
reached = set()
|
|
673
|
+
for entity, granted in role.get("rights", {}).items():
|
|
674
|
+
if not set(granted) & set("IU") or entity not in built:
|
|
675
|
+
continue
|
|
676
|
+
for column in built[entity]["fields"].values():
|
|
677
|
+
if column.get("columnType") == "R" and column.get("link"):
|
|
678
|
+
target = column["link"]["entity"]
|
|
679
|
+
if target.startswith(prefix) and target[len(prefix):] in built:
|
|
680
|
+
target = target[len(prefix):]
|
|
681
|
+
reached.add(target)
|
|
682
|
+
for target in sorted(reached - set(rights)):
|
|
683
|
+
rights[target] = "S"
|
|
684
|
+
out[code] = dict(role, rights=rights)
|
|
685
|
+
return out
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# A right on another module's entity is qualified by that module; these keys are not
|
|
689
|
+
# entities at all, and their own dots name no provider.
|
|
690
|
+
NON_ENTITY_RIGHTS = ("action:", "sp:", "report:")
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def dependency_contracts(spec, built, dependencies):
|
|
694
|
+
"""deps/<provider>.json for every manifest dependency: what this module consumes.
|
|
695
|
+
|
|
696
|
+
The platform refuses a dependency without one, and checks each against what it can
|
|
697
|
+
read off the package - reference links, FK blocks, `extends`, role grants and view
|
|
698
|
+
sources. So the contract is read off the very same places, from the entities as they
|
|
699
|
+
are written, rather than from the spec's shorthand: a contract derived from anything
|
|
700
|
+
else is a second opinion that can disagree with the files it describes.
|
|
701
|
+
|
|
702
|
+
Every cross-module touch lands on the provider's key (`ref:` points at `<entity>_id`),
|
|
703
|
+
so a contract names entities and their keys and no other columns. A touch that lands
|
|
704
|
+
elsewhere raises, because its storage class would have to be looked up in the provider
|
|
705
|
+
and nothing here does that yet.
|
|
706
|
+
"""
|
|
707
|
+
own = spec["code"]
|
|
708
|
+
used = collections.defaultdict(lambda: collections.defaultdict(set))
|
|
709
|
+
|
|
710
|
+
def note(qualified, token, column=None):
|
|
711
|
+
module, _, entity = (qualified or "").partition(".")
|
|
712
|
+
if not entity or module == own:
|
|
713
|
+
return
|
|
714
|
+
used[module][entity].add(token)
|
|
715
|
+
if column and column != target_pk(qualified):
|
|
716
|
+
raise ValueError("%s: %s reads %s.%s, which is not its key - deps/%s.json would "
|
|
717
|
+
"have to type that column" % (own, token, qualified, column, module))
|
|
718
|
+
|
|
719
|
+
for key, entity in built.items():
|
|
720
|
+
if entity.get("extends"):
|
|
721
|
+
note(entity["extends"], "extends:" + entity["extends"])
|
|
722
|
+
for name, column in entity.get("fields", {}).items():
|
|
723
|
+
link = column.get("link")
|
|
724
|
+
if link:
|
|
725
|
+
note(link["entity"], "ref:%s.%s" % (key, name), link["otherKey"])
|
|
726
|
+
for reference in (entity.get("references") or {}).values():
|
|
727
|
+
note(reference["to"]["entity"], "ref:%s.%s" % (key, reference["from"]["field"]),
|
|
728
|
+
reference["to"]["field"])
|
|
729
|
+
|
|
730
|
+
for role, body in spec["roles"].items():
|
|
731
|
+
for right in body.get("rights", {}):
|
|
732
|
+
if not right.startswith(NON_ENTITY_RIGHTS):
|
|
733
|
+
note(right, "role:" + role)
|
|
734
|
+
|
|
735
|
+
# A register is declared over an entity, and the declaration is a row in the platform's
|
|
736
|
+
# register table pointing at `metadata.entity`. That is what the `metadata` floor in
|
|
737
|
+
# the manifest buys, so it is what that dependency's contract names.
|
|
738
|
+
# A turnover register keeps its movements in a journal rather than a state entity.
|
|
739
|
+
for register in (spec.get("registerDefs") or {}).values():
|
|
740
|
+
used["metadata"]["entity"].add("ref:%s" % (register.get("entity") or register["journal"]))
|
|
741
|
+
|
|
742
|
+
# What the project says a module uses that nothing in the package shows - a posting
|
|
743
|
+
# column bound to another module's register writes that module's journal, and neither
|
|
744
|
+
# the binding nor its register is a reference link a contract can be read off.
|
|
745
|
+
for qualified, token in DECLARED_USES.get(own, ()):
|
|
746
|
+
note(qualified, token)
|
|
747
|
+
|
|
748
|
+
# A system module required for what the platform does at install - running a
|
|
749
|
+
# migration - is a version gate, not a consumption: its contract names no entity.
|
|
750
|
+
gates = set(DECLARED_MODULE.get(own, {}).get("requires", {}))
|
|
751
|
+
for gate in gates:
|
|
752
|
+
used.setdefault(gate, collections.defaultdict(set))
|
|
753
|
+
|
|
754
|
+
unused = sorted(set(dependencies) - set(used))
|
|
755
|
+
if unused:
|
|
756
|
+
raise ValueError("%s depends on %s but uses nothing of it that a contract can "
|
|
757
|
+
"name - drop the dependency or say what it is for"
|
|
758
|
+
% (own, ", ".join(unused)))
|
|
759
|
+
undeclared = sorted(set(used) - set(dependencies))
|
|
760
|
+
if undeclared:
|
|
761
|
+
raise ValueError("%s uses %s without depending on it"
|
|
762
|
+
% (own, ", ".join("%s (%s)" % (m, ", ".join(sorted(used[m])))
|
|
763
|
+
for m in undeclared)))
|
|
764
|
+
return dict(
|
|
765
|
+
(module, {
|
|
766
|
+
"module": module,
|
|
767
|
+
"version": dependencies[module],
|
|
768
|
+
"entities": collections.OrderedDict(
|
|
769
|
+
(entity, {"pk": entity + "_id", "use": sorted(tokens)})
|
|
770
|
+
for entity, tokens in sorted(used[module].items())),
|
|
771
|
+
})
|
|
772
|
+
for module in sorted(used))
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
# What each register shape first became understood in. A module asks for the highest floor
|
|
776
|
+
# its own declarations reach, because asking for less than a declaration needs is a manifest
|
|
777
|
+
# that lies: it installs against a tenant that would refuse the register, and the refusal
|
|
778
|
+
# arrives as a register error rather than as the version mismatch it is.
|
|
779
|
+
#
|
|
780
|
+
# There is one floor now, and the reason is worth keeping. These were seven versions -
|
|
781
|
+
# 1.13.0 through 1.20.0, one per shape as each landed - and the platform branch **squashed
|
|
782
|
+
# them into 1.13.x**, one minor per branch. So a module generated against the old table asked
|
|
783
|
+
# for `metadata >= 1.17.0` and was refused by a tenant carrying every one of those features
|
|
784
|
+
# under 1.13.2.
|
|
785
|
+
#
|
|
786
|
+
# It moved once more, and not because a shape changed: merging main back into the register
|
|
787
|
+
# branch pushed the register migration from 1.13.0 to 1.14.0, so the whole set is understood
|
|
788
|
+
# at 1.14.0 and a manifest asking for 1.13.0 now understates what it needs. Read the floor
|
|
789
|
+
# from the platform's own `system-modules/metadata/manifest.json` when in doubt - it is the
|
|
790
|
+
# only place that cannot drift.
|
|
791
|
+
#
|
|
792
|
+
# The list stays a list: the next feature to land gets a row, and the mechanism below then
|
|
793
|
+
# does something again. Reading it as dead weight and deleting it is how the lesson gets
|
|
794
|
+
# lost.
|
|
795
|
+
REGISTER_FLOOR = [
|
|
796
|
+
("1.14.0", lambda d: True),
|
|
797
|
+
# `recordChangesOnly` / `absentAs`, on the dForge-core branch 261006-binding-literal-types.
|
|
798
|
+
("1.17.1", lambda d: bool(d.get("recordChangesOnly"))),
|
|
799
|
+
]
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def metadata_floor(register_defs):
|
|
803
|
+
"""The lowest platform metadata version that understands every register declared."""
|
|
804
|
+
floor = "1.14.0"
|
|
805
|
+
for definition in register_defs.values():
|
|
806
|
+
for version, applies in REGISTER_FLOOR:
|
|
807
|
+
if applies(definition) and _version(version) > _version(floor):
|
|
808
|
+
floor = version
|
|
809
|
+
return floor
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _version(text):
|
|
813
|
+
return tuple(int(part) for part in text.split("."))
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def emit_module(spec):
|
|
817
|
+
base = os.path.join(MODULES, spec["code"])
|
|
818
|
+
clean_module(base)
|
|
819
|
+
entities = spec["entities"]
|
|
820
|
+
|
|
821
|
+
# The metadata floor rides in the dependency map beside the module ones — the
|
|
822
|
+
# manifest has no separate "requires" key and the schema rejects one. Which floor
|
|
823
|
+
# depends on what the registers are, not merely that there are some.
|
|
824
|
+
dependencies = dict(
|
|
825
|
+
spec.get("dependencies", {}),
|
|
826
|
+
**({"metadata": ">=" + metadata_floor(spec["registerDefs"])}
|
|
827
|
+
if spec.get("registerDefs") else {}))
|
|
828
|
+
|
|
829
|
+
write_json(os.path.join(base, "manifest.json"), {
|
|
830
|
+
"packageFormat": 1,
|
|
831
|
+
"moduleId": spec["moduleId"],
|
|
832
|
+
"code": spec["code"],
|
|
833
|
+
# A module whose schema has already changed once carries the version that says
|
|
834
|
+
# so. Hard-coding 0.1.0 here quietly reverted a bump made in the package, and a
|
|
835
|
+
# reverted version is how an upgrade stops being applied.
|
|
836
|
+
"version": spec.get("version", "0.1.0"),
|
|
837
|
+
"dbSchemaVersion": spec.get("version", "0.1.0"),
|
|
838
|
+
"displayName": spec["displayName"],
|
|
839
|
+
"description": spec["description"],
|
|
840
|
+
"auditHistory": spec.get("auditHistory", "basic"),
|
|
841
|
+
"author": {"name": "SweetSoft"},
|
|
842
|
+
"license": "proprietary",
|
|
843
|
+
"category": "operations",
|
|
844
|
+
"tags": spec["tags"],
|
|
845
|
+
"icon": spec["icon"],
|
|
846
|
+
"dependencies": dependencies,
|
|
847
|
+
# An extension is listed under its dotted target code, so the installer knows it
|
|
848
|
+
# adds to another module's entity rather than declaring one of its own.
|
|
849
|
+
"entities": dict(
|
|
850
|
+
[(code, "./entities/%s.json" % code) for code in entities]
|
|
851
|
+
+ [(target, "./entities/%s.json" % target)
|
|
852
|
+
for target in spec.get("extends", {})]),
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
# Written before the entities read as a matter of form only \u2014 the installer resolves
|
|
856
|
+
# the declaration itself \u2014 but a register names the entity holding its state, so the
|
|
857
|
+
# file that owns the invariants belongs beside the module root, not under ui/.
|
|
858
|
+
if spec.get("registerDefs"):
|
|
859
|
+
write_json(os.path.join(base, "registers.json"), spec["registerDefs"])
|
|
860
|
+
|
|
861
|
+
built = collections.OrderedDict(
|
|
862
|
+
(code, build_entity(code, entity_spec)) for code, entity_spec in entities.items())
|
|
863
|
+
for code, entity in built.items():
|
|
864
|
+
write_json(os.path.join(base, "entities", code + ".json"), entity)
|
|
865
|
+
|
|
866
|
+
for target, fields in spec.get("extends", {}).items():
|
|
867
|
+
built[target] = build_extension(spec["code"], target, fields,
|
|
868
|
+
(spec.get("extendRegistry") or {}).get(target),
|
|
869
|
+
(spec.get("extendDescription") or {}).get(target))
|
|
870
|
+
write_json(os.path.join(base, "entities", target + ".json"), built[target])
|
|
871
|
+
|
|
872
|
+
# `hideInFolder` keeps an entity off the folder's own list, but a menu item opens its
|
|
873
|
+
# view INSIDE the folder, and the platform refuses an entity the folder does not grant
|
|
874
|
+
# ("Entity 'X' is not accessible in folder 'Y'") - only when someone clicks it. So
|
|
875
|
+
# whatever the menu reaches is granted, hidden or not.
|
|
876
|
+
in_menu = set(menu_view_codes(spec["menu"]))
|
|
877
|
+
write_json(os.path.join(base, "ui", "folders.json"), {
|
|
878
|
+
"label": spec["label"],
|
|
879
|
+
"description": spec["description"],
|
|
880
|
+
"color": spec["color"],
|
|
881
|
+
"entities": {code: ({"quickAdd": True} if entities[code].get("quickAdd") else {})
|
|
882
|
+
for code in entities
|
|
883
|
+
if not entities[code].get("hideInFolder") or plural(code) in in_menu},
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
layouts = collections.OrderedDict()
|
|
887
|
+
for code, entity_spec in entities.items():
|
|
888
|
+
found = build_card_layout(code, entity_spec)
|
|
889
|
+
if found:
|
|
890
|
+
layouts[code + "_card"] = found
|
|
891
|
+
if layouts:
|
|
892
|
+
write_json(os.path.join(base, "ui", "card_layouts.json"), layouts)
|
|
893
|
+
|
|
894
|
+
write_json(os.path.join(base, "ui", "data_views.json"), {
|
|
895
|
+
plural(code): {
|
|
896
|
+
"label": entities[code]["desc"],
|
|
897
|
+
"dataSources": [data_source(code, built[code])],
|
|
898
|
+
}
|
|
899
|
+
for code in entities
|
|
900
|
+
})
|
|
901
|
+
|
|
902
|
+
write_json(os.path.join(base, "ui", "menus.json"), {
|
|
903
|
+
"%s_menu" % spec["code"].replace("-", "_"): {
|
|
904
|
+
"label": spec["label"],
|
|
905
|
+
"folders": [spec["code"]],
|
|
906
|
+
"items": spec["menu"],
|
|
907
|
+
}
|
|
908
|
+
})
|
|
909
|
+
|
|
910
|
+
roles = readable_lookups(spec["roles"], built, spec["code"])
|
|
911
|
+
write_json(os.path.join(base, "security", "roles.json"), roles)
|
|
912
|
+
|
|
913
|
+
for provider, contract in dependency_contracts(dict(spec, roles=roles), built,
|
|
914
|
+
dependencies).items():
|
|
915
|
+
write_json(os.path.join(base, "deps", provider + ".json"), contract)
|
|
916
|
+
|
|
917
|
+
if spec.get("uk"):
|
|
918
|
+
write_json(os.path.join(base, "translations", "uk-UA.json"),
|
|
919
|
+
build_translations(spec))
|
|
920
|
+
|
|
921
|
+
if spec.get("settings"):
|
|
922
|
+
write_json(os.path.join(base, "settings.json"), spec["settings"])
|
|
923
|
+
|
|
924
|
+
copied = apply_authored(base, spec["code"])
|
|
925
|
+
|
|
926
|
+
print("%-10s %2d entities -> %s%s" % (spec["code"], len(entities), base,
|
|
927
|
+
" (+%d authored)" % copied if copied else ""))
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _merge(into, extra):
|
|
931
|
+
"""`extra` laid over `into`, nested dictionaries merged and every other value replaced."""
|
|
932
|
+
for key, value in extra.items():
|
|
933
|
+
if isinstance(value, dict) and isinstance(into.get(key), dict):
|
|
934
|
+
_merge(into[key], value)
|
|
935
|
+
else:
|
|
936
|
+
into[key] = value
|
|
937
|
+
return into
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def apply_authored(base, module):
|
|
941
|
+
"""Copy the project's `authored/<module>/` files into the module just emitted.
|
|
942
|
+
|
|
943
|
+
What the generator cannot derive - an action and its DSL - is written once in the
|
|
944
|
+
project and laid over every regeneration, so `genmodule` never drops it. A translations
|
|
945
|
+
or roles file is merged into the generated one, and so is a dependency contract - the
|
|
946
|
+
library functions an action imports from that dependency (`"functions"`), beside the
|
|
947
|
+
entities the generator found it uses; any other file the generator already wrote is
|
|
948
|
+
refused, since replacing it would quietly discard what was derived. Returns how many
|
|
949
|
+
files were laid over.
|
|
950
|
+
"""
|
|
951
|
+
source = binding.authored(module)
|
|
952
|
+
if not source:
|
|
953
|
+
return 0
|
|
954
|
+
count = 0
|
|
955
|
+
for directory, _, names in os.walk(source):
|
|
956
|
+
for name in sorted(names):
|
|
957
|
+
if name.endswith((".pyc", ".pyo")):
|
|
958
|
+
continue
|
|
959
|
+
path = os.path.join(directory, name)
|
|
960
|
+
relative = os.path.relpath(path, source)
|
|
961
|
+
target = os.path.join(base, relative)
|
|
962
|
+
# Translations, roles and contracts are merged into the generated file: an action's
|
|
963
|
+
# label, its Execute right and its imports live beside what was derived.
|
|
964
|
+
contract = os.path.dirname(relative) == "deps" and name.endswith(".json")
|
|
965
|
+
if contract and not os.path.isfile(target):
|
|
966
|
+
raise ValueError("authored/%s/%s: %s is not a dependency of %s"
|
|
967
|
+
% (module, relative.replace(os.sep, "/"), name[:-5], module))
|
|
968
|
+
# An entity file adds what the 1C metadata cannot give - formula columns - to the
|
|
969
|
+
# entity the generator derived; it never stands for an entity of its own.
|
|
970
|
+
entity = os.path.dirname(relative) == "entities" and name.endswith(".json")
|
|
971
|
+
if entity and not os.path.isfile(target):
|
|
972
|
+
raise ValueError("authored/%s/%s: the generator wrote no entity %s"
|
|
973
|
+
% (module, relative.replace(os.sep, "/"), name[:-5]))
|
|
974
|
+
if (os.path.dirname(relative) == "translations" and name.endswith(".json") or contract or entity
|
|
975
|
+
or relative.replace(os.sep, "/") == "security/roles.json"):
|
|
976
|
+
with codecs.open(path, "r", "utf-8") as f:
|
|
977
|
+
extra = json.load(f)
|
|
978
|
+
generated = {}
|
|
979
|
+
if os.path.isfile(target):
|
|
980
|
+
with codecs.open(target, "r", "utf-8") as f:
|
|
981
|
+
generated = json.load(f)
|
|
982
|
+
write_json(target, _merge(generated, extra))
|
|
983
|
+
else:
|
|
984
|
+
if os.path.exists(target):
|
|
985
|
+
raise ValueError("authored/%s/%s would replace a file the generator wrote"
|
|
986
|
+
% (module, relative.replace(os.sep, "/")))
|
|
987
|
+
if not os.path.isdir(os.path.dirname(target)):
|
|
988
|
+
os.makedirs(os.path.dirname(target))
|
|
989
|
+
shutil.copyfile(path, target)
|
|
990
|
+
count += 1
|
|
991
|
+
return count
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
# ------------------------------------------------------------------ generated modules
|
|
995
|
+
|
|
996
|
+
MODULE_UUID = "b1000000-0000-0000-0000-0000000000%02d"
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def module_uuid(code, index):
|
|
1000
|
+
"""A stable id for a generated module.
|
|
1001
|
+
|
|
1002
|
+
The counter numbers modules by their position in the contour map, which is fine while
|
|
1003
|
+
there is one map and wrong the moment there are two: a second project's twelfth module
|
|
1004
|
+
drew the first project's twelfth id, and install refused it - "moduleId is already used
|
|
1005
|
+
by a different installed module 'catering'" - after creating 359 tables. Two projects
|
|
1006
|
+
meant for the SAME tenant cannot number themselves independently.
|
|
1007
|
+
|
|
1008
|
+
The first project keeps the counter, because its modules are installed in live tenants
|
|
1009
|
+
and a module that changes its id is a DIFFERENT module to the platform: the old one
|
|
1010
|
+
would be orphaned and the new one installed beside it. Every other project derives its
|
|
1011
|
+
ids from the module code instead, which collides with nothing and needs no registry.
|
|
1012
|
+
"""
|
|
1013
|
+
if os.path.basename(paths.PROJECT) == "project":
|
|
1014
|
+
return MODULE_UUID % (10 + index)
|
|
1015
|
+
digest = hashlib.sha1(code.encode("utf-8")).hexdigest()
|
|
1016
|
+
return "b2000000-0000-0000-0000-%s" % digest[:12]
|
|
1017
|
+
|
|
1018
|
+
FAMILY_GROUPS = [
|
|
1019
|
+
("documents", "Documents", "file-text", 10, ("Document", "DocumentJournal")),
|
|
1020
|
+
("catalogs", "Catalogs", "database", 20, ("Reference", "Chrc", "Acc")),
|
|
1021
|
+
("registers", "Registers", "table", 30, ("InfoRg", "AccumRg")),
|
|
1022
|
+
]
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def dependencies_of(entities):
|
|
1026
|
+
"""Which other modules this one's references reach into - and whose registers its
|
|
1027
|
+
posting columns bind to, since a binding names `module.register` and the register has
|
|
1028
|
+
to be installed before the column that posts to it."""
|
|
1029
|
+
found = set()
|
|
1030
|
+
for entity in entities.values():
|
|
1031
|
+
for field in entity["fields"]:
|
|
1032
|
+
spec = field[1]
|
|
1033
|
+
if named(spec, "ref:") and "." in spec:
|
|
1034
|
+
found.add(spec[len("ref:"):].split(".")[0])
|
|
1035
|
+
for column in (entity.get("registry") or {}).values():
|
|
1036
|
+
register = (column.get("params") or {}).get("register") or ""
|
|
1037
|
+
if "." in register:
|
|
1038
|
+
found.add(register.split(".")[0])
|
|
1039
|
+
return found
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def authored_reports(module):
|
|
1043
|
+
"""The reports a module's authored `ui/reports.json` declares, in file order: [(code, label)].
|
|
1044
|
+
|
|
1045
|
+
The generator writes the menu, and a report nobody can reach from it is a report nobody
|
|
1046
|
+
opens, so the reports the project authors for a generated module get a section of their own
|
|
1047
|
+
(`auto_menu`). Their labels are the report's own; the Ukrainian ones come with the authored
|
|
1048
|
+
translations, merged like any other."""
|
|
1049
|
+
source = binding.authored(module)
|
|
1050
|
+
path = source and os.path.join(source, "ui", "reports.json")
|
|
1051
|
+
if not path or not os.path.isfile(path):
|
|
1052
|
+
return []
|
|
1053
|
+
with codecs.open(path, "r", "utf-8") as f:
|
|
1054
|
+
reports = json.load(f, object_pairs_hook=collections.OrderedDict)
|
|
1055
|
+
return [(code, report.get("label") or code) for code, report in reports.items()]
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def auto_menu(entities, module=None):
|
|
1059
|
+
"""Menu sections by 1C family, and the module's authored reports. Line entities are
|
|
1060
|
+
reached through their parent."""
|
|
1061
|
+
menu = {}
|
|
1062
|
+
reports = authored_reports(module) if module else []
|
|
1063
|
+
if reports:
|
|
1064
|
+
menu["reports"] = {"orderNum": 5, "label": "Reports", "icon": "file-earmark-spreadsheet",
|
|
1065
|
+
"children": dict((code, {"itemType": "R", "reportCode": code,
|
|
1066
|
+
"orderNum": 6 + n, "label": label})
|
|
1067
|
+
for n, (code, label) in enumerate(reports))}
|
|
1068
|
+
for group, label, icon, order, families in FAMILY_GROUPS:
|
|
1069
|
+
children = {}
|
|
1070
|
+
item_order = order + 1
|
|
1071
|
+
for code, entity in entities.items():
|
|
1072
|
+
if entity.get("lineOf") or entity.get("family") not in families:
|
|
1073
|
+
continue
|
|
1074
|
+
children[code] = {"itemType": "V", "dataViewCode": plural(code),
|
|
1075
|
+
"orderNum": item_order, "label": entity["desc"]}
|
|
1076
|
+
item_order += 1
|
|
1077
|
+
if children:
|
|
1078
|
+
menu[group] = {"orderNum": order, "label": label, "icon": icon,
|
|
1079
|
+
"children": children}
|
|
1080
|
+
return menu
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def auto_roles(module, entities):
|
|
1084
|
+
visible = [c for c, e in entities.items() if not e.get("lineOf")]
|
|
1085
|
+
lines = [c for c, e in entities.items() if e.get("lineOf")]
|
|
1086
|
+
everything = visible + lines
|
|
1087
|
+
return {
|
|
1088
|
+
"%s.admin" % module: {
|
|
1089
|
+
"description": "%s administrator" % module,
|
|
1090
|
+
"rights": dict((code, "SIUDC") for code in everything),
|
|
1091
|
+
},
|
|
1092
|
+
"%s.user" % module: {
|
|
1093
|
+
"description": "Works with %s data" % module,
|
|
1094
|
+
"rights": dict((code, "SIU") for code in everything),
|
|
1095
|
+
},
|
|
1096
|
+
"%s.viewer" % module: {
|
|
1097
|
+
"description": "Read-only access to %s data" % module,
|
|
1098
|
+
"rights": dict((code, "S") for code in everything),
|
|
1099
|
+
},
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
MENU_GROUP_UK = {"documents": "Документи", "catalogs": "Довідники",
|
|
1104
|
+
"registers": "Регістри", "reports": "Звіти"}
|
|
1105
|
+
|
|
1106
|
+
ROLE_SUFFIX_UK = {"admin": "Адміністратор", "user": "Ведення", "viewer": "Перегляд"}
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
def auto_uk(module, entities, contour, built_menu):
|
|
1110
|
+
"""The module's `uk` section, from the Ukrainian each entity carries.
|
|
1111
|
+
|
|
1112
|
+
Everything below the entity - field labels, dropdown options - was translated in
|
|
1113
|
+
`autospec`, from the same 1C synonym the English label came from. What is added here
|
|
1114
|
+
is the furniture around it: the folder, the views, the menu and the roles.
|
|
1115
|
+
"""
|
|
1116
|
+
translated = collections.OrderedDict(
|
|
1117
|
+
(code, entity["uk"]) for code, entity in entities.items() if entity.get("uk"))
|
|
1118
|
+
if not translated:
|
|
1119
|
+
return None
|
|
1120
|
+
|
|
1121
|
+
label = contour.get("uk_label", contour["label"])
|
|
1122
|
+
|
|
1123
|
+
# Label only what the MENU actually holds, groups included. Two ways a label for a
|
|
1124
|
+
# non-existent item used to get written: a group `auto_menu` left out because no entity
|
|
1125
|
+
# of this module belongs to it - `catalogs` in five modules, `registers` in one - and,
|
|
1126
|
+
# since the register overlay could introduce an entity, one with no 1C FAMILY, which is
|
|
1127
|
+
# what `auto_menu` groups by. The installer reports each as "no code mapping found".
|
|
1128
|
+
menu = {"_": label}
|
|
1129
|
+
in_menu = set()
|
|
1130
|
+
for group, section in (built_menu or {}).items():
|
|
1131
|
+
menu[group] = MENU_GROUP_UK.get(group, group)
|
|
1132
|
+
in_menu |= set((section.get("children") or {}).keys())
|
|
1133
|
+
for code, entity in entities.items():
|
|
1134
|
+
if code in in_menu and entity.get("uk"):
|
|
1135
|
+
menu[code] = entity["uk"]["_"]
|
|
1136
|
+
# an authored report's menu item reads as the report does in the authored translation
|
|
1137
|
+
source = binding.authored(module)
|
|
1138
|
+
path = source and os.path.join(source, "translations", "uk-UA.json")
|
|
1139
|
+
if path and os.path.isfile(path):
|
|
1140
|
+
with codecs.open(path, "r", "utf-8") as f:
|
|
1141
|
+
reports = json.load(f).get("reports") or {}
|
|
1142
|
+
for code, report in reports.items():
|
|
1143
|
+
if code in in_menu and report.get("label"):
|
|
1144
|
+
menu[code] = report["label"]
|
|
1145
|
+
|
|
1146
|
+
return {
|
|
1147
|
+
"folder": label,
|
|
1148
|
+
"menu": menu,
|
|
1149
|
+
"views": dict((plural(code), entity["uk"]["_"])
|
|
1150
|
+
for code, entity in entities.items() if entity.get("uk")),
|
|
1151
|
+
"roles": dict(("%s.%s" % (module, key), "%s - %s" % (suffix, label))
|
|
1152
|
+
for key, suffix in ROLE_SUFFIX_UK.items()),
|
|
1153
|
+
"entities": translated,
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def apply_registers(module, entities, overlay):
|
|
1158
|
+
"""Fold a project's register overlay into a generated module's entities.
|
|
1159
|
+
|
|
1160
|
+
A generated entity is a dict the same shape an authored one has, so a register over
|
|
1161
|
+
one is the same three things it is anywhere: the columns the register needs on its
|
|
1162
|
+
own table, the declaration, and the `A` column each writing document binds from. The
|
|
1163
|
+
only thing this does that an authored spec does not is find the entity by code and
|
|
1164
|
+
fail loudly when the code is wrong - a silent miss here would install a register with
|
|
1165
|
+
nothing writing to it.
|
|
1166
|
+
"""
|
|
1167
|
+
if not overlay:
|
|
1168
|
+
return None
|
|
1169
|
+
|
|
1170
|
+
# An entity the register needs and the infobase does not have. A dForge balance register
|
|
1171
|
+
# keeps its running total in a STATE entity, and 1C keeps no such table - it holds the
|
|
1172
|
+
# movements and computes the balance from them on every read. So the state has no 1C
|
|
1173
|
+
# original to generate from, and until this the overlay could add columns, bindings and
|
|
1174
|
+
# constraints to a generated entity but never an entity of its own.
|
|
1175
|
+
#
|
|
1176
|
+
# It is applied FIRST so the sections below can target what it introduced, and it refuses
|
|
1177
|
+
# a code that already exists: silently shadowing an entity the generator derived from the
|
|
1178
|
+
# infobase would replace a copied table with a hand-written one and nothing would say so.
|
|
1179
|
+
#
|
|
1180
|
+
# The spec is the same shape autospec builds, because everything downstream reads that
|
|
1181
|
+
# shape - `fields` as a list of tuples, `uk` with its `_`/`desc`/`f`/`o`. The surrogate
|
|
1182
|
+
# primary key is added for it like any other entity, which is what the platform's
|
|
1183
|
+
# "one column it mints" rule wants.
|
|
1184
|
+
for code, spec in (overlay.get("entities") or {}).items():
|
|
1185
|
+
if code in entities:
|
|
1186
|
+
raise KeyError("registers.py: %s already has an entity %r - an overlay entity "
|
|
1187
|
+
"may not shadow one the generator derived from the infobase"
|
|
1188
|
+
% (module, code))
|
|
1189
|
+
entities[code] = dict(spec)
|
|
1190
|
+
|
|
1191
|
+
# A column the generator typed from the infobase and the register needs typed as what
|
|
1192
|
+
# it MEANS. The one case so far is a date dimension the 1C author declared as a string:
|
|
1193
|
+
# the physical column is nvarchar, so the generator read nvarchar, and a text date
|
|
1194
|
+
# neither sorts nor compares nor matches what a posting would write into it.
|
|
1195
|
+
for code, columns in (overlay.get("retype") or {}).items():
|
|
1196
|
+
if code not in entities:
|
|
1197
|
+
raise KeyError("registers.py: %s has no entity %r to retype a column on"
|
|
1198
|
+
% (module, code))
|
|
1199
|
+
fields = list(entities[code]["fields"])
|
|
1200
|
+
for name, spec in sorted(columns.items()):
|
|
1201
|
+
for i, item in enumerate(fields):
|
|
1202
|
+
if item[0] == name:
|
|
1203
|
+
fields[i] = (item[0], spec) + tuple(item[2:])
|
|
1204
|
+
break
|
|
1205
|
+
else:
|
|
1206
|
+
raise KeyError("registers.py: %s.%s has no column %r to retype"
|
|
1207
|
+
% (module, code, name))
|
|
1208
|
+
entities[code]["fields"] = fields
|
|
1209
|
+
|
|
1210
|
+
for code, fields in (overlay.get("fields") or {}).items():
|
|
1211
|
+
if code not in entities:
|
|
1212
|
+
raise KeyError("registers.py: %s has no entity %r to add register columns to"
|
|
1213
|
+
% (module, code))
|
|
1214
|
+
entities[code]["fields"] = list(entities[code]["fields"]) + list(fields)
|
|
1215
|
+
|
|
1216
|
+
for code, columns in (overlay.get("bind") or {}).items():
|
|
1217
|
+
if code not in entities:
|
|
1218
|
+
raise KeyError("registers.py: %s has no entity %r to bind a register from"
|
|
1219
|
+
% (module, code))
|
|
1220
|
+
registry = dict(entities[code].get("registry") or {})
|
|
1221
|
+
registry.update(columns)
|
|
1222
|
+
entities[code]["registry"] = registry
|
|
1223
|
+
|
|
1224
|
+
# A rule the 1C handler enforced by refusing to post. The platform has no precondition
|
|
1225
|
+
# on an `A` column, so the invariant behind the refusal is declared instead - as a real
|
|
1226
|
+
# Postgres CHECK, which holds on every path into the table rather than only the one the
|
|
1227
|
+
# UI takes.
|
|
1228
|
+
for code, constraints in (overlay.get("constraints") or {}).items():
|
|
1229
|
+
if code not in entities:
|
|
1230
|
+
raise KeyError("registers.py: %s has no entity %r to constrain" % (module, code))
|
|
1231
|
+
entities[code]["constraints"] = dict(entities[code].get("constraints") or {},
|
|
1232
|
+
**constraints)
|
|
1233
|
+
|
|
1234
|
+
# An index a posting action's lookup needs on a generated table. 1C keeps its information
|
|
1235
|
+
# registers indexed by their dimensions; the generator copies the rows, not the index, so
|
|
1236
|
+
# a slice "the price in force for this product here" scans the whole table - fine once,
|
|
1237
|
+
# a timeout when a document asks it per line.
|
|
1238
|
+
for code, indexes in (overlay.get("indexes") or {}).items():
|
|
1239
|
+
if code not in entities:
|
|
1240
|
+
raise KeyError("registers.py: %s has no entity %r to index" % (module, code))
|
|
1241
|
+
entities[code]["indexes"] = dict(entities[code].get("indexes") or {}, **indexes)
|
|
1242
|
+
|
|
1243
|
+
# The Ukrainian for what was just added. Everything else in a generated module was
|
|
1244
|
+
# translated from the 1C synonym it came from; these columns have no 1C original, so
|
|
1245
|
+
# the only place their Ukrainian can come from is the overlay that introduced them.
|
|
1246
|
+
for code, labels in (overlay.get("uk") or {}).items():
|
|
1247
|
+
if code not in entities:
|
|
1248
|
+
raise KeyError("registers.py: %s has no entity %r to translate" % (module, code))
|
|
1249
|
+
uk = entities[code].get("uk")
|
|
1250
|
+
if uk is not None:
|
|
1251
|
+
uk["f"] = dict(uk.get("f") or {}, **labels)
|
|
1252
|
+
|
|
1253
|
+
# Another module's document this one posts: its own columns and posting columns on it, in
|
|
1254
|
+
# an extension (`extends`) rather than on an entity of this module.
|
|
1255
|
+
if overlay.get("extend"):
|
|
1256
|
+
OVERLAY_EXTENDS[module] = overlay["extend"]
|
|
1257
|
+
|
|
1258
|
+
# What the module consumes that its files do not show, for its dependency contracts:
|
|
1259
|
+
# [("provider_module.entity", "<kind>:<symbol>"), ...].
|
|
1260
|
+
if overlay.get("uses"):
|
|
1261
|
+
DECLARED_USES[module] = list(overlay["uses"])
|
|
1262
|
+
|
|
1263
|
+
# A generated module is 0.1.0 until its schema has to change under rows already
|
|
1264
|
+
# installed. The platform's upgrade adds columns and never removes a constraint, so a
|
|
1265
|
+
# rule taken off a column needs an upgrade migration (`authored/<module>/migrations/
|
|
1266
|
+
# <version>.sql`), which runs only on a version above the installed one - and only on
|
|
1267
|
+
# a platform that runs migrations at all, which is what `requires` gates on.
|
|
1268
|
+
declared = dict((key, overlay[key]) for key in ("version", "requires") if overlay.get(key))
|
|
1269
|
+
if declared:
|
|
1270
|
+
DECLARED_MODULE[module] = declared
|
|
1271
|
+
|
|
1272
|
+
return overlay.get("defs") or None
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
# module -> [(qualified provider entity, provenance token)], from the register overlay.
|
|
1276
|
+
DECLARED_USES = {}
|
|
1277
|
+
# module -> {"version": "x.y.z", "requires": {system module: range}}, from the overlay.
|
|
1278
|
+
DECLARED_MODULE = {}
|
|
1279
|
+
# What a module's register overlay adds to another module's entity: {module: {target: {...}}}.
|
|
1280
|
+
OVERLAY_EXTENDS = {}
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def auto_spec(module, entities, contour, index, registerDefs=None):
|
|
1284
|
+
"""A generated module's SPEC, around the entities autospec built."""
|
|
1285
|
+
extends = collections.OrderedDict(autospec.EXTENSIONS.get(module, {}))
|
|
1286
|
+
overlaid = OVERLAY_EXTENDS.get(module, {})
|
|
1287
|
+
for target, ext in overlaid.items():
|
|
1288
|
+
extends[target] = list(extends.get(target, [])) + list(ext.get("fields", []))
|
|
1289
|
+
deps = dependencies_of(entities) - {module}
|
|
1290
|
+
deps |= set(target.split(".")[0] for target in extends) - {module}
|
|
1291
|
+
deps |= dependencies_of(dict((target, {"fields": ext.get("fields", []),
|
|
1292
|
+
"registry": ext.get("registry")})
|
|
1293
|
+
for target, ext in overlaid.items())) - {module}
|
|
1294
|
+
return {
|
|
1295
|
+
"extendRegistry": dict((t, e.get("registry")) for t, e in overlaid.items() if e.get("registry")),
|
|
1296
|
+
"extendUk": dict((t, e.get("uk")) for t, e in overlaid.items() if e.get("uk")),
|
|
1297
|
+
"extendDescription": dict((t, e.get("desc")) for t, e in overlaid.items() if e.get("desc")),
|
|
1298
|
+
**({"registerDefs": registerDefs} if registerDefs else {}),
|
|
1299
|
+
"extends": extends,
|
|
1300
|
+
"uk": auto_uk(module, entities, contour, auto_menu(entities, module)),
|
|
1301
|
+
"code": module,
|
|
1302
|
+
"moduleId": module_uuid(module, index),
|
|
1303
|
+
"displayName": contour["label"],
|
|
1304
|
+
"description": contour.get("description", contour["label"]),
|
|
1305
|
+
"label": contour["label"],
|
|
1306
|
+
"color": contour.get("color", "#78909C"),
|
|
1307
|
+
"icon": contour.get("icon", "box"),
|
|
1308
|
+
"tags": contour.get("tags", [module]),
|
|
1309
|
+
"auditHistory": "basic",
|
|
1310
|
+
"dependencies": dict(
|
|
1311
|
+
[(name, ">=0.1.0") for name in sorted(deps)]
|
|
1312
|
+
+ sorted(DECLARED_MODULE.get(module, {}).get("requires", {}).items())),
|
|
1313
|
+
**({"version": DECLARED_MODULE[module]["version"]}
|
|
1314
|
+
if DECLARED_MODULE.get(module, {}).get("version") else {}),
|
|
1315
|
+
"entities": entities,
|
|
1316
|
+
"menu": auto_menu(entities, module),
|
|
1317
|
+
"roles": auto_roles(module, entities),
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
# --------------------------------------------------------------------------- specs
|
|
1322
|
+
|
|
1323
|
+
import autospec
|
|
1324
|
+
import binding
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
def resolve_enums(spec, model):
|
|
1328
|
+
"""Turn every `drop1c:<enum>` in an authored spec into the enum's real options."""
|
|
1329
|
+
uk_entities = spec.get("uk", {}).get("entities", {})
|
|
1330
|
+
for code, entity in spec["entities"].items():
|
|
1331
|
+
fields = []
|
|
1332
|
+
for item in entity.get("fields", []):
|
|
1333
|
+
if not named(item[1], "drop1c:"):
|
|
1334
|
+
fields.append(item)
|
|
1335
|
+
continue
|
|
1336
|
+
found = autospec.enum_spec(model, item[1][len("drop1c:"):])
|
|
1337
|
+
if not found:
|
|
1338
|
+
raise ValueError("no such enum: %s" % item[1])
|
|
1339
|
+
fields.append((item[0], found["spec"]) + tuple(item[2:]))
|
|
1340
|
+
uk = uk_entities.get(code)
|
|
1341
|
+
if uk is not None:
|
|
1342
|
+
uk.setdefault("o", {})[item[0]] = found["uk"]
|
|
1343
|
+
entity["fields"] = fields
|
|
1344
|
+
return spec
|
|
1345
|
+
|
|
1346
|
+
|
|
1347
|
+
def with_extensions(spec, module):
|
|
1348
|
+
"""The generated extensions of an authored module, beside the ones its spec declares - a
|
|
1349
|
+
bridge the spec posts on another module's document. Assigning the generated ones dropped
|
|
1350
|
+
the spec's own without a word."""
|
|
1351
|
+
extends = collections.OrderedDict(autospec.EXTENSIONS[module])
|
|
1352
|
+
for target, fields in (spec.get("extends") or {}).items():
|
|
1353
|
+
extends[target] = list(extends.get(target, [])) + list(fields)
|
|
1354
|
+
return extends
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def main():
|
|
1358
|
+
generated, model = autospec.build()
|
|
1359
|
+
|
|
1360
|
+
hand = dict((code, dict(spec)) for code, spec in binding.specs().items())
|
|
1361
|
+
contours = binding.contours()
|
|
1362
|
+
overlays = binding.registers()
|
|
1363
|
+
|
|
1364
|
+
unknown = set(overlays) - set(generated)
|
|
1365
|
+
if unknown:
|
|
1366
|
+
raise KeyError("registers.py declares registers over %s, which %s"
|
|
1367
|
+
% (", ".join(sorted(unknown)),
|
|
1368
|
+
"is not a generated module - author it in a spec instead"))
|
|
1369
|
+
defs = dict((module, apply_registers(module, entities, overlays.get(module)))
|
|
1370
|
+
for module, entities in generated.items())
|
|
1371
|
+
|
|
1372
|
+
# One authored spec owns the base catalogs, and the generated entities for that
|
|
1373
|
+
# module - the integrator's classifiers and the stubs for catalogs the port only
|
|
1374
|
+
# points at - are added to it rather than living in a module of their own. Which
|
|
1375
|
+
# spec that is belongs to the project, so the project says so.
|
|
1376
|
+
absorbing = next((code for code, spec in sorted(hand.items())
|
|
1377
|
+
if spec.get("absorbsGenerated")), None)
|
|
1378
|
+
if absorbing is None:
|
|
1379
|
+
for module, spec in sorted(hand.items()):
|
|
1380
|
+
if module in autospec.EXTENSIONS:
|
|
1381
|
+
spec["extends"] = with_extensions(spec, module)
|
|
1382
|
+
emit_module(resolve_enums(spec, model))
|
|
1383
|
+
for index, (module, entities) in enumerate(generated.items()):
|
|
1384
|
+
emit_module(auto_spec(module, entities, contours.CONTOURS[module], index,
|
|
1385
|
+
defs.get(module)))
|
|
1386
|
+
return
|
|
1387
|
+
|
|
1388
|
+
masterdata = hand[absorbing]
|
|
1389
|
+
added = generated.pop(absorbing, {})
|
|
1390
|
+
|
|
1391
|
+
# A register declared over the absorbing module's GENERATED entities. Its overlay was
|
|
1392
|
+
# applied above like every other module's, but the absorbing module is emitted from its
|
|
1393
|
+
# authored spec rather than from `auto_spec`, which is where `registerDefs` is otherwise
|
|
1394
|
+
# threaded through - so without this the declaration is computed and silently dropped,
|
|
1395
|
+
# constraints and Ukrainian landing while the register itself never appears. The guard
|
|
1396
|
+
# at the top of this function cannot catch it either: the absorbing module IS generated
|
|
1397
|
+
# when that runs, and stops being so one line above.
|
|
1398
|
+
if defs.get(absorbing):
|
|
1399
|
+
masterdata["registerDefs"] = dict(masterdata.get("registerDefs") or {},
|
|
1400
|
+
**defs[absorbing])
|
|
1401
|
+
merged = collections.OrderedDict(masterdata["entities"])
|
|
1402
|
+
merged.update(added)
|
|
1403
|
+
masterdata["entities"] = merged
|
|
1404
|
+
|
|
1405
|
+
# The authored Ukrainian wins over anything derived from the dictionary; the derived
|
|
1406
|
+
# entries fill in the entities the hand-written spec never covered.
|
|
1407
|
+
uk = dict(masterdata["uk"])
|
|
1408
|
+
uk_entities = collections.OrderedDict(
|
|
1409
|
+
(code, entity["uk"]) for code, entity in added.items() if entity.get("uk"))
|
|
1410
|
+
uk_entities.update(uk.get("entities", {}))
|
|
1411
|
+
uk["entities"] = uk_entities
|
|
1412
|
+
uk["views"] = dict(uk.get("views", {}))
|
|
1413
|
+
for code, entity in added.items():
|
|
1414
|
+
if entity.get("uk"):
|
|
1415
|
+
uk["views"].setdefault(plural(code), entity["uk"]["_"])
|
|
1416
|
+
masterdata["uk"] = uk
|
|
1417
|
+
|
|
1418
|
+
hand[absorbing] = masterdata
|
|
1419
|
+
for module, spec in hand.items():
|
|
1420
|
+
if module in autospec.EXTENSIONS:
|
|
1421
|
+
spec["extends"] = with_extensions(spec, module)
|
|
1422
|
+
|
|
1423
|
+
for module in sorted(hand):
|
|
1424
|
+
emit_module(resolve_enums(hand[module], model))
|
|
1425
|
+
|
|
1426
|
+
for index, (module, entities) in enumerate(generated.items()):
|
|
1427
|
+
emit_module(auto_spec(module, entities, contours.CONTOURS[module], index,
|
|
1428
|
+
defs.get(module)))
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
if __name__ == "__main__":
|
|
1432
|
+
main()
|