@dforge-core/dforge-mcp 0.2.45 → 0.2.47
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 +45 -0
- package/README.md +2 -1
- package/dist/server.js +71 -3
- package/package.json +3 -3
- package/resources/docs/dsl-reference.md +1 -1
- package/resources/schemas/data-views.schema.json +50 -0
- package/scripts/install-skills.mjs +1 -0
- package/skills/dforge-1c-import/SKILL.md +375 -0
- package/skills/dforge-1c-import/references/environment.md +98 -0
- package/skills/dforge-1c-import/references/loading.md +164 -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 +164 -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/changes.py +326 -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 +770 -0
- package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/docusage.py +106 -0
- package/skills/dforge-1c-import/scripts/lib/dump1c.py +408 -0
- package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
- package/skills/dforge-1c-import/scripts/lib/external.py +202 -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 +325 -0
- package/skills/dforge-1c-import/scripts/lib/migrate.py +1318 -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/onecd.py +269 -0
- package/skills/dforge-1c-import/scripts/lib/paths.py +111 -0
- package/skills/dforge-1c-import/scripts/lib/peek.py +68 -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 +91 -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 +493 -0
- package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
- package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
- package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -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/store.py +126 -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/.env.example +24 -0
- package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
- package/skills/dforge-1c-import/scripts/project-template/sources.py +24 -0
- package/skills/dforge-mcp-author/SKILL.md +5 -0
- package/skills/dforge-mcp-author/references/action-dsl.md +1 -1
- package/skills/dforge-mcp-author/references/data-views.md +38 -2
- package/skills/dforge-mcp-author/references/formulas.md +32 -1
- package/skills/dforge-mcp-author/references/print-templates.md +30 -5
- package/skills/dforge-mcp-author/references/queries.md +17 -2
- package/skills/dforge-mcp-author/references/registers.md +1 -0
- package/skills/dforge-mcp-author/references/reports.md +1 -1
- package/skills/dforge-mcp-author/references/validation-checklist.md +1 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Read a FILE infobase - the `1Cv8.1CD` 1C keeps when it runs without a server - with no 1C
|
|
5
|
+
software. It is the same tables a server infobase keeps in MS SQL (`Config`, `Params`,
|
|
6
|
+
`_Reference12`, `_Fld345`), in 1C's own page format, and this reads them row by row.
|
|
7
|
+
|
|
8
|
+
Formats 8.2.14 (4 KB pages) and 8.3.8 (4-64 KB pages) - every file infobase 8.2 and 8.3
|
|
9
|
+
write. The layout is not published by 1C; it is the one Tool_1CD and onec_dtools read,
|
|
10
|
+
checked against the Platform8Demo fixtures in all three formats.
|
|
11
|
+
|
|
12
|
+
file header page 0: "1CDBMSV8", the format version, the page count, the page size
|
|
13
|
+
an object a header page listing its data pages (directly, or through index pages);
|
|
14
|
+
a table is three of them - records, blobs, indexes
|
|
15
|
+
the root object 2: the locale and one text description per table
|
|
16
|
+
a record a fixed-width row; the first byte set marks a free slot
|
|
17
|
+
a blob 256-byte blocks inside the blob object, chained by the next block's number
|
|
18
|
+
|
|
19
|
+
Read it while 1C is not writing to it - a copy of the file, or with the infobase closed. A
|
|
20
|
+
file has no `MIN_ACTIVE_ROWVERSION`: what is in it is what was committed.
|
|
21
|
+
"""
|
|
22
|
+
import collections
|
|
23
|
+
import datetime
|
|
24
|
+
import decimal
|
|
25
|
+
import re
|
|
26
|
+
import struct
|
|
27
|
+
|
|
28
|
+
ROOT = 2
|
|
29
|
+
BLOCK = 256
|
|
30
|
+
FORMATS = ("8.2.14.0", "8.3.8.0")
|
|
31
|
+
|
|
32
|
+
Field = collections.namedtuple("Field", "name type nullable length precision offset size")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Database(object):
|
|
36
|
+
def __init__(self, path):
|
|
37
|
+
self.path = path
|
|
38
|
+
self.file = open(path, "rb")
|
|
39
|
+
head = self.file.read(24)
|
|
40
|
+
if head[:8] != b"1CDBMSV8":
|
|
41
|
+
raise ValueError("%s is not a 1C file infobase (no 1CDBMSV8 signature)" % path)
|
|
42
|
+
self.version = ".".join(str(b) for b in bytearray(head[8:12]))
|
|
43
|
+
if self.version not in FORMATS:
|
|
44
|
+
raise NotImplementedError("1CD format %s: only %s are read" % (self.version, ", ".join(FORMATS)))
|
|
45
|
+
self.page_size = 4096
|
|
46
|
+
if self.version == "8.3.8.0":
|
|
47
|
+
(self.page_size,) = struct.unpack_from("<I", head, 20)
|
|
48
|
+
root = Object(self, ROOT)
|
|
49
|
+
if self.version == "8.3.8.0":
|
|
50
|
+
# 8.3.8 keeps the root as a blob in its own object: block 1 is the header.
|
|
51
|
+
data = blob(root, 1)
|
|
52
|
+
describe = lambda offset: blob(root, offset).decode("utf-8")
|
|
53
|
+
else:
|
|
54
|
+
data = root.read(0, len(root))
|
|
55
|
+
describe = lambda offset: Object(self, offset).read_all().decode("utf-16-le")
|
|
56
|
+
self.locale = data[:32].rstrip(b"\x00").decode("utf-8")
|
|
57
|
+
(count,) = struct.unpack_from("<i", data, 32)
|
|
58
|
+
self.tables = collections.OrderedDict()
|
|
59
|
+
for offset in struct.unpack_from("<%di" % count, data, 36):
|
|
60
|
+
table = Table(self, describe(offset))
|
|
61
|
+
self.tables[table.name] = table
|
|
62
|
+
|
|
63
|
+
def page(self, number, size=None):
|
|
64
|
+
self.file.seek(number * self.page_size)
|
|
65
|
+
return self.file.read(size or self.page_size)
|
|
66
|
+
|
|
67
|
+
def close(self):
|
|
68
|
+
self.file.close()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Object(object):
|
|
72
|
+
"""One object of the file: its length and the pages that hold it, in order."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, db, number):
|
|
75
|
+
self.db = db
|
|
76
|
+
head = db.page(number)
|
|
77
|
+
size = db.page_size
|
|
78
|
+
if db.version == "8.3.8.0":
|
|
79
|
+
if head[:2] != b"\x1c\xfd":
|
|
80
|
+
raise ValueError("object at page %d: signature %r" % (number, head[:2]))
|
|
81
|
+
(fat,) = struct.unpack_from("<H", head, 2)
|
|
82
|
+
(self.length,) = struct.unpack_from("<Q", head, 16)
|
|
83
|
+
slots = struct.unpack_from("<%dI" % ((size - 24) // 4), head, 24)
|
|
84
|
+
if fat == 0:
|
|
85
|
+
self.pages = list(slots[:-(-self.length // size)])
|
|
86
|
+
elif fat == 1:
|
|
87
|
+
self.pages = []
|
|
88
|
+
for index in slots:
|
|
89
|
+
if not index:
|
|
90
|
+
break
|
|
91
|
+
for page in struct.unpack("<%dI" % (size // 4), db.page(index)):
|
|
92
|
+
if not page:
|
|
93
|
+
break
|
|
94
|
+
self.pages.append(page)
|
|
95
|
+
else:
|
|
96
|
+
raise NotImplementedError("object at page %d: allocation depth %d" % (number, fat))
|
|
97
|
+
else:
|
|
98
|
+
if head[:8] != b"1CDBOBV8":
|
|
99
|
+
raise ValueError("object at page %d: signature %r" % (number, head[:8]))
|
|
100
|
+
(self.length,) = struct.unpack_from("<i", head, 8)
|
|
101
|
+
indexes = (self.length - 1) // (1023 * size) + 1 if self.length else 0
|
|
102
|
+
self.pages = []
|
|
103
|
+
for index in struct.unpack_from("<%dI" % indexes, head, 24):
|
|
104
|
+
data = db.page(index)
|
|
105
|
+
(n,) = struct.unpack_from("<i", data, 0)
|
|
106
|
+
self.pages.extend(struct.unpack_from("<%dI" % n, data, 4))
|
|
107
|
+
|
|
108
|
+
def __len__(self):
|
|
109
|
+
return self.length
|
|
110
|
+
|
|
111
|
+
def read(self, at, count):
|
|
112
|
+
size, out = self.db.page_size, []
|
|
113
|
+
count = min(count, self.length - at)
|
|
114
|
+
while count > 0:
|
|
115
|
+
page, within = divmod(at, size)
|
|
116
|
+
take = min(size - within, count)
|
|
117
|
+
self.db.file.seek(self.pages[page] * size + within)
|
|
118
|
+
out.append(self.db.file.read(take))
|
|
119
|
+
at, count = at + take, count - take
|
|
120
|
+
return b"".join(out)
|
|
121
|
+
|
|
122
|
+
def read_all(self):
|
|
123
|
+
return self.read(0, self.length)
|
|
124
|
+
|
|
125
|
+
def chunks(self, width, per=None):
|
|
126
|
+
"""The object as consecutive `width`-byte slices, read a run of pages at a time."""
|
|
127
|
+
per = per or max(1, (1 << 20) // width)
|
|
128
|
+
at = 0
|
|
129
|
+
while at < self.length:
|
|
130
|
+
data = self.read(at, width * per)
|
|
131
|
+
for i in range(0, len(data) - width + 1, width):
|
|
132
|
+
yield data[i:i + width]
|
|
133
|
+
at += width * per
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def blob(obj, block):
|
|
137
|
+
"""A value kept in a blob object: the 256-byte blocks from `block` on, by their links."""
|
|
138
|
+
out = []
|
|
139
|
+
while block:
|
|
140
|
+
data = obj.read(block * BLOCK, BLOCK)
|
|
141
|
+
block, used = struct.unpack_from("<Ih", data, 0)
|
|
142
|
+
out.append(data[6:6 + used])
|
|
143
|
+
return b"".join(out)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# --------------------------------------------------------------------------- tables
|
|
147
|
+
|
|
148
|
+
TABLE = re.compile(r'^\{"([^"]+)",\d+,?\s*\{"Fields",(.*?)\},\s*\{"Indexes".*'
|
|
149
|
+
r'\{"Recordlock","(\d)"\},\s*\{"Files",(\d+),(\d+),(\d+)\}\s*\}\s*$', re.S)
|
|
150
|
+
FIELD = re.compile(r'\{"(\w+)","(\w+)",(\d),(\d+),(\d+),"(\w+)"\}')
|
|
151
|
+
|
|
152
|
+
WIDTH = {
|
|
153
|
+
"B": lambda n: n, # binary(n)
|
|
154
|
+
"L": lambda n: 1, # boolean
|
|
155
|
+
"N": lambda n: n // 2 + 1, # BCD digits behind a sign nibble
|
|
156
|
+
"NC": lambda n: n * 2, # nchar(n), UTF-16
|
|
157
|
+
"NVC": lambda n: n * 2 + 2,# nvarchar(n): a u16 length, then the characters
|
|
158
|
+
"RV": lambda n: 16, # the row version
|
|
159
|
+
"NT": lambda n: 8, # ntext: a blob reference
|
|
160
|
+
"I": lambda n: 8, # image: a blob reference
|
|
161
|
+
"DT": lambda n: 7, # datetime, BCD YYYYMMDDhhmmss
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Table(object):
|
|
166
|
+
def __init__(self, db, description):
|
|
167
|
+
self.db = db
|
|
168
|
+
match = TABLE.match(description.lstrip(u"").strip())
|
|
169
|
+
if not match:
|
|
170
|
+
raise ValueError("cannot read the table description %r" % description[:120])
|
|
171
|
+
self.name = match.group(1)
|
|
172
|
+
self.records, self.blobs, self.indexes = (int(match.group(i)) for i in (4, 5, 6))
|
|
173
|
+
self.fields = collections.OrderedDict()
|
|
174
|
+
specs = FIELD.findall(match.group(2))
|
|
175
|
+
# The row version, when a table has one, sits first after the free-slot byte.
|
|
176
|
+
offset = 17 if any(s[1] == "RV" for s in specs) else 1
|
|
177
|
+
for name, kind, nullable, length, precision, _ in specs:
|
|
178
|
+
if kind not in WIDTH:
|
|
179
|
+
raise ValueError("%s.%s: field type %s" % (self.name, name, kind))
|
|
180
|
+
size = (nullable == "1") + WIDTH[kind](int(length))
|
|
181
|
+
if kind == "RV":
|
|
182
|
+
at = 1
|
|
183
|
+
else:
|
|
184
|
+
at, offset = offset, offset + size
|
|
185
|
+
self.fields[name] = Field(name, kind, nullable == "1", int(length), int(precision), at, size)
|
|
186
|
+
# A record is never shorter than a free-slot byte and the next free slot's number.
|
|
187
|
+
self.width = max(offset, 5)
|
|
188
|
+
self._records = self._blobs = None
|
|
189
|
+
|
|
190
|
+
def _object(self, which):
|
|
191
|
+
if which == "records":
|
|
192
|
+
if self._records is None:
|
|
193
|
+
self._records = Object(self.db, self.records) if self.records else None
|
|
194
|
+
return self._records
|
|
195
|
+
if self._blobs is None:
|
|
196
|
+
self._blobs = Object(self.db, self.blobs)
|
|
197
|
+
return self._blobs
|
|
198
|
+
|
|
199
|
+
def __len__(self):
|
|
200
|
+
"""Slots, the free ones included; `rows()` skips those."""
|
|
201
|
+
obj = self._object("records")
|
|
202
|
+
return len(obj) // self.width if obj else 0
|
|
203
|
+
|
|
204
|
+
def count(self):
|
|
205
|
+
"""Live rows: the slots whose free-slot byte is clear."""
|
|
206
|
+
obj = self._object("records")
|
|
207
|
+
return sum(1 for raw in obj.chunks(self.width) if raw[:1] != b"\x01") if obj else 0
|
|
208
|
+
|
|
209
|
+
def rows(self, columns=None):
|
|
210
|
+
"""Live rows as tuples of Python values, in `columns` order (all fields by default)."""
|
|
211
|
+
obj = self._object("records")
|
|
212
|
+
if obj is None:
|
|
213
|
+
return
|
|
214
|
+
fields = [self.fields[c] for c in (columns or list(self.fields))]
|
|
215
|
+
for raw in obj.chunks(self.width):
|
|
216
|
+
if raw[:1] == b"\x01":
|
|
217
|
+
continue
|
|
218
|
+
yield tuple(self.value(f, raw) for f in fields)
|
|
219
|
+
|
|
220
|
+
def value(self, field, raw):
|
|
221
|
+
data = raw[field.offset:field.offset + field.size]
|
|
222
|
+
if field.nullable:
|
|
223
|
+
if data[:1] == b"\x00":
|
|
224
|
+
return None
|
|
225
|
+
data = data[1:]
|
|
226
|
+
kind = field.type
|
|
227
|
+
if kind == "B":
|
|
228
|
+
return data
|
|
229
|
+
if kind == "L":
|
|
230
|
+
return data[:1] != b"\x00"
|
|
231
|
+
if kind == "N":
|
|
232
|
+
return numeric(data, field.length, field.precision)
|
|
233
|
+
if kind == "NC":
|
|
234
|
+
return data.decode("utf-16-le")
|
|
235
|
+
if kind == "NVC":
|
|
236
|
+
(n,) = struct.unpack_from("<H", data, 0)
|
|
237
|
+
return data[2:2 + n * 2].decode("utf-16-le")
|
|
238
|
+
if kind == "RV":
|
|
239
|
+
return struct.unpack("<4I", data)
|
|
240
|
+
if kind == "DT":
|
|
241
|
+
return moment(data)
|
|
242
|
+
block, size = struct.unpack("<2I", data)
|
|
243
|
+
if not block and not size:
|
|
244
|
+
return u"" if kind == "NT" else b""
|
|
245
|
+
value = blob(self._object("blobs"), block)[:size]
|
|
246
|
+
return value.decode("utf-16-le") if kind == "NT" else value
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def numeric(data, length, precision):
|
|
250
|
+
"""BCD: the high nibble of the first byte is the sign (1 positive), then `length` digits.
|
|
251
|
+
|
|
252
|
+
A Decimal, never a float: the kopeck has to survive the read.
|
|
253
|
+
"""
|
|
254
|
+
digits = "".join("%02x" % b for b in bytearray(data))
|
|
255
|
+
sign, body = digits[0], digits[1:1 + length]
|
|
256
|
+
if precision:
|
|
257
|
+
body = body[:length - precision] + "." + body[length - precision:]
|
|
258
|
+
value = decimal.Decimal(body)
|
|
259
|
+
return value if sign != "0" else -value
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def moment(data):
|
|
263
|
+
digits = "".join("%02x" % b for b in bytearray(data))
|
|
264
|
+
year = int(digits[0:4])
|
|
265
|
+
if year == 0:
|
|
266
|
+
# The empty date, which a server infobase holds as 0001-01-01 (shifted).
|
|
267
|
+
return datetime.datetime(1, 1, 1)
|
|
268
|
+
return datetime.datetime(year, int(digits[4:6]), int(digits[6:8]),
|
|
269
|
+
int(digits[8:10]), int(digits[10:12]), int(digits[12:14]))
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Where the working files live.
|
|
3
|
+
|
|
4
|
+
Every tool used to derive this from its own `__file__`, two directories up, which quietly
|
|
5
|
+
assumed the library sits inside the project it serves. It does not have to: the library
|
|
6
|
+
is meant to be lifted out and pointed at a different infobase, and the project it is
|
|
7
|
+
pointed at is then somewhere else entirely.
|
|
8
|
+
|
|
9
|
+
So the root is asked for, not assumed. `DF1C_ROOT` names it; without that, the directory
|
|
10
|
+
holding `tools/` is used, which is what a project laid out like this one gets for free.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import print_function
|
|
13
|
+
|
|
14
|
+
import io
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _default_root():
|
|
21
|
+
# tools/lib/paths.py -> tools -> the project root. Only a library sitting in a
|
|
22
|
+
# project's `tools/` has one; a copy anywhere else (an installed skill) would write
|
|
23
|
+
# the dump into its own directory, so it asks instead.
|
|
24
|
+
if os.path.basename(os.path.dirname(HERE)) != "tools":
|
|
25
|
+
raise SystemExit("set DF1C_ROOT to the porting project's directory (and DF1C_PROJECT"
|
|
26
|
+
" to its project package): this library is not inside one")
|
|
27
|
+
return os.path.dirname(os.path.dirname(HERE))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
ROOT = os.path.abspath(os.environ.get("DF1C_ROOT") or _default_root())
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_env(path=os.path.join(ROOT, ".env")):
|
|
34
|
+
"""Read `<root>/.env` into the environment without overriding what is already set.
|
|
35
|
+
|
|
36
|
+
Here, before anything below is worked out, so that every tool sees it - the export
|
|
37
|
+
included - and a `DF1C_OUT` written there moves `out/` as one exported in the shell does.
|
|
38
|
+
"""
|
|
39
|
+
if not os.path.isfile(path):
|
|
40
|
+
return
|
|
41
|
+
with io.open(path, encoding="utf-8") as f:
|
|
42
|
+
for line in f:
|
|
43
|
+
line = line.strip()
|
|
44
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
45
|
+
continue
|
|
46
|
+
key, value = line.split("=", 1)
|
|
47
|
+
os.environ.setdefault(key.strip(), value.strip())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
load_env()
|
|
51
|
+
|
|
52
|
+
# What the extraction writes and everything downstream reads. Kept separate from ROOT
|
|
53
|
+
# because a second infobase dumped beside the first only differs here.
|
|
54
|
+
OUT = os.path.abspath(os.environ.get("DF1C_OUT") or os.path.join(ROOT, "out"))
|
|
55
|
+
|
|
56
|
+
# Generated dForge modules.
|
|
57
|
+
MODULES = os.path.abspath(os.environ.get("DF1C_MODULES") or os.path.join(ROOT, "modules"))
|
|
58
|
+
|
|
59
|
+
# The project's own package - the contour map, the column map and the authored specs.
|
|
60
|
+
# `DF1C_PROJECT` points the library at a different one without moving anything.
|
|
61
|
+
PROJECT = os.path.abspath(os.environ.get("DF1C_PROJECT")
|
|
62
|
+
or os.path.join(os.path.dirname(HERE), "project"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def required(name, what):
|
|
66
|
+
u"""An environment value the library cannot invent, with a default that would be a lie.
|
|
67
|
+
|
|
68
|
+
`DF1C_DB` used to default to `utp_biscuit` and `DF_TENANT` to `hbf1` - this project's own
|
|
69
|
+
names, sitting in the library. Harmless while there is one infobase and a trap as soon as
|
|
70
|
+
there are two: pointing the tools at a second configuration and forgetting one variable
|
|
71
|
+
would have read or written the FIRST one, quietly and with the right-looking output. So
|
|
72
|
+
the library asks and stops rather than guessing.
|
|
73
|
+
"""
|
|
74
|
+
value = os.environ.get(name)
|
|
75
|
+
if value:
|
|
76
|
+
return value
|
|
77
|
+
raise SystemExit(
|
|
78
|
+
"%s is not set - %s.\n"
|
|
79
|
+
" This is the project's to say, not the library's. Copy .env.example to .env and\n"
|
|
80
|
+
" fill it in, or export the variable for this shell." % (name, what))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def derived(name):
|
|
84
|
+
"""Where a file BUILT FROM THE PROJECT goes, as opposed to one read from the infobase.
|
|
85
|
+
|
|
86
|
+
`out/` holds two different kinds of thing. The extraction - report.json, dbnames.json,
|
|
87
|
+
the configuration dump, the module sources - is about the infobase and is the same
|
|
88
|
+
whoever is porting it. `columnmap.json` and `registers.json` are about the PROJECT:
|
|
89
|
+
which of its entities copies which 1C column, which of its modules declares which
|
|
90
|
+
register. Pointing the library at a second project and rebuilding the first kind is
|
|
91
|
+
free; rebuilding the second kind on top of the first project's copy destroys it.
|
|
92
|
+
|
|
93
|
+
Which is what happened: one `columnmap` run against the standard-configuration project
|
|
94
|
+
overwrote the client port's map with 21 modules the client port has never heard of.
|
|
95
|
+
`migrate` reads that file, so the next load would have copied by a map for other
|
|
96
|
+
entities entirely - and the file is committed, so the damage outlives the session.
|
|
97
|
+
|
|
98
|
+
The default project keeps writing where it always did, so nothing that exists moves.
|
|
99
|
+
Any other project gets a subdirectory named after it.
|
|
100
|
+
"""
|
|
101
|
+
if os.path.basename(PROJECT) == "project":
|
|
102
|
+
return os.path.join(OUT, name)
|
|
103
|
+
folder = os.path.join(OUT, os.path.basename(PROJECT))
|
|
104
|
+
if not os.path.isdir(folder):
|
|
105
|
+
os.makedirs(folder)
|
|
106
|
+
return os.path.join(folder, name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def show():
|
|
110
|
+
for name in ("ROOT", "OUT", "MODULES", "PROJECT"):
|
|
111
|
+
print("%-8s %s" % (name, globals()[name]))
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Show sample rows of a physical table, with 1C's standard columns decoded.
|
|
5
|
+
|
|
6
|
+
Attribute lists from the configuration omit the implicit fields every catalog and
|
|
7
|
+
document carries (_Code, _Description, _Date_Time, _Posted, _Marked), so the only
|
|
8
|
+
way to see what actually lives in them is to look at the data.
|
|
9
|
+
|
|
10
|
+
python tools/peek.py _Reference214 # 5 rows, all columns
|
|
11
|
+
python tools/peek.py _Reference214 10 # 10 rows
|
|
12
|
+
python tools/peek.py --find рзВодители # resolve a metadata name to its table
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import print_function
|
|
15
|
+
|
|
16
|
+
import codecs
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
import paths
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
24
|
+
|
|
25
|
+
import store
|
|
26
|
+
|
|
27
|
+
OUT = paths.OUT
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve(name):
|
|
31
|
+
with codecs.open(os.path.join(OUT, "report.json"), "r", "utf-8") as f:
|
|
32
|
+
for row in json.load(f):
|
|
33
|
+
if row.get("name") == name:
|
|
34
|
+
return row
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def render(value):
|
|
39
|
+
if value is None:
|
|
40
|
+
return "-"
|
|
41
|
+
if isinstance(value, (bytes, bytearray)):
|
|
42
|
+
return value.hex()[:16]
|
|
43
|
+
text = str(value).strip()
|
|
44
|
+
return text[:38] if text else "-"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main():
|
|
48
|
+
if sys.argv[1] == "--find":
|
|
49
|
+
row = resolve(sys.argv[2])
|
|
50
|
+
print(json.dumps(row, ensure_ascii=False, indent="\t") if row else "not found")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
table = sys.argv[1]
|
|
54
|
+
limit = int(sys.argv[2]) if len(sys.argv) > 2 else 5
|
|
55
|
+
cur = store.connect().cursor()
|
|
56
|
+
cur.execute("SELECT * FROM %s LIMIT %d" % (store.quote(table), limit))
|
|
57
|
+
columns = [d[0] for d in cur.description]
|
|
58
|
+
rows = cur.fetchall()
|
|
59
|
+
# drop columns that are empty across every sampled row - they only add noise
|
|
60
|
+
keep = [i for i, _ in enumerate(columns)
|
|
61
|
+
if any(render(r[i]) not in ("-", "0", "0.0000") for r in rows)]
|
|
62
|
+
for i in keep:
|
|
63
|
+
print("%-28s | %s" % (columns[i], " | ".join(render(r[i]) for r in rows)))
|
|
64
|
+
print("\n(%d columns, %d hidden as empty)" % (len(columns), len(columns) - len(keep)))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
main()
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Mark the migrated documents that 1C posted as posted in the source system.
|
|
5
|
+
|
|
6
|
+
The loader brings **both** sides across from the infobase: the documents and the register
|
|
7
|
+
rows they wrote. The platform's posting flag is not among them - it is engine state, and a
|
|
8
|
+
generated trigger refuses any write to it but the engine's (`DF006`) - so every migrated
|
|
9
|
+
document arrives saying "not posted" while its movements already sit in the register. Left
|
|
10
|
+
alone, a user opens a 2018 document, sees it unposted, presses Post, and the engine writes a
|
|
11
|
+
second set of rows on top of the migrated ones.
|
|
12
|
+
|
|
13
|
+
The platform's answer is a distinct state rather than a claim that the engine posted it:
|
|
14
|
+
`register.markImported` sets the hidden marker pair beside a state column
|
|
15
|
+
(`<state>_imported`, `<state>_imported_source`). A marked document is settled - the engine
|
|
16
|
+
refuses to post or unpost it, its locked fields freeze, and the card shows it read-only under
|
|
17
|
+
an "Imported" badge - while its state column stays false, so "posted" keeps meaning "the
|
|
18
|
+
engine posted this".
|
|
19
|
+
|
|
20
|
+
python tools/1c.py posted # measure: what 1C posted, what is marked
|
|
21
|
+
DF_TOKEN=<jwt> python tools/1c.py posted --mark # make the markers match 1C
|
|
22
|
+
DF_TOKEN=<jwt> python tools/1c.py posted --unmark # take every marker off again
|
|
23
|
+
|
|
24
|
+
What gets marked is exactly what 1C asserted: `posted` is the infobase's own `Проведен`, so a
|
|
25
|
+
document 1C left unposted stays postable here, and its movements were never written either.
|
|
26
|
+
`--mark` converges on that: it also takes the marker off a document 1C did not post (an
|
|
27
|
+
earlier run marked without the condition), since a marked draft could never be posted here.
|
|
28
|
+
It is idempotent - the RPC skips a document already marked or already posted - so it is safe
|
|
29
|
+
to run after every load, and it has to be: a reload of a document table brings the rows back
|
|
30
|
+
unmarked.
|
|
31
|
+
|
|
32
|
+
It is deliberately **not** part of `migrate run`. Marking is a statement about history made
|
|
33
|
+
once the history is in place, and it needs a token the loader does not have.
|
|
34
|
+
|
|
35
|
+
Nothing here stamps `source` on the migrated register rows. That column exists so unposting
|
|
36
|
+
can find the rows it wrote, and a marked document is never unposted.
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import print_function
|
|
39
|
+
|
|
40
|
+
import argparse
|
|
41
|
+
import sys
|
|
42
|
+
|
|
43
|
+
import migrate
|
|
44
|
+
import paths
|
|
45
|
+
import posting
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def source():
|
|
49
|
+
"""The label a marker carries: which infobase the document was posted in."""
|
|
50
|
+
return "1C:%s" % paths.required("DF1C_DB", "which MS SQL infobase the documents came from")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def registry_columns(pg):
|
|
54
|
+
"""Every posting column in the tenant - `A` and ledger `L` alike - with its module and
|
|
55
|
+
the table it sits on.
|
|
56
|
+
|
|
57
|
+
Read from the tenant's own metadata rather than listed here, so a register declared
|
|
58
|
+
tomorrow is covered without editing this file - the failure mode otherwise is a new
|
|
59
|
+
binding quietly missing from the sweep, which is exactly the bug this guards against.
|
|
60
|
+
It was one: the sweep read `A` only, and the first ledger binding would have left every
|
|
61
|
+
document 1C posted offered Post again, doubling its postings in the ledger.
|
|
62
|
+
|
|
63
|
+
A binding may keep its state in a field of its own (`stateField`), and an extension's
|
|
64
|
+
columns sit in a side table (`storage_table`) joined on the document's key: the column
|
|
65
|
+
returned is the state the marker sits beside, and the storage is that side table or None.
|
|
66
|
+
"""
|
|
67
|
+
cur = pg.cursor()
|
|
68
|
+
cur.execute("""
|
|
69
|
+
SELECT m.module_cd, e.entity_cd, e.schema_name, e.db_object,
|
|
70
|
+
coalesce(c.params->>'stateField', c.column_cd), s.storage_table,
|
|
71
|
+
(SELECT k.column_cd FROM "dForge".entity_column k
|
|
72
|
+
WHERE k.entity_id = e.entity_id AND k.is_pk LIMIT 1)
|
|
73
|
+
FROM "dForge".entity_column c
|
|
74
|
+
JOIN "dForge".entity e ON e.entity_id = c.entity_id
|
|
75
|
+
JOIN "dForge".module m ON m.module_id = e.module_id
|
|
76
|
+
LEFT JOIN "dForge".entity_column s
|
|
77
|
+
ON s.entity_id = e.entity_id AND s.column_cd = coalesce(c.params->>'stateField', c.column_cd)
|
|
78
|
+
WHERE c.column_type IN ('A', 'L')
|
|
79
|
+
ORDER BY m.module_cd, e.entity_cd, c.column_cd""")
|
|
80
|
+
return [row for row in cur.fetchall() if row[2] and row[3]]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def storage_of(schema, table, storage):
|
|
84
|
+
"""(schema, table) the state and its marker are kept in."""
|
|
85
|
+
if not storage:
|
|
86
|
+
return schema, table
|
|
87
|
+
side_schema, side_table = storage.split(".", 1)
|
|
88
|
+
return side_schema.strip('"'), side_table.strip('"')
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def has_column(pg, schema, table, column):
|
|
92
|
+
cur = pg.cursor()
|
|
93
|
+
cur.execute("""SELECT 1 FROM information_schema.columns
|
|
94
|
+
WHERE table_schema = %s AND table_name = %s AND column_name = %s""",
|
|
95
|
+
(schema, table, column))
|
|
96
|
+
return cur.fetchone() is not None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def counts(pg, schema, table, column, storage=None, key=None):
|
|
100
|
+
"""(1C posted, engine posted, marked, still to mark, marked though 1C did not post).
|
|
101
|
+
An extension's state is read from its side table, where a document it never wrote has no row."""
|
|
102
|
+
marker = column + "_imported"
|
|
103
|
+
side = ' LEFT JOIN %s x ON x."%s" = t."%s"' % (storage, key, key) if storage else ""
|
|
104
|
+
cur = pg.cursor()
|
|
105
|
+
cur.execute("""SELECT count(*) FILTER (WHERE posted),
|
|
106
|
+
count(*) FILTER (WHERE "{c}"),
|
|
107
|
+
count(*) FILTER (WHERE "{m}"),
|
|
108
|
+
count(*) FILTER (WHERE posted AND NOT coalesce("{c}", false) AND NOT coalesce("{m}", false)),
|
|
109
|
+
count(*) FILTER (WHERE "{m}" AND NOT coalesce(posted, false))
|
|
110
|
+
FROM "{s}"."{t}" t{x}""".format(c=column, m=marker, s=schema, t=table, x=side))
|
|
111
|
+
return cur.fetchone()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def folder_of(pg, module):
|
|
115
|
+
# As a string: the RPC declares folderId as one.
|
|
116
|
+
cur = pg.cursor()
|
|
117
|
+
cur.execute('select f.folder_id from "dForge".folder f '
|
|
118
|
+
'join "dForge".module m on m.module_id = f.module_id '
|
|
119
|
+
'where m.module_cd = %s order by f.folder_id limit 1', (module,))
|
|
120
|
+
row = cur.fetchone()
|
|
121
|
+
return str(row[0]) if row else None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def survey(pg, module=None, unmark=False, quiet=False):
|
|
125
|
+
"""The table of what 1C posted against what is marked, and the work it implies.
|
|
126
|
+
|
|
127
|
+
Returns (work, total, stale): one (module, label, column) per registry column that can
|
|
128
|
+
be marked, the number of documents a converge would touch, and the modules installed
|
|
129
|
+
before the marker pair existed.
|
|
130
|
+
"""
|
|
131
|
+
columns = [c for c in registry_columns(pg) if not module or c[0] == module]
|
|
132
|
+
out = (lambda *a: None) if quiet else print
|
|
133
|
+
out("%-52s %-22s %9s %9s %9s %9s %9s"
|
|
134
|
+
% ("document", "state", "1C", "posted", "marked", "to mark", "stray"))
|
|
135
|
+
out("-" * 126)
|
|
136
|
+
work, total, stale = [], 0, []
|
|
137
|
+
for module_cd, entity_cd, schema, table, column, storage, key in columns:
|
|
138
|
+
label = "%s.%s" % (module_cd, entity_cd)
|
|
139
|
+
if not has_column(pg, schema, table, "posted"):
|
|
140
|
+
out("%-52s %-22s skipped: no `posted` column to read" % (label[:52], column[:22]))
|
|
141
|
+
continue
|
|
142
|
+
if not has_column(pg, *(storage_of(schema, table, storage) + (column + "_imported",))):
|
|
143
|
+
# Installed before the platform synthesised the marker pair. Marking refuses and
|
|
144
|
+
# says to reinstall; saying so here costs nothing.
|
|
145
|
+
stale.append(label)
|
|
146
|
+
out("%-52s %-22s no marker column: reinstall the module" % (label[:52], column[:22]))
|
|
147
|
+
continue
|
|
148
|
+
in_1c, posted_here, marked, pending, stray = counts(pg, schema, table, column, storage, key)
|
|
149
|
+
total += marked if unmark else pending + stray
|
|
150
|
+
work.append((module_cd, label, column))
|
|
151
|
+
out("%-52s %-22s %9s %9s %9s %9s %9s"
|
|
152
|
+
% (label[:52], column[:22], format(in_1c, ","), format(posted_here, ","),
|
|
153
|
+
format(marked, ","), format(pending, ","), format(stray, ",")))
|
|
154
|
+
out("-" * 126)
|
|
155
|
+
out("%-98s %9s" % ("to unmark" if unmark else "to mark or unmark", format(total, ",")))
|
|
156
|
+
return work, total, stale
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def converge(pg, work, jwt, unmark=False):
|
|
160
|
+
"""Make the markers match 1C (or take every one off): {verb: count}, and whether all went.
|
|
161
|
+
|
|
162
|
+
--mark marks what 1C posted and takes the marker off what it did not. The delta loader
|
|
163
|
+
calls this too, after it has upserted documents: a document it brought in is unmarked,
|
|
164
|
+
and one 1C has since unposted is still marked.
|
|
165
|
+
"""
|
|
166
|
+
steps = [("register.unmarkImported", "true", "unmarked")] if unmark else [
|
|
167
|
+
("register.markImported", "t.posted", "marked"),
|
|
168
|
+
("register.unmarkImported", "NOT coalesce(t.posted, false)", "unmarked")]
|
|
169
|
+
failed = False
|
|
170
|
+
done = {}
|
|
171
|
+
for module, label, column in work:
|
|
172
|
+
for method, where, verb in steps:
|
|
173
|
+
params = {"entity": label, "stateColumn": column, "where": where}
|
|
174
|
+
if method == "register.markImported":
|
|
175
|
+
params["source"] = source()
|
|
176
|
+
result = posting.call(method, params, folder_of(pg, module), jwt)
|
|
177
|
+
count = (result.get("result") or {}).get("count") if isinstance(result, dict) else None
|
|
178
|
+
if count is None:
|
|
179
|
+
failed = True
|
|
180
|
+
print(" %-52s %s" % (label, result), file=sys.stderr)
|
|
181
|
+
continue
|
|
182
|
+
done[verb] = done.get(verb, 0) + count
|
|
183
|
+
if count:
|
|
184
|
+
print(" %-52s %-22s %s %s" % (label, column, verb, format(count, ",")))
|
|
185
|
+
return done, not failed
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def main():
|
|
189
|
+
parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
|
|
190
|
+
action = parser.add_mutually_exclusive_group()
|
|
191
|
+
action.add_argument("--mark", action="store_true",
|
|
192
|
+
help="mark every document 1C posted (needs a token)")
|
|
193
|
+
action.add_argument("--unmark", action="store_true",
|
|
194
|
+
help="remove every marker (needs a token)")
|
|
195
|
+
parser.add_argument("module", nargs="?", help="one module only")
|
|
196
|
+
args = parser.parse_args()
|
|
197
|
+
|
|
198
|
+
migrate.load_env()
|
|
199
|
+
pg = migrate.pg_connect()
|
|
200
|
+
pg.autocommit = True
|
|
201
|
+
if not any(not args.module or c[0] == args.module for c in registry_columns(pg)):
|
|
202
|
+
print("no registry columns in this tenant")
|
|
203
|
+
return
|
|
204
|
+
work, total, _ = survey(pg, args.module, args.unmark)
|
|
205
|
+
|
|
206
|
+
if not (args.mark or args.unmark):
|
|
207
|
+
if total:
|
|
208
|
+
print("\nnothing written. --mark (with a token) marks them.")
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
done, ok = converge(pg, work, posting.token(), args.unmark)
|
|
212
|
+
print("\n" + ", ".join("%s %s" % (verb, format(n, ",")) for verb, n in done.items())
|
|
213
|
+
+ " document(s).")
|
|
214
|
+
if not ok:
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
main()
|