@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,964 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Extracted 1C model -> genmodule specs, for the whole infobase.
|
|
5
|
+
|
|
6
|
+
`spec_fleet.py` and `spec_shipping.py` were written by hand, which was the right way to
|
|
7
|
+
learn what a good port looks like and the wrong way to do 124 more objects. This builds
|
|
8
|
+
the same spec structure from the extracted model instead: names through `naming.py`,
|
|
9
|
+
field types from the type patterns, references resolved against a registry of everything
|
|
10
|
+
being ported, tabular sections as their own line entities, and the standard 1C columns
|
|
11
|
+
(`_Code`, `_Description`, `_Number`, `_Posted`, `_Period`, `_Recorder`) taken from the
|
|
12
|
+
physical table rather than assumed.
|
|
13
|
+
|
|
14
|
+
What it deliberately does not do:
|
|
15
|
+
|
|
16
|
+
* it does not wire accumulation registers as `columnType: "A"` columns. A register
|
|
17
|
+
column needs the posting logic that fills it, which is ported per contour; until then
|
|
18
|
+
the register is an ordinary table, which is also exactly what a direct data copy
|
|
19
|
+
needs. See docs/design-migration.md.
|
|
20
|
+
* it does not invent Ukrainian. Base labels are English, derived from the 1C synonym
|
|
21
|
+
through the same dictionary; `translations/uk-UA.json` is authored per contour as it
|
|
22
|
+
is reviewed, as it already is for fleet, shipping and masterdata.
|
|
23
|
+
|
|
24
|
+
python tools/autospec.py # what would be generated, per module
|
|
25
|
+
python tools/autospec.py <module> # one module's entities, in detail
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import print_function
|
|
28
|
+
|
|
29
|
+
import codecs
|
|
30
|
+
import collections
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
import attrs
|
|
37
|
+
import binding
|
|
38
|
+
|
|
39
|
+
contours = binding.contours()
|
|
40
|
+
import meta
|
|
41
|
+
import naming
|
|
42
|
+
|
|
43
|
+
# 1C objects that are already entities in a hand-written spec: the project says which
|
|
44
|
+
# (`contours.HAND_ENTITIES`), since which objects were authored is the project's choice.
|
|
45
|
+
HAND_ENTITIES = binding.hand_entities()
|
|
46
|
+
|
|
47
|
+
# 1C catalogs the port does not own but fields point at. Each gets a minimal entity in
|
|
48
|
+
# masterdata - key, code, name, deletion mark - so the reference survives as a reference
|
|
49
|
+
# instead of decaying into an opaque GUID. Filled by resolve(), emitted by stubs().
|
|
50
|
+
STUBS = collections.Counter()
|
|
51
|
+
|
|
52
|
+
# Reference targets that are not catalogs (documents, business processes, the vendor's
|
|
53
|
+
# own registers) and composite references with several targets. These become a plain
|
|
54
|
+
# uuid column: the value is kept for the migration, but there is nothing to point it at.
|
|
55
|
+
RAW = collections.Counter()
|
|
56
|
+
# (physical type, what the metadata claimed) -> how many fields disagreed.
|
|
57
|
+
MISTYPED = collections.Counter()
|
|
58
|
+
|
|
59
|
+
TYPE_RE = re.compile(r"^(string|number|bool|date)")
|
|
60
|
+
|
|
61
|
+
# Physical columns every family carries, and what the port calls them.
|
|
62
|
+
RESERVED = {"code", "name", "deletion_mark", "is_folder", "parent", "owner",
|
|
63
|
+
"doc_no", "doc_date", "posted", "period", "recorder", "line_no",
|
|
64
|
+
"is_active", "record_kind"}
|
|
65
|
+
|
|
66
|
+
# Those same columns in Ukrainian. They come from 1C's own vocabulary rather than from a
|
|
67
|
+
# synonym, so they are named here instead of going through the dictionary.
|
|
68
|
+
STANDARD_UK = {
|
|
69
|
+
"code": u"Код",
|
|
70
|
+
"name": u"Найменування",
|
|
71
|
+
"deletion_mark": u"Позначка видалення",
|
|
72
|
+
"is_folder": u"Група",
|
|
73
|
+
"parent": u"Батьківська група",
|
|
74
|
+
"owner": u"Власник",
|
|
75
|
+
"doc_no": u"Номер",
|
|
76
|
+
"doc_date": u"Дата",
|
|
77
|
+
"posted": u"Проведено",
|
|
78
|
+
"deletion_mark_doc": u"Позначка видалення",
|
|
79
|
+
"period": u"Період",
|
|
80
|
+
"recorder": u"Документ-реєстратор",
|
|
81
|
+
"line_no": u"№ рядка",
|
|
82
|
+
"record_kind": u"Вид руху",
|
|
83
|
+
"is_active": u"Активність",
|
|
84
|
+
"account_kind": u"Вид рахунку",
|
|
85
|
+
"off_balance": u"Позабалансовий",
|
|
86
|
+
"totals_by_analytics": u"Підсумки за аналітикою",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
RECORD_KIND_UK = {"receipt": u"Прихід", "expense": u"Витрата"}
|
|
90
|
+
|
|
91
|
+
# The three kinds of account, in the words a Ukrainian accountant uses for them.
|
|
92
|
+
ACCOUNT_KIND_UK = {"active": u"Активний", "passive": u"Пасивний",
|
|
93
|
+
"active_passive": u"Активно-пасивний"}
|
|
94
|
+
|
|
95
|
+
# The standard columns, and the physical column each one is copied from. `_Recorder` is
|
|
96
|
+
# a composite reference: `_RecorderRRef` alone on a register that only one document kind
|
|
97
|
+
# writes, `_RecorderTRef` + `_RecorderRRef` where several do.
|
|
98
|
+
STANDARD_PHYSICAL = {
|
|
99
|
+
"code": ["_Code"],
|
|
100
|
+
"name": ["_Description"],
|
|
101
|
+
"deletion_mark": ["_Marked"],
|
|
102
|
+
"is_folder": ["_Folder"],
|
|
103
|
+
"parent": ["_ParentIDRRef"],
|
|
104
|
+
"owner": ["_OwnerIDRRef", "_OwnerID_RRRef"],
|
|
105
|
+
"doc_no": ["_Number"],
|
|
106
|
+
"doc_date": ["_Date_Time"],
|
|
107
|
+
"posted": ["_Posted"],
|
|
108
|
+
"period": ["_Period"],
|
|
109
|
+
"recorder": ["_RecorderRRef"],
|
|
110
|
+
"record_kind": ["_RecordKind"],
|
|
111
|
+
"is_active": ["_Active"],
|
|
112
|
+
"account_kind": ["_Kind"],
|
|
113
|
+
"off_balance": ["_OffBalance"],
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
# (module, entity) -> {dForge column: physical column}, filled while generating. The
|
|
117
|
+
# migration reads this rather than re-deriving it, so the copy cannot drift from the
|
|
118
|
+
# schema it copies into.
|
|
119
|
+
COLUMNS = collections.OrderedDict()
|
|
120
|
+
|
|
121
|
+
# owning module -> {"<module>.<entity>": [field spec, ...]}, for the columns diverted by
|
|
122
|
+
# `contours.EXTENSIONS` because they would otherwise invert a dependency.
|
|
123
|
+
EXTENSIONS = collections.OrderedDict()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _pinned():
|
|
127
|
+
"""(1C table, physical column) -> the dForge column name already given to it.
|
|
128
|
+
|
|
129
|
+
A name the generator emitted and the loader filled is a column in a tenant, holding rows.
|
|
130
|
+
Renaming it is a migration, and the generator does not know it is doing one: the
|
|
131
|
+
transliteration dictionary improved after the standard configuration's modules were
|
|
132
|
+
loaded (`налоговым` became `tax`, `валютный` `currency`), and a regeneration renamed
|
|
133
|
+
installed columns. So a column the project's column map already names keeps its name; a
|
|
134
|
+
better dictionary applies to columns that are new. Read once, from the map as committed,
|
|
135
|
+
before this run rewrites it.
|
|
136
|
+
"""
|
|
137
|
+
import paths
|
|
138
|
+
path = paths.derived("columnmap.json")
|
|
139
|
+
if not os.path.isfile(path):
|
|
140
|
+
return {}
|
|
141
|
+
with codecs.open(path, "r", "utf-8") as f:
|
|
142
|
+
mapping = json.load(f)
|
|
143
|
+
names = collections.defaultdict(set)
|
|
144
|
+
for entities in mapping.values():
|
|
145
|
+
for spec in entities.values():
|
|
146
|
+
table = spec.get("table")
|
|
147
|
+
for name, source in (spec.get("columns") or {}).items():
|
|
148
|
+
if table and isinstance(source, type(u"")) and source.startswith("_Fld"):
|
|
149
|
+
names[(table, source)].add(name)
|
|
150
|
+
# One 1C column read into two of ours (a magnitude and its direction) pins neither.
|
|
151
|
+
return dict((key, found.pop()) for key, found in names.items() if len(found) == 1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _pinned_enums():
|
|
155
|
+
"""1C enum table -> the option codes the project's column map loaded it under, in order."""
|
|
156
|
+
import paths
|
|
157
|
+
path = paths.derived("columnmap.json")
|
|
158
|
+
if not os.path.isfile(path):
|
|
159
|
+
return {}
|
|
160
|
+
with codecs.open(path, "r", "utf-8") as f:
|
|
161
|
+
mapping = json.load(f)
|
|
162
|
+
found = {}
|
|
163
|
+
for entities in mapping.values():
|
|
164
|
+
for spec in entities.values():
|
|
165
|
+
for enum in (spec.get("enums") or {}).values():
|
|
166
|
+
if isinstance(enum, dict) and enum.get("table") and enum.get("options"):
|
|
167
|
+
found.setdefault(enum["table"], list(enum["options"]))
|
|
168
|
+
return found
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
PINNED = _pinned()
|
|
172
|
+
PINNED_ENUMS = _pinned_enums()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def column_name(field, used, table, source, spec, max_len=48):
|
|
176
|
+
"""The column's name: the one the project already gave this 1C column, else a new one.
|
|
177
|
+
|
|
178
|
+
The map names a reference by its key column (`<name>_id`); the field is the name
|
|
179
|
+
without it. Any other column is named as it stands, `_id` or not."""
|
|
180
|
+
pinned = PINNED.get((table, source)) if source else None
|
|
181
|
+
if pinned and spec.startswith("ref:"):
|
|
182
|
+
pinned = pinned[:-3] if pinned.endswith("_id") else None
|
|
183
|
+
if pinned and pinned not in used:
|
|
184
|
+
used.add(pinned)
|
|
185
|
+
return pinned
|
|
186
|
+
return naming.unique(field["name"], used, max_len)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --------------------------------------------------------------------------- loading
|
|
190
|
+
|
|
191
|
+
class Model(object):
|
|
192
|
+
"""Everything the generator reads, loaded once."""
|
|
193
|
+
|
|
194
|
+
def __init__(self):
|
|
195
|
+
self.objects = attrs.load_json("objects.json")
|
|
196
|
+
self.types = attrs.load_json("typemap.json")
|
|
197
|
+
self.report = {}
|
|
198
|
+
for row in attrs.load_json("report.json"):
|
|
199
|
+
self.report[(row["family"], row["name"])] = row
|
|
200
|
+
self.report.setdefault(row["name"], row)
|
|
201
|
+
self.by_name = {}
|
|
202
|
+
for obj in self.objects:
|
|
203
|
+
if obj.get("name"):
|
|
204
|
+
self.by_name[(obj["family"], obj["name"])] = obj
|
|
205
|
+
self.by_name.setdefault(obj["name"], obj)
|
|
206
|
+
with codecs.open(os.path.join(attrs.OUT, "schema.json"), "r", "utf-8") as f:
|
|
207
|
+
schema = json.load(f)
|
|
208
|
+
tables = schema if isinstance(schema, list) else schema["tables"]
|
|
209
|
+
self.tables = {t["name"]: t for t in tables}
|
|
210
|
+
self.registry = {} # (family, 1C name) -> "module.entity"
|
|
211
|
+
self._enums = {}
|
|
212
|
+
# DBNames keys a Fld entry by the attribute's GUID, which is how a ported column
|
|
213
|
+
# finds the `_FldNNN` holding its data.
|
|
214
|
+
with codecs.open(os.path.join(attrs.OUT, "dbnames.json"), "r", "utf-8") as f:
|
|
215
|
+
names = json.load(f)
|
|
216
|
+
self.fld = dict((e["guid"], e["physical"]) for e in names["entries"]
|
|
217
|
+
if e["type"] == "Fld")
|
|
218
|
+
# A tabular section's own table and line-number column, keyed the same way.
|
|
219
|
+
self.vt = dict((e["guid"], e["physical"]) for e in names["entries"]
|
|
220
|
+
if e["type"] == "VT")
|
|
221
|
+
self.line_no = dict((e["guid"], e["physical"]) for e in names["entries"]
|
|
222
|
+
if e["type"] == "LineNo")
|
|
223
|
+
|
|
224
|
+
def table_columns(self, table):
|
|
225
|
+
found = self.tables.get(table or "")
|
|
226
|
+
return set(c["column"] for c in found["columns"]) if found else set()
|
|
227
|
+
|
|
228
|
+
def row(self, name, family=None):
|
|
229
|
+
"""One object's report entry. A name can belong to two families - the infobase
|
|
230
|
+
holds a certificates catalog and a certificates register under the very same
|
|
231
|
+
name - so the family is part of the address wherever it is known."""
|
|
232
|
+
return self.report.get((family, name)) or self.report.get(name) or {}
|
|
233
|
+
|
|
234
|
+
def fields(self, name, family=None):
|
|
235
|
+
"""The object's attributes, resolved. The family-aware `attrs.fields_of`."""
|
|
236
|
+
obj = self.by_name.get((family, name)) or self.by_name.get(name)
|
|
237
|
+
if not obj:
|
|
238
|
+
return []
|
|
239
|
+
text = meta.load(obj["guid"])
|
|
240
|
+
if text is None:
|
|
241
|
+
return []
|
|
242
|
+
found = []
|
|
243
|
+
attrs.collect(meta.parse(text), None, None, self.types, found)
|
|
244
|
+
return found
|
|
245
|
+
|
|
246
|
+
def physical(self, name, family=None):
|
|
247
|
+
"""The object's own table, and its standard column names."""
|
|
248
|
+
row = self.row(name, family)
|
|
249
|
+
table = self.tables.get(row.get("physical") or "")
|
|
250
|
+
columns = {c["column"] for c in table["columns"]} if table else set()
|
|
251
|
+
return row.get("physical"), columns
|
|
252
|
+
|
|
253
|
+
def sections(self, name, family=None):
|
|
254
|
+
"""Tabular-section name -> its physical table, for the sections of one object."""
|
|
255
|
+
physical, _ = self.physical(name, family)
|
|
256
|
+
if not physical:
|
|
257
|
+
return {}
|
|
258
|
+
prefix = physical + "_VT"
|
|
259
|
+
return {n: self.tables[n] for n in self.tables if n.startswith(prefix)}
|
|
260
|
+
|
|
261
|
+
def enum_options(self, name):
|
|
262
|
+
"""Enum object -> [(code, label, label_uk)], its values in declaration order."""
|
|
263
|
+
if name in self._enums:
|
|
264
|
+
return self._enums[name]
|
|
265
|
+
obj = self.by_name.get(name)
|
|
266
|
+
options = []
|
|
267
|
+
if obj:
|
|
268
|
+
text = meta.load(obj["guid"])
|
|
269
|
+
if text is not None:
|
|
270
|
+
taken = set()
|
|
271
|
+
for value in attrs.enum_values(meta.parse(text), name):
|
|
272
|
+
options.append((naming.unique(value, taken, 28,
|
|
273
|
+
avoid_reserved=False),
|
|
274
|
+
naming.label(value),
|
|
275
|
+
naming.label_uk(value) or naming.label(value)))
|
|
276
|
+
# The option codes the project already loaded rows under keep their spelling, by the
|
|
277
|
+
# same rule as column names (see `_pinned`): a stored value that changes spelling is
|
|
278
|
+
# a row that no longer matches its option.
|
|
279
|
+
table, _ = self.physical(name, "Enum")
|
|
280
|
+
kept = PINNED_ENUMS.get(table)
|
|
281
|
+
if kept and len(kept) == len(options):
|
|
282
|
+
options = [(code, label if code == fresh else
|
|
283
|
+
re.sub(r"_\d+$", "", code).replace("_", " ").capitalize(), uk)
|
|
284
|
+
for code, (fresh, label, uk) in zip(kept, options)]
|
|
285
|
+
self._enums[name] = options
|
|
286
|
+
return options
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# --------------------------------------------------------------------------- types
|
|
290
|
+
|
|
291
|
+
def parse_target(part):
|
|
292
|
+
"""'-> Reference.Номенклатура' -> ('Reference', 'Номенклатура')"""
|
|
293
|
+
body = part.strip()[3:]
|
|
294
|
+
family, _, name = body.partition(".")
|
|
295
|
+
return family, name
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# 1C's own type for a serialized value. The attribute reads as a reference and is
|
|
299
|
+
# nothing of the kind: the column is `image`, and what sits in it is 1C's
|
|
300
|
+
# serialization - a length, a BOM and a JSON-ish body naming a type GUID.
|
|
301
|
+
VALUE_STORAGE = "e199ca70"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def resolve_reference(parts, model):
|
|
305
|
+
"""The reference targets of a field -> a genmodule type, or None for a raw uuid."""
|
|
306
|
+
if len(parts) == 1 and VALUE_STORAGE in parts[0]:
|
|
307
|
+
return "blob"
|
|
308
|
+
targets = [parse_target(p) for p in parts]
|
|
309
|
+
if len(targets) != 1:
|
|
310
|
+
RAW[" + ".join("%s.%s" % t for t in targets)] += 1
|
|
311
|
+
return None
|
|
312
|
+
family, name = targets[0]
|
|
313
|
+
|
|
314
|
+
if family == "Enum":
|
|
315
|
+
options = model.enum_options(name)
|
|
316
|
+
if not options:
|
|
317
|
+
RAW["Enum.%s (no values)" % name] += 1
|
|
318
|
+
return None
|
|
319
|
+
return "drop:" + "|".join("%s=%s" % (o[0], o[1]) for o in options)
|
|
320
|
+
|
|
321
|
+
if name in HAND_ENTITIES:
|
|
322
|
+
return "ref:" + HAND_ENTITIES[name]
|
|
323
|
+
if (family, name) in model.registry:
|
|
324
|
+
return "ref:" + model.registry[(family, name)]
|
|
325
|
+
if family in ("Reference", "Chrc", "Acc"):
|
|
326
|
+
# A catalog, an analytics kind or a chart-of-accounts entry the port does not own.
|
|
327
|
+
# A stub entity keeps it a reference rather than an opaque GUID.
|
|
328
|
+
STUBS[(family, name)] += 1
|
|
329
|
+
return "ref:%s.%s" % (stub_module(), stub_code(name))
|
|
330
|
+
|
|
331
|
+
RAW["%s.%s" % (family, name)] += 1
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def stub_module():
|
|
336
|
+
"""The module a stub entity belongs to, which the project names.
|
|
337
|
+
|
|
338
|
+
Moving the stubs to a project-chosen module was half the change: they were still
|
|
339
|
+
REFERENCED as `masterdata.<code>` and recorded under that key in the column map, so a
|
|
340
|
+
second project emitted its stubs into `common` and every reference into them pointed at
|
|
341
|
+
the first project's `masterdata`, which has no such entity. Install said so, once per
|
|
342
|
+
stub. The name and the placement have to come from the same answer.
|
|
343
|
+
"""
|
|
344
|
+
return getattr(contours, "STUB_MODULE", None) or "masterdata"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def stub_code(name):
|
|
348
|
+
return contours.CODES.get(name) or naming.code(name, 40)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def physical_of(field, model, columns):
|
|
352
|
+
"""Where this attribute's data actually sits.
|
|
353
|
+
|
|
354
|
+
DBNames gives the base name; which column carries the value depends on the type. A
|
|
355
|
+
reference is `_FldNNNRRef`, a composite one splits into `_FldNNN_TYPE` / `_RTRef` /
|
|
356
|
+
`_RRRef` and the reference half is what a copy wants, and a plain value is the base
|
|
357
|
+
name itself.
|
|
358
|
+
"""
|
|
359
|
+
base = model.fld.get(field.get("guid"))
|
|
360
|
+
if not base:
|
|
361
|
+
return None
|
|
362
|
+
for candidate in (base, base + "RRef", base + "_RRRef"):
|
|
363
|
+
if candidate in columns:
|
|
364
|
+
return candidate
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# A reference lives in its own column: `_FldNNNRRef`, or `_FldNNN_RRRef` when the
|
|
369
|
+
# attribute may point at several kinds of thing. The base name holds a VALUE.
|
|
370
|
+
REF_STORAGE = ("RRef", "_RRRef")
|
|
371
|
+
|
|
372
|
+
# Attributes whose column belongs to another object. Reported, not silently dropped.
|
|
373
|
+
ORPHANED = {}
|
|
374
|
+
|
|
375
|
+
# Which side an account is settled on. Active settles on the debit side, passive on the
|
|
376
|
+
# credit side, and active-passive on either - which is why a settlement account's balance
|
|
377
|
+
# is read EXPANDED (розгорнуте сальдо) rather than netted: a receivable and a payable with
|
|
378
|
+
# the same counterparty belong on opposite sides of a balance sheet.
|
|
379
|
+
#
|
|
380
|
+
# 1C keeps it as 0/1/2 in a numeric column, which is not an enum reference and so not
|
|
381
|
+
# something `enum_decoder` can read. The project turns the number into these codes in the
|
|
382
|
+
# query it declares for the chart, because that is machinery which already exists and is
|
|
383
|
+
# tested, and `WHERE account_kind = 'passive'` is worth the declaration.
|
|
384
|
+
ACCOUNT_KIND = "drop:active=Active|passive=Passive|active_passive=Active-passive"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def held_column(table, column):
|
|
388
|
+
"""The physical column's own record, from the schema dump."""
|
|
389
|
+
for held in (table or {}).get("columns", []):
|
|
390
|
+
if held["column"] == column:
|
|
391
|
+
return held
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def physical_spec(held):
|
|
396
|
+
"""A genmodule type for what the column physically is.
|
|
397
|
+
|
|
398
|
+
`len` on an `n`-type is bytes, which is why a `Строка(11)` document number reads as
|
|
399
|
+
`nchar(22)`; on `numeric` it is the precision.
|
|
400
|
+
"""
|
|
401
|
+
kind, width, scale = held.get("type"), held.get("len") or 0, held.get("scale") or 0
|
|
402
|
+
if kind in ("nvarchar", "nchar"):
|
|
403
|
+
return "text" if width < 0 else "varchar:%d" % max(width // 2, 1)
|
|
404
|
+
if kind in ("varchar", "char", "sysname"):
|
|
405
|
+
return "text" if width < 0 else "varchar:%d" % max(width, 1)
|
|
406
|
+
if kind in ("text", "ntext"):
|
|
407
|
+
return "text"
|
|
408
|
+
if kind == "numeric":
|
|
409
|
+
return ("int:%d" % (width or 15)) if not scale else ("num:%d,%d" % (width or 15, scale))
|
|
410
|
+
if kind in ("int", "smallint", "bigint", "tinyint"):
|
|
411
|
+
return "int:10"
|
|
412
|
+
if kind == "datetime":
|
|
413
|
+
return "datetime"
|
|
414
|
+
if kind == "bit":
|
|
415
|
+
return "bool"
|
|
416
|
+
return "blob"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def reconcile(spec, source, table):
|
|
420
|
+
"""A reference the physical column cannot be holding.
|
|
421
|
+
|
|
422
|
+
`РегистрСведений.Штрихкоды.Штрихкод` reads out of the metadata as a reference to
|
|
423
|
+
`ПланВидовХарактеристик.ТипыШтрихкодов` and is a `Строка(200)`: 1C keeps it in
|
|
424
|
+
`_Fld15870`, nvarchar(400), and a reference would be in `_Fld15870RRef`. Both halves of
|
|
425
|
+
the generator then believed their own source - the spec said `ref:`, so the column was
|
|
426
|
+
declared `uuid` and named `shtrihkod_id`, while the copy was pointed at the string
|
|
427
|
+
column - and the load found out 55 minutes and 44 million rows in, when `to_uuid` was
|
|
428
|
+
handed a str.
|
|
429
|
+
|
|
430
|
+
Where the two disagree the COLUMN wins. Of the two it is the one that cannot be a
|
|
431
|
+
misreading: whatever the metadata meant, the value is where the value is. Everything
|
|
432
|
+
this fixes would otherwise be caught by `migrate check`, one failed load at a time.
|
|
433
|
+
|
|
434
|
+
The same misreading has a second shape. `СубконтоДт1` is typed
|
|
435
|
+
`Характеристика.ВидыСубконтоХозрасчетные`: its value is a counterparty, a product, a
|
|
436
|
+
cash-flow item - whatever the analytic KIND on that row says - and the metadata reads it
|
|
437
|
+
as a reference to the chart of kinds itself. 1C stores it as a composite, `_FldNNN_TYPE`
|
|
438
|
+
/ `_RTRef` / `_RRRef`, which a value of one reference type never is. Linked to the kinds,
|
|
439
|
+
not one of 884,590 loaded values was a kind: every row that resolved, resolved to a
|
|
440
|
+
placeholder `fill` had stood in for it, and where nothing stood in the install could not
|
|
441
|
+
build the foreign key at all. A single target over composite storage is a raw GUID.
|
|
442
|
+
"""
|
|
443
|
+
if not source or not (spec.startswith("ref:") or spec == "rawref"):
|
|
444
|
+
return spec
|
|
445
|
+
if spec.startswith("ref:") and source.endswith("_RRRef"):
|
|
446
|
+
MISTYPED[("composite", spec)] += 1
|
|
447
|
+
return "rawref"
|
|
448
|
+
if source.endswith(REF_STORAGE):
|
|
449
|
+
return spec
|
|
450
|
+
held = held_column(table, source)
|
|
451
|
+
if not held:
|
|
452
|
+
return spec
|
|
453
|
+
if held.get("type") == "binary" and (held.get("len") or 0) == 16:
|
|
454
|
+
return spec
|
|
455
|
+
MISTYPED[(held.get("type"), spec)] += 1
|
|
456
|
+
return physical_spec(held)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def options_uk(field, spec, model):
|
|
460
|
+
"""The Ukrainian labels of a dropdown's options, when it came from a 1C enum."""
|
|
461
|
+
if not spec.startswith("drop:"):
|
|
462
|
+
return None
|
|
463
|
+
for part in (field["type"] or "").split(","):
|
|
464
|
+
part = part.strip()
|
|
465
|
+
if part.startswith("-> Enum."):
|
|
466
|
+
return dict((o[0], o[2]) for o in model.enum_options(part[len("-> Enum."):]))
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def enum_source(field, model):
|
|
471
|
+
"""An enum attribute's own table, and its values in the order our options are in.
|
|
472
|
+
|
|
473
|
+
1C stores an enum value as a reference into `_EnumNNN`, where `_EnumOrder` is the
|
|
474
|
+
value's position in the metadata - the same order `enum_options` reads them in. That
|
|
475
|
+
pairing is what lets the migration turn a GUID back into an option code.
|
|
476
|
+
"""
|
|
477
|
+
for part in (field.get("type") or "").split(","):
|
|
478
|
+
part = part.strip()
|
|
479
|
+
if not part.startswith("-> Enum."):
|
|
480
|
+
continue
|
|
481
|
+
name = part[len("-> Enum."):]
|
|
482
|
+
table, _ = model.physical(name, "Enum")
|
|
483
|
+
options = [o[0] for o in model.enum_options(name)]
|
|
484
|
+
if table and options:
|
|
485
|
+
return {"table": table, "options": options}
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def enum_spec(model, name):
|
|
490
|
+
"""`drop1c:<enum>` -> its dropdown spec, its Ukrainian labels and its source table.
|
|
491
|
+
|
|
492
|
+
An authored spec names the 1C enum rather than restating its values, so the options,
|
|
493
|
+
their order and the table the migration decodes them from all come from the same
|
|
494
|
+
place. Restating them by hand is how a code ends up with no value behind it.
|
|
495
|
+
"""
|
|
496
|
+
options = model.enum_options(name)
|
|
497
|
+
if not options:
|
|
498
|
+
return None
|
|
499
|
+
table, _ = model.physical(name, "Enum")
|
|
500
|
+
return {
|
|
501
|
+
"spec": "drop:" + "|".join("%s=%s" % (o[0], o[1]) for o in options),
|
|
502
|
+
"uk": dict((o[0], o[2]) for o in options),
|
|
503
|
+
"source": {"table": table, "options": [o[0] for o in options]} if table else None,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def field_type(raw, model):
|
|
508
|
+
"""The extracted type string -> a genmodule type spec."""
|
|
509
|
+
parts = [p.strip() for p in raw.split(",")] if raw else []
|
|
510
|
+
# `number(15,2)` splits on its own comma; rejoin those
|
|
511
|
+
merged, buffer = [], ""
|
|
512
|
+
for part in parts:
|
|
513
|
+
buffer = (buffer + "," + part) if buffer else part
|
|
514
|
+
if buffer.count("(") == buffer.count(")"):
|
|
515
|
+
merged.append(buffer)
|
|
516
|
+
buffer = ""
|
|
517
|
+
if buffer:
|
|
518
|
+
merged.append(buffer)
|
|
519
|
+
|
|
520
|
+
refs = [p for p in merged if p.startswith("->")]
|
|
521
|
+
scalars = [p for p in merged if TYPE_RE.match(p)]
|
|
522
|
+
|
|
523
|
+
if refs and not scalars:
|
|
524
|
+
return resolve_reference(refs, model) or "rawref"
|
|
525
|
+
if refs and scalars:
|
|
526
|
+
# 1C's "any of these" - a composite value. Nothing to point at.
|
|
527
|
+
RAW["composite with scalar"] += 1
|
|
528
|
+
return "rawref"
|
|
529
|
+
|
|
530
|
+
for part in scalars:
|
|
531
|
+
match = re.match(r"^string\((\d+|\?)\)$", part)
|
|
532
|
+
if match:
|
|
533
|
+
return "text" if match.group(1) == "?" else "varchar:" + match.group(1)
|
|
534
|
+
match = re.match(r"^number\((\d+|\?),(\d+|\?)\)$", part)
|
|
535
|
+
if match:
|
|
536
|
+
precision = match.group(1) if match.group(1) != "?" else "15"
|
|
537
|
+
scale = match.group(2) if match.group(2) != "?" else "0"
|
|
538
|
+
return ("int:" + precision) if scale == "0" else ("num:%s,%s" % (precision, scale))
|
|
539
|
+
if part == "bool":
|
|
540
|
+
return "bool"
|
|
541
|
+
if part.startswith("date("):
|
|
542
|
+
return "date" if part == "date(D)" else "datetime"
|
|
543
|
+
return "text"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# --------------------------------------------------------------------------- entities
|
|
547
|
+
|
|
548
|
+
def standard_fields(family, columns, table, model):
|
|
549
|
+
"""The columns 1C gives every object of this family, as spec entries."""
|
|
550
|
+
out = []
|
|
551
|
+
width = {c["column"]: c.get("len") for c in (table or {}).get("columns", [])}
|
|
552
|
+
|
|
553
|
+
# A chart of accounts is a catalog to 1C as well - code, description, hierarchy - and
|
|
554
|
+
# without these an account reads as its GUID in every posting that names it.
|
|
555
|
+
if family in ("Reference", "Chrc", "Acc"):
|
|
556
|
+
if "_Code" in columns:
|
|
557
|
+
out.append(("code", "varchar:%d" % (width.get("_Code") or 25), "Code"))
|
|
558
|
+
if "_Description" in columns:
|
|
559
|
+
out.append(("name", "varchar:%d" % (width.get("_Description") or 150), "Name"))
|
|
560
|
+
if "_Marked" in columns:
|
|
561
|
+
out.append(("deletion_mark", "bool", "Marked for deletion"))
|
|
562
|
+
if "_Folder" in columns:
|
|
563
|
+
out.append(("is_folder", "bool", "Group"))
|
|
564
|
+
if "_ParentIDRRef" in columns:
|
|
565
|
+
out.append(("parent", "rawref", "Parent group"))
|
|
566
|
+
if "_OwnerIDRRef" in columns or "_OwnerID_RRRef" in columns:
|
|
567
|
+
out.append(("owner", "rawref", "Owner"))
|
|
568
|
+
# A chart of accounts carries two facts no catalog does, and both are
|
|
569
|
+
# structural rather than attributes: which side an account is settled on,
|
|
570
|
+
# and whether it is off-balance. Without them the chart is one nobody can
|
|
571
|
+
# compute a balance from - a trial balance has to know whether a debit
|
|
572
|
+
# total is a balance or a contra.
|
|
573
|
+
if family == "Acc":
|
|
574
|
+
if "_Kind" in columns:
|
|
575
|
+
out.append(("account_kind", ACCOUNT_KIND, "Kind"))
|
|
576
|
+
if "_OffBalance" in columns:
|
|
577
|
+
out.append(("off_balance", "bool", "Off balance"))
|
|
578
|
+
# Not 1C's: the platform's switch for caching an account's totals by its
|
|
579
|
+
# analytics (registers.md 8.5 `analyticTotals.accountFlag`), the job 1C's own
|
|
580
|
+
# subconto totals table does. Copied from nothing, so a reload leaves it at its
|
|
581
|
+
# default; the accounting module sets it on install.
|
|
582
|
+
out.append(("totals_by_analytics", "bool", "Totals by analytics"))
|
|
583
|
+
|
|
584
|
+
elif family in ("Document", "DocumentJournal"):
|
|
585
|
+
if "_Number" in columns:
|
|
586
|
+
out.append(("doc_no", "varchar:%d" % (width.get("_Number") or 25), "Number"))
|
|
587
|
+
if "_Date_Time" in columns:
|
|
588
|
+
out.append(("doc_date", "datetime", "Date", "req"))
|
|
589
|
+
if "_Posted" in columns:
|
|
590
|
+
out.append(("posted", "bool", "Posted", "hidden"))
|
|
591
|
+
if "_Marked" in columns:
|
|
592
|
+
out.append(("deletion_mark", "bool", "Marked for deletion"))
|
|
593
|
+
|
|
594
|
+
elif family in ("InfoRg", "AccumRg"):
|
|
595
|
+
if "_Period" in columns:
|
|
596
|
+
out.append(("period", "datetime", "Period"))
|
|
597
|
+
if "_RecorderRRef" in columns:
|
|
598
|
+
out.append(("recorder", "rawref", "Recorder document"))
|
|
599
|
+
out.append(("line_no", "int:9", "Line no"))
|
|
600
|
+
if "_RecordKind" in columns:
|
|
601
|
+
out.append(("record_kind", "drop:receipt=Receipt|expense=Expense",
|
|
602
|
+
"Movement kind"))
|
|
603
|
+
if "_Active" in columns:
|
|
604
|
+
# 1C clears _Active only on a movement whose document is unposted, and none of
|
|
605
|
+
# those is loaded (0 of 13.8M in ПартииТоваровНаСкладах). A journal row the
|
|
606
|
+
# platform writes names no such column, so it takes the default - which has to be
|
|
607
|
+
# true, or every as-of read that drops inactive rows drops the engine's postings.
|
|
608
|
+
out.append(("is_active", {"dbDatatype": "bool", "fieldTypeCd": "checkbox",
|
|
609
|
+
"formula": "true"}, "Active"))
|
|
610
|
+
|
|
611
|
+
return out
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def entity_of(name, family, module, model, taken_codes):
|
|
615
|
+
"""One 1C object -> one spec entity, plus any line entities for its sections."""
|
|
616
|
+
row = model.row(name, family)
|
|
617
|
+
family = family or row.get("family") or "Reference"
|
|
618
|
+
physical, columns = model.physical(name, family)
|
|
619
|
+
table = model.tables.get(physical or "")
|
|
620
|
+
code = model.registry[(family, name)].split(".")[-1]
|
|
621
|
+
|
|
622
|
+
extracted = model.fields(name, family)
|
|
623
|
+
|
|
624
|
+
header = [f for f in extracted if not f.get("section")]
|
|
625
|
+
sections = collections.OrderedDict()
|
|
626
|
+
for field in extracted:
|
|
627
|
+
if field.get("section"):
|
|
628
|
+
sections.setdefault(field["section"], []).append(field)
|
|
629
|
+
|
|
630
|
+
fields = list(standard_fields(family, columns, table, model))
|
|
631
|
+
used = {n for n, _, _ in ((f[0], f[1], f[2]) for f in fields)}
|
|
632
|
+
used |= {code + "_id"}
|
|
633
|
+
|
|
634
|
+
uk_fields = dict((f[0], STANDARD_UK[f[0]]) for f in fields if f[0] in STANDARD_UK)
|
|
635
|
+
uk_options = {}
|
|
636
|
+
if "record_kind" in uk_fields:
|
|
637
|
+
uk_options["record_kind"] = dict(RECORD_KIND_UK)
|
|
638
|
+
if "account_kind" in uk_fields:
|
|
639
|
+
uk_options["account_kind"] = dict(ACCOUNT_KIND_UK)
|
|
640
|
+
|
|
641
|
+
physical_map = {code + "_id": "_IDRRef"} if family not in ("InfoRg", "AccumRg") else {}
|
|
642
|
+
for name_, candidates in STANDARD_PHYSICAL.items():
|
|
643
|
+
if any(f[0] == name_ for f in fields):
|
|
644
|
+
for candidate in candidates:
|
|
645
|
+
if candidate in columns:
|
|
646
|
+
physical_map[name_] = candidate
|
|
647
|
+
break
|
|
648
|
+
if "line_no" in (f[0] for f in fields) and "_LineNo" in columns:
|
|
649
|
+
physical_map["line_no"] = "_LineNo"
|
|
650
|
+
enum_map = collections.OrderedDict()
|
|
651
|
+
|
|
652
|
+
for field in header:
|
|
653
|
+
if naming.is_dead(field["name"]):
|
|
654
|
+
continue
|
|
655
|
+
source = physical_of(field, model, columns)
|
|
656
|
+
spec = field_type(field["type"], model)
|
|
657
|
+
column = column_name(field, used, physical, source, spec)
|
|
658
|
+
spec = reconcile(spec, source, table)
|
|
659
|
+
label = naming.label(field.get("synonym") or field["name"])
|
|
660
|
+
|
|
661
|
+
owner = contours.EXTENSIONS.get((module, code, column))
|
|
662
|
+
if owner:
|
|
663
|
+
EXTENSIONS.setdefault(owner, collections.OrderedDict()).setdefault(
|
|
664
|
+
"%s.%s" % (module, code), []).append(
|
|
665
|
+
(column, spec, label,
|
|
666
|
+
naming.label_uk(field.get("synonym") or field["name"])))
|
|
667
|
+
# The column moves module, not infobase: it is still a column of the host's
|
|
668
|
+
# 1C table, and the copy has to find it there. It is recorded against the
|
|
669
|
+
# owning module under the host's dotted name, which is how dForge itself
|
|
670
|
+
# names an extension, and carries the host's key so the 1:1 row can be
|
|
671
|
+
# written.
|
|
672
|
+
if source:
|
|
673
|
+
held = COLUMNS.setdefault((owner, "%s.%s" % (module, code)), {
|
|
674
|
+
"object": name, "family": family, "table": physical,
|
|
675
|
+
"columns": {code + "_id": "_IDRRef"}, "enums": {},
|
|
676
|
+
"extends": "%s.%s" % (module, code),
|
|
677
|
+
})
|
|
678
|
+
held["columns"][
|
|
679
|
+
column + "_id" if spec.startswith("ref:") else column] = source
|
|
680
|
+
continue
|
|
681
|
+
|
|
682
|
+
# An attribute DBNames resolves to a column this table has not got belongs to
|
|
683
|
+
# another object. Measured on this configuration: exactly three, `Суммовой`,
|
|
684
|
+
# `Валютный` and `Количественный` at `_Fld781..783`, which are columns of the
|
|
685
|
+
# OTHER chart of accounts (`Налоговый`) and arrive attached to this one. Emitting
|
|
686
|
+
# them gives three columns nothing can ever fill, which reads as data the port
|
|
687
|
+
# lost rather than data that was never here.
|
|
688
|
+
#
|
|
689
|
+
# The test is deliberately narrow: a name that resolved and points elsewhere. An
|
|
690
|
+
# attribute DBNames cannot resolve AT ALL is still emitted, because that is the
|
|
691
|
+
# port failing to read the metadata, and silently dropping a real column would be
|
|
692
|
+
# the worse of the two mistakes.
|
|
693
|
+
if model.fld.get(field.get("guid")) and not source:
|
|
694
|
+
ORPHANED[(name, field["name"])] = model.fld[field["guid"]]
|
|
695
|
+
continue
|
|
696
|
+
|
|
697
|
+
fields.append((column, spec, label))
|
|
698
|
+
translated = naming.label_uk(field.get("synonym") or field["name"])
|
|
699
|
+
if translated:
|
|
700
|
+
uk_fields[column] = translated
|
|
701
|
+
options = options_uk(field, spec, model)
|
|
702
|
+
if options:
|
|
703
|
+
uk_options[column] = options
|
|
704
|
+
if source:
|
|
705
|
+
physical_map[column + "_id" if spec.startswith("ref:") else column] = source
|
|
706
|
+
if spec.startswith("drop:"):
|
|
707
|
+
found = enum_source(field, model)
|
|
708
|
+
if found:
|
|
709
|
+
enum_map[column] = found
|
|
710
|
+
|
|
711
|
+
uk_desc = naming.label_uk(row.get("synonym") or name)
|
|
712
|
+
entity = {
|
|
713
|
+
"desc": naming.label(row.get("synonym") or name),
|
|
714
|
+
"source": u"%s (%s)" % (name, physical or "-"),
|
|
715
|
+
"family": family,
|
|
716
|
+
"fields": fields,
|
|
717
|
+
"uk": {"_": uk_desc, "desc": uk_desc, "f": uk_fields, "o": uk_options},
|
|
718
|
+
}
|
|
719
|
+
if any(f[0] == "name" for f in fields):
|
|
720
|
+
entity["toString"] = "{name}"
|
|
721
|
+
elif any(f[0] == "doc_no" for f in fields):
|
|
722
|
+
entity["toString"] = "{doc_no}"
|
|
723
|
+
elif any(f[0] == "code" for f in fields):
|
|
724
|
+
entity["toString"] = "{code}"
|
|
725
|
+
if family in ("InfoRg", "AccumRg"):
|
|
726
|
+
entity["hideInFolder"] = True
|
|
727
|
+
|
|
728
|
+
entities = collections.OrderedDict([(code, entity)])
|
|
729
|
+
COLUMNS[(module, code)] = {
|
|
730
|
+
"object": name, "family": family, "table": physical, "columns": physical_map,
|
|
731
|
+
"enums": enum_map,
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
# Tabular sections become line entities keyed the way 1C keys a _VT table.
|
|
735
|
+
for section, section_fields in sections.items():
|
|
736
|
+
line_code = naming.unique(u"%s %s" % (name, section), taken_codes, 48)
|
|
737
|
+
line_used = {"line_no", code}
|
|
738
|
+
line_fields = [(code, "ref:" + code, entity["desc"], "req")]
|
|
739
|
+
line_uk = {code: uk_desc, "line_no": STANDARD_UK["line_no"]}
|
|
740
|
+
line_options = {}
|
|
741
|
+
|
|
742
|
+
section_guid = section_fields[0].get("sectionGuid")
|
|
743
|
+
# DBNames records a section's table as the suffix alone - `_VT12640` - because
|
|
744
|
+
# the table is that suffix appended to the owner's own table.
|
|
745
|
+
suffix = model.vt.get(section_guid)
|
|
746
|
+
line_table = (physical + suffix) if (physical and suffix) else None
|
|
747
|
+
line_columns = model.table_columns(line_table)
|
|
748
|
+
line_physical = {}
|
|
749
|
+
line_enums = collections.OrderedDict()
|
|
750
|
+
if line_table:
|
|
751
|
+
owner = (physical or "") + "_IDRRef"
|
|
752
|
+
if owner in line_columns:
|
|
753
|
+
line_physical[code + "_id"] = owner
|
|
754
|
+
line_number = model.line_no.get(section_guid)
|
|
755
|
+
if line_number in line_columns:
|
|
756
|
+
line_physical["line_no"] = line_number
|
|
757
|
+
|
|
758
|
+
for field in section_fields:
|
|
759
|
+
if naming.is_dead(field["name"]):
|
|
760
|
+
continue
|
|
761
|
+
source = physical_of(field, model, line_columns)
|
|
762
|
+
spec = field_type(field["type"], model)
|
|
763
|
+
column = column_name(field, line_used, line_table, source, spec)
|
|
764
|
+
spec = reconcile(spec, source, model.tables.get(line_table or ""))
|
|
765
|
+
line_fields.append((column, spec,
|
|
766
|
+
naming.label(field.get("synonym") or field["name"])))
|
|
767
|
+
translated = naming.label_uk(field.get("synonym") or field["name"])
|
|
768
|
+
if translated:
|
|
769
|
+
line_uk[column] = translated
|
|
770
|
+
options = options_uk(field, spec, model)
|
|
771
|
+
if options:
|
|
772
|
+
line_options[column] = options
|
|
773
|
+
if source:
|
|
774
|
+
line_physical[column + "_id" if spec.startswith("ref:") else column] = source
|
|
775
|
+
if spec.startswith("drop:"):
|
|
776
|
+
found = enum_source(field, model)
|
|
777
|
+
if found:
|
|
778
|
+
line_enums[column] = found
|
|
779
|
+
section_uk = naming.label_uk(section) or naming.label(section)
|
|
780
|
+
line_desc_uk = u"%s - %s" % (uk_desc, section_uk)
|
|
781
|
+
entities[line_code] = {
|
|
782
|
+
"desc": "%s - %s" % (entity["desc"], naming.label(section)),
|
|
783
|
+
"source": u"%s / %s" % (name, section),
|
|
784
|
+
"family": family,
|
|
785
|
+
"lineOf": code,
|
|
786
|
+
"hideInFolder": True,
|
|
787
|
+
"fields": line_fields,
|
|
788
|
+
"uk": {"_": line_desc_uk, "desc": line_desc_uk,
|
|
789
|
+
"f": line_uk, "o": line_options},
|
|
790
|
+
}
|
|
791
|
+
COLUMNS[(module, line_code)] = {
|
|
792
|
+
"object": u"%s / %s" % (name, section), "family": family,
|
|
793
|
+
"table": line_table, "columns": line_physical, "enums": line_enums,
|
|
794
|
+
}
|
|
795
|
+
set_column = naming.code(section, 30) or "lines"
|
|
796
|
+
entity.setdefault("sets", []).append(
|
|
797
|
+
(set_column, line_code, naming.label(section),
|
|
798
|
+
code + "_id", code + "_id"))
|
|
799
|
+
uk_fields[set_column] = section_uk
|
|
800
|
+
|
|
801
|
+
return entities
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def text_widths(model, table):
|
|
805
|
+
"""A stub copies `_Code` and `_Description` as they are, so it must be as wide.
|
|
806
|
+
|
|
807
|
+
Guessing costs a load: `_Code` is nchar(9) in one catalog and nchar(50) in another,
|
|
808
|
+
and a column narrower than the one it copies from is a truncation waiting to happen.
|
|
809
|
+
"""
|
|
810
|
+
found = model.tables.get(table or "")
|
|
811
|
+
if not found:
|
|
812
|
+
return {}
|
|
813
|
+
# `sys.columns.max_length` counts bytes, so an `nchar(50)` reports 100.
|
|
814
|
+
return dict((c["column"], c["len"] // 2 if c["type"].startswith("n") else c["len"])
|
|
815
|
+
for c in found["columns"]
|
|
816
|
+
if c.get("len") and c["type"] in ("char", "nchar", "varchar", "nvarchar"))
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def stubs(model):
|
|
820
|
+
"""Minimal masterdata entities for the vendor catalogs the port only points at."""
|
|
821
|
+
out = collections.OrderedDict()
|
|
822
|
+
for (family, name), hits in sorted(STUBS.items(), key=lambda kv: (-kv[1], kv[0][1])):
|
|
823
|
+
uk_desc = naming.label_uk(name) or naming.label(name)
|
|
824
|
+
code = stub_code(name)
|
|
825
|
+
table, columns = model.physical(name, family)
|
|
826
|
+
# A stub still has to be filled, or every reference into it dangles. Its three
|
|
827
|
+
# columns come from the same standard places any catalog's do.
|
|
828
|
+
COLUMNS[(stub_module(), code)] = {
|
|
829
|
+
"object": name, "family": family, "table": table,
|
|
830
|
+
"columns": dict((ours, theirs) for ours, theirs in (
|
|
831
|
+
(code + "_id", "_IDRRef"), ("code", "_Code"),
|
|
832
|
+
("name", "_Description"), ("deletion_mark", "_Marked"))
|
|
833
|
+
if theirs in columns),
|
|
834
|
+
}
|
|
835
|
+
# A handful of catalogs are keyed by code alone and carry no `_Description`.
|
|
836
|
+
# Declaring a mandatory `name` there would leave a NOT NULL column the
|
|
837
|
+
# migration has nothing to put in.
|
|
838
|
+
named = "_Description" in columns
|
|
839
|
+
wide = text_widths(model, table)
|
|
840
|
+
fields = [("code", "varchar:%d" % wide.get("_Code", 25), "Code")]
|
|
841
|
+
if named:
|
|
842
|
+
fields.append(("name", "varchar:%d" % wide.get("_Description", 200),
|
|
843
|
+
"Name", "req"))
|
|
844
|
+
fields.append(("deletion_mark", "bool", "Marked for deletion"))
|
|
845
|
+
labels = {"code": STANDARD_UK["code"],
|
|
846
|
+
"deletion_mark": STANDARD_UK["deletion_mark"]}
|
|
847
|
+
if named:
|
|
848
|
+
labels["name"] = STANDARD_UK["name"]
|
|
849
|
+
# The few attributes a project reads off a catalog it does not own - a report
|
|
850
|
+
# printing a packaging kind's short name - as contours.STUB_FIELDS names them.
|
|
851
|
+
extracted = dict((f["name"], f) for f in model.fields(name, family))
|
|
852
|
+
for ours, attribute, kind, en, uk in getattr(contours, "STUB_FIELDS", {}).get(name, ()):
|
|
853
|
+
# A standard attribute the metadata does not list - the owner - is named by its
|
|
854
|
+
# physical column, `_OwnerIDRRef` or the reference half of a composite owner.
|
|
855
|
+
source = (attribute if attribute.startswith("_") and attribute in columns
|
|
856
|
+
else physical_of(extracted[attribute], model, columns))
|
|
857
|
+
COLUMNS[(stub_module(), code)]["columns"][ours] = source
|
|
858
|
+
fields.append((ours, kind, en))
|
|
859
|
+
labels[ours] = uk
|
|
860
|
+
out[code] = {
|
|
861
|
+
"desc": naming.label(name),
|
|
862
|
+
"source": u"%s.%s - referenced %d time(s), not ported" % (family, name, hits),
|
|
863
|
+
"toString": "{name}" if named else "{code}",
|
|
864
|
+
"hideInFolder": True,
|
|
865
|
+
"fields": fields,
|
|
866
|
+
"uk": {"_": uk_desc, "desc": uk_desc, "o": {}, "f": labels},
|
|
867
|
+
}
|
|
868
|
+
return out
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
# --------------------------------------------------------------------------- build
|
|
872
|
+
|
|
873
|
+
def build():
|
|
874
|
+
"""Every contour -> {module code: {entity code: entity spec}}."""
|
|
875
|
+
model = Model()
|
|
876
|
+
|
|
877
|
+
# Pass one: name everything, so a reference can resolve to an entity that has not
|
|
878
|
+
# been generated yet.
|
|
879
|
+
per_module_codes = collections.defaultdict(set)
|
|
880
|
+
assignments = collections.OrderedDict()
|
|
881
|
+
for module, contour in contours.CONTOURS.items():
|
|
882
|
+
listed = []
|
|
883
|
+
for entry in contour["objects"]:
|
|
884
|
+
name, family = entry if isinstance(entry, tuple) else (entry, None)
|
|
885
|
+
family = family or model.row(name).get("family")
|
|
886
|
+
code = contours.CODES.get(name) or naming.unique(
|
|
887
|
+
name, per_module_codes[module], 48)
|
|
888
|
+
per_module_codes[module].add(code)
|
|
889
|
+
model.registry[(family, name)] = "%s.%s" % (module, code)
|
|
890
|
+
listed.append((name, family))
|
|
891
|
+
assignments[module] = listed
|
|
892
|
+
|
|
893
|
+
# Pass two: generate.
|
|
894
|
+
generated = collections.OrderedDict()
|
|
895
|
+
for module, listed in assignments.items():
|
|
896
|
+
entities = collections.OrderedDict()
|
|
897
|
+
for name, family in listed:
|
|
898
|
+
entities.update(
|
|
899
|
+
entity_of(name, family, module, model, per_module_codes[module]))
|
|
900
|
+
generated[module] = entities
|
|
901
|
+
|
|
902
|
+
# A stub stands in for a vendor catalog the port only POINTS at, so it has to live in
|
|
903
|
+
# a module everything may depend on. Which module that is belongs to the project: this
|
|
904
|
+
# one calls it `masterdata`, and the library knew that name, which meant a second
|
|
905
|
+
# project - one whose shared module is called anything else - died here on a KeyError
|
|
906
|
+
# with nothing to suggest what it was supposed to declare.
|
|
907
|
+
stubbed = stubs(model)
|
|
908
|
+
if stubbed:
|
|
909
|
+
home = stub_module()
|
|
910
|
+
if home not in generated:
|
|
911
|
+
raise SystemExit(
|
|
912
|
+
"autospec: %d reference(s) point at objects no contour lists, so they need "
|
|
913
|
+
"stub entities, and there is nowhere to put them. Name the module every "
|
|
914
|
+
"other one may depend on as STUB_MODULE in the project's contours.py%s"
|
|
915
|
+
% (len(stubbed), "" if home is None else " (%r is not a module here)" % home))
|
|
916
|
+
generated[home].update(stubbed)
|
|
917
|
+
return generated, model
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def main():
|
|
921
|
+
generated, _ = build()
|
|
922
|
+
wanted = sys.argv[1] if len(sys.argv) > 1 else None
|
|
923
|
+
|
|
924
|
+
total_entities = total_fields = 0
|
|
925
|
+
for module, entities in generated.items():
|
|
926
|
+
fields = sum(len(e["fields"]) for e in entities.values())
|
|
927
|
+
total_entities += len(entities)
|
|
928
|
+
total_fields += fields
|
|
929
|
+
if wanted and module != wanted:
|
|
930
|
+
continue
|
|
931
|
+
print("%-12s %3d entities %5d fields" % (module, len(entities), fields))
|
|
932
|
+
if wanted:
|
|
933
|
+
for code, entity in entities.items():
|
|
934
|
+
print("\n %s - %s" % (code, entity["desc"]))
|
|
935
|
+
print(" source: %s" % entity["source"])
|
|
936
|
+
for field in entity["fields"]:
|
|
937
|
+
print(" %-42s %-28s %s"
|
|
938
|
+
% (field[0], field[1][:28], field[2]))
|
|
939
|
+
|
|
940
|
+
if not wanted:
|
|
941
|
+
print("-" * 40)
|
|
942
|
+
print("%-12s %3d entities %5d fields" % ("total", total_entities, total_fields))
|
|
943
|
+
if STUBS:
|
|
944
|
+
print("\n%d vendor catalogs referenced but not ported -> masterdata stubs"
|
|
945
|
+
% len(STUBS))
|
|
946
|
+
for name, hits in sorted(STUBS.items(), key=lambda kv: -kv[1])[:15]:
|
|
947
|
+
print(" %-44s %d" % (name, hits))
|
|
948
|
+
if RAW:
|
|
949
|
+
print("\n%d reference shapes with nothing to point at -> plain uuid"
|
|
950
|
+
% sum(RAW.values()))
|
|
951
|
+
for shape, hits in sorted(RAW.items(), key=lambda kv: -kv[1])[:15]:
|
|
952
|
+
print(" %-60s %d" % (shape[:60], hits))
|
|
953
|
+
if MISTYPED:
|
|
954
|
+
print("\n%d field(s) the metadata called a reference and the column is not"
|
|
955
|
+
% sum(MISTYPED.values()))
|
|
956
|
+
for (kind, claimed), hits in sorted(MISTYPED.items(), key=lambda kv: -kv[1]):
|
|
957
|
+
print(" %-30s metadata said %-24s %d" % (kind, claimed[:24], hits))
|
|
958
|
+
if naming.UNKNOWN:
|
|
959
|
+
print("\nwords still missing from terms.py: %s"
|
|
960
|
+
% ", ".join(sorted(naming.UNKNOWN)[:20]))
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
if __name__ == "__main__":
|
|
964
|
+
main()
|