@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +2 -1
  3. package/dist/server.js +71 -3
  4. package/package.json +3 -3
  5. package/resources/docs/dsl-reference.md +1 -1
  6. package/resources/schemas/data-views.schema.json +50 -0
  7. package/scripts/install-skills.mjs +1 -0
  8. package/skills/dforge-1c-import/SKILL.md +375 -0
  9. package/skills/dforge-1c-import/references/environment.md +98 -0
  10. package/skills/dforge-1c-import/references/loading.md +164 -0
  11. package/skills/dforge-1c-import/references/measurements.md +210 -0
  12. package/skills/dforge-1c-import/references/print-forms.md +148 -0
  13. package/skills/dforge-1c-import/scripts/1c.py +178 -0
  14. package/skills/dforge-1c-import/scripts/README.md +164 -0
  15. package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
  16. package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
  17. package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
  18. package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
  19. package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
  20. package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
  21. package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
  22. package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
  23. package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
  24. package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
  25. package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
  26. package/skills/dforge-1c-import/scripts/lib/delta.py +770 -0
  27. package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
  28. package/skills/dforge-1c-import/scripts/lib/docusage.py +106 -0
  29. package/skills/dforge-1c-import/scripts/lib/dump1c.py +408 -0
  30. package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
  31. package/skills/dforge-1c-import/scripts/lib/external.py +202 -0
  32. package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
  33. package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
  34. package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
  35. package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
  36. package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
  37. package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
  38. package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
  39. package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
  40. package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
  41. package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
  42. package/skills/dforge-1c-import/scripts/lib/meta.py +325 -0
  43. package/skills/dforge-1c-import/scripts/lib/migrate.py +1318 -0
  44. package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
  45. package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
  46. package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
  47. package/skills/dforge-1c-import/scripts/lib/paths.py +111 -0
  48. package/skills/dforge-1c-import/scripts/lib/peek.py +68 -0
  49. package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
  50. package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
  51. package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
  52. package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
  53. package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
  54. package/skills/dforge-1c-import/scripts/lib/recorders.py +91 -0
  55. package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
  56. package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
  57. package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
  58. package/skills/dforge-1c-import/scripts/lib/seedstate.py +493 -0
  59. package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
  60. package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
  61. package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -0
  62. package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
  63. package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
  64. package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
  65. package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
  66. package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
  67. package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
  68. package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
  69. package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
  70. package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
  71. package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
  72. package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
  73. package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
  74. package/skills/dforge-1c-import/scripts/project-template/sources.py +24 -0
  75. package/skills/dforge-mcp-author/SKILL.md +5 -0
  76. package/skills/dforge-mcp-author/references/action-dsl.md +1 -1
  77. package/skills/dforge-mcp-author/references/data-views.md +38 -2
  78. package/skills/dforge-mcp-author/references/formulas.md +32 -1
  79. package/skills/dforge-mcp-author/references/print-templates.md +30 -5
  80. package/skills/dforge-mcp-author/references/queries.md +17 -2
  81. package/skills/dforge-mcp-author/references/registers.md +1 -0
  82. package/skills/dforge-mcp-author/references/reports.md +1 -1
  83. 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()