@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,488 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Rebuild a balance register's state from its journal, and check it against 1C's totals.
|
|
5
|
+
|
|
6
|
+
A dForge `balance` register keeps a running total in a STATE entity and its movements in a
|
|
7
|
+
JOURNAL. A port loads the movements - they are 1C's rows - but 1C has no state table to copy:
|
|
8
|
+
it keeps per-period totals of its own (`_AccumRgT`) and sums on every read. The platform
|
|
9
|
+
does not seed a state from a journal either: posting moves both together, and nothing here
|
|
10
|
+
is posted. So this does it, as one idempotent statement per register:
|
|
11
|
+
|
|
12
|
+
state := SUM(resource * journalDirection) GROUP BY dimensions, the non-zero tuples only
|
|
13
|
+
|
|
14
|
+
A zero tuple is left out because a zero row and no row answer `getBalance` alike, and 1C's
|
|
15
|
+
own Остатки does not return one either. The state's key is minted from the tuple itself
|
|
16
|
+
(md5 -> uuid), so a re-run lands on the same keys rather than reshuffling them.
|
|
17
|
+
|
|
18
|
+
python tools/1c.py seedstate run [module ...] # rebuild every journalled balance state
|
|
19
|
+
python tools/1c.py seedstate check [module ...] # state against its journal, no writes
|
|
20
|
+
python tools/1c.py seedstate verify [module ...] # state against 1C's current totals
|
|
21
|
+
python tools/1c.py seedstate verify --asof 2026-08 # as-of read against 1C's month row
|
|
22
|
+
python tools/1c.py seedstate probe [module ...] # the platform's getBalance against 1C
|
|
23
|
+
|
|
24
|
+
Which registers: every `kind: "balance"` register in the tenant that names both an entity
|
|
25
|
+
and a journal with a `journalDirection` - read from `"dForge".register`, so nothing here
|
|
26
|
+
names a register. `run` refuses a journal with a row whose direction is not +1/-1: the
|
|
27
|
+
loader stamps it (`migrate.record_kind`), and a NULL would drop the row from the sum.
|
|
28
|
+
|
|
29
|
+
`verify` reads the journal's 1C table from the column map, finds its totals table through
|
|
30
|
+
DBNames (the `AccumRgT` entry beside the `AccumRg` one), and reads the CURRENT totals - the
|
|
31
|
+
rows at the table's last `_Period` - by a seek on the clustered index, summing on this side.
|
|
32
|
+
No GROUP BY runs on the server, whose tempdb has no room for one.
|
|
33
|
+
|
|
34
|
+
The writes run with `session_replication_role = 'replica'`, as the loader's do: the state
|
|
35
|
+
table carries the audit trigger, and 5,000 rows of "inserted" history describe nothing.
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import print_function
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import codecs
|
|
41
|
+
import collections
|
|
42
|
+
import datetime
|
|
43
|
+
import decimal
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import sys
|
|
47
|
+
|
|
48
|
+
import attrs
|
|
49
|
+
import migrate
|
|
50
|
+
|
|
51
|
+
Register = collections.namedtuple("Register", [
|
|
52
|
+
"module", "code", "state", "journal", "journal_entity", "direction", "date",
|
|
53
|
+
"dimensions", "resources", "key", "key_type", "resource_names"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def q(name):
|
|
57
|
+
return '"%s"' % name.replace('"', '""')
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def registers(cur, modules):
|
|
61
|
+
cur.execute("""
|
|
62
|
+
SELECT m.module_cd, r.register_cd, se.schema_name, se.db_object,
|
|
63
|
+
je.schema_name, je.db_object, je.entity_cd,
|
|
64
|
+
r.journal_direction, r.journal_date, r.dimensions, r.resources
|
|
65
|
+
FROM "dForge".register r
|
|
66
|
+
JOIN "dForge".module m ON m.module_id = r.module_id
|
|
67
|
+
JOIN "dForge".entity se ON se.entity_id = r.entity_id
|
|
68
|
+
JOIN "dForge".entity je ON je.entity_id = r.journal_entity_id
|
|
69
|
+
WHERE r.kind = 'balance' AND r.journal_direction IS NOT NULL
|
|
70
|
+
ORDER BY 1, 2""")
|
|
71
|
+
found = []
|
|
72
|
+
for (module, code, s_schema, s_table, j_schema, j_table, j_entity,
|
|
73
|
+
direction, date, dims, resources) in cur.fetchall():
|
|
74
|
+
if modules and module not in modules:
|
|
75
|
+
continue
|
|
76
|
+
dims = dims if isinstance(dims, list) else json.loads(dims)
|
|
77
|
+
resources = resources if isinstance(resources, dict) else json.loads(resources)
|
|
78
|
+
fields = [spec["field"] for _, spec in sorted(resources.items())]
|
|
79
|
+
names = [name for name, _ in sorted(resources.items())]
|
|
80
|
+
cur.execute("""
|
|
81
|
+
SELECT a.attname, format_type(a.atttypid, a.atttypmod)
|
|
82
|
+
FROM pg_index i
|
|
83
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
|
84
|
+
WHERE i.indrelid = %s::regclass AND i.indisprimary""",
|
|
85
|
+
("%s.%s" % (q(s_schema), q(s_table)),))
|
|
86
|
+
key = cur.fetchall()
|
|
87
|
+
if len(key) != 1:
|
|
88
|
+
raise SystemExit("seedstate: %s.%s - the state %s.%s is not keyed by one column"
|
|
89
|
+
% (module, code, s_schema, s_table))
|
|
90
|
+
found.append(Register(module, code, "%s.%s" % (q(s_schema), q(s_table)),
|
|
91
|
+
"%s.%s" % (q(j_schema), q(j_table)), j_entity,
|
|
92
|
+
direction, date, dims, fields, key[0][0], key[0][1], names))
|
|
93
|
+
return found
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def summed(reg):
|
|
97
|
+
"""The journal grouped by tuple: dims, then one signed sum per resource."""
|
|
98
|
+
dims = ", ".join(q(d) for d in reg.dimensions)
|
|
99
|
+
sums = ", ".join("COALESCE(SUM(%s * %s), 0) AS %s" % (q(f), q(reg.direction), q(f))
|
|
100
|
+
for f in reg.resources)
|
|
101
|
+
return "SELECT %s, %s FROM %s GROUP BY %s" % (dims, sums, reg.journal, dims)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def nonzero(alias, reg):
|
|
105
|
+
return " OR ".join("%s.%s <> 0" % (alias, q(f)) for f in reg.resources)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def unstamped(cur, reg):
|
|
109
|
+
cur.execute("SELECT count(*) FILTER (WHERE %s IS NULL OR %s NOT IN (1, -1)),"
|
|
110
|
+
" count(*) FILTER (WHERE %s IS NULL), count(*) FROM %s"
|
|
111
|
+
% (q(reg.direction), q(reg.direction), q(reg.date), reg.journal))
|
|
112
|
+
return cur.fetchone()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def seed(pg, reg):
|
|
116
|
+
cur = pg.cursor()
|
|
117
|
+
bad, undated, rows = unstamped(cur, reg)
|
|
118
|
+
if bad:
|
|
119
|
+
raise SystemExit("seedstate: %s.%s - %d of %d journal rows carry no direction (+1/-1);"
|
|
120
|
+
" reload the journal with its direction transform first"
|
|
121
|
+
% (reg.module, reg.code, bad, rows))
|
|
122
|
+
if reg.key_type == "uuid":
|
|
123
|
+
# The tuple is the key: md5 over the quoted values, so NULL and '' stay apart.
|
|
124
|
+
parts = " || '|' || ".join("quote_nullable(g.%s)" % q(d) for d in reg.dimensions)
|
|
125
|
+
key = "md5('%s|' || %s)::uuid" % (reg.state.replace("'", "''"), parts)
|
|
126
|
+
elif reg.key_type == "bigint":
|
|
127
|
+
key = '"dForge".next_id()'
|
|
128
|
+
else:
|
|
129
|
+
raise SystemExit("seedstate: %s.%s - a state key of type %s cannot be minted here"
|
|
130
|
+
% (reg.module, reg.code, reg.key_type))
|
|
131
|
+
columns = [reg.key] + reg.dimensions + reg.resources
|
|
132
|
+
cur.execute("SET LOCAL session_replication_role = 'replica'")
|
|
133
|
+
cur.execute("DELETE FROM %s" % reg.state)
|
|
134
|
+
cur.execute("INSERT INTO %s (%s) SELECT %s, %s FROM (%s) g WHERE %s"
|
|
135
|
+
% (reg.state, ", ".join(q(c) for c in columns), key,
|
|
136
|
+
", ".join("g.%s" % q(c) for c in reg.dimensions + reg.resources),
|
|
137
|
+
summed(reg), nonzero("g", reg)))
|
|
138
|
+
written = cur.rowcount
|
|
139
|
+
pg.commit()
|
|
140
|
+
return rows, written, undated
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def drift(cur, reg):
|
|
144
|
+
"""Rows the state and the journal's sum do not share, counted from both sides.
|
|
145
|
+
|
|
146
|
+
A set difference rather than a FULL JOIN: the dimensions hold NULLs, which only
|
|
147
|
+
IS NOT DISTINCT FROM matches, and Postgres refuses that as a FULL JOIN condition.
|
|
148
|
+
EXCEPT treats two NULLs as the same value and compares numerics by value.
|
|
149
|
+
"""
|
|
150
|
+
columns = ", ".join(q(c) for c in reg.dimensions + reg.resources)
|
|
151
|
+
journal = "SELECT %s FROM (%s) g WHERE %s" % (columns, summed(reg), nonzero("g", reg))
|
|
152
|
+
# A zero state row is left out as the seed leaves it out: posting keeps the row it created
|
|
153
|
+
# when a tuple returns to zero, and a zero row and no row answer `getBalance` alike.
|
|
154
|
+
state = "SELECT %s FROM %s s WHERE %s" % (columns, reg.state, nonzero("s", reg))
|
|
155
|
+
cur.execute("SELECT (SELECT count(*) FROM (%s EXCEPT ALL %s) a)"
|
|
156
|
+
" + (SELECT count(*) FROM (%s EXCEPT ALL %s) b)"
|
|
157
|
+
% (journal, state, state, journal))
|
|
158
|
+
return cur.fetchone()[0]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --------------------------------------------------------------------------- 1C side
|
|
162
|
+
|
|
163
|
+
def totals_table(table):
|
|
164
|
+
"""`_AccumRg16999` -> `_AccumRgT17010`: the same object's AccumRgT entry in DBNames."""
|
|
165
|
+
with codecs.open(os.path.join(attrs.OUT, "dbnames.json"), "r", "utf-8") as f:
|
|
166
|
+
entries = json.load(f)["entries"]
|
|
167
|
+
guid = next((e["guid"] for e in entries if e["physical"] == table), None)
|
|
168
|
+
found = [e["physical"] for e in entries
|
|
169
|
+
if e["guid"] == guid and e["type"] == "AccumRgT"]
|
|
170
|
+
return found[0] if found else None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def one_c_balance(mssql, pg, reg, mapping, source, period=None):
|
|
174
|
+
"""(totals table, period, {tuple: resources}) - 1C's totals at a period, non-zero only.
|
|
175
|
+
|
|
176
|
+
No period means the table's last one: 5999-11-01, where 1C keeps its CURRENT totals. A
|
|
177
|
+
month row `P` holds the balance at the START of P - measured, not assumed: it equals the
|
|
178
|
+
platform's as-of read at the end of the month before, on every month compared.
|
|
179
|
+
"""
|
|
180
|
+
mapped = mapping.get(reg.module, {}).get(reg.journal_entity)
|
|
181
|
+
if not mapped:
|
|
182
|
+
raise SystemExit("seedstate: %s.%s - the column map has no source for %s"
|
|
183
|
+
% (reg.module, reg.code, reg.journal_entity))
|
|
184
|
+
table = totals_table(mapped["table"])
|
|
185
|
+
if not table:
|
|
186
|
+
raise SystemExit("seedstate: %s has no totals table in DBNames" % mapped["table"])
|
|
187
|
+
schema, name = [p.strip('"') for p in reg.journal.split(".")]
|
|
188
|
+
types = dict((c[0], c[1]) for c in migrate.table_columns(pg, schema, name))
|
|
189
|
+
held = source[mapped["table"]]["columns"]
|
|
190
|
+
transforms = mapped.get("transforms") or {}
|
|
191
|
+
|
|
192
|
+
columns, converters = [], []
|
|
193
|
+
for ours in reg.dimensions + reg.resources:
|
|
194
|
+
physical = mapped["columns"][ours]
|
|
195
|
+
columns.append(physical)
|
|
196
|
+
if ours in transforms:
|
|
197
|
+
converters.append(migrate.TRANSFORMS[transforms[ours]])
|
|
198
|
+
else:
|
|
199
|
+
converters.append(migrate.converter(types[ours], held[physical]["type"]))
|
|
200
|
+
|
|
201
|
+
if period is None:
|
|
202
|
+
mssql.execute("SELECT MAX(_Period) FROM [%s]" % table)
|
|
203
|
+
period = mssql.fetchone()[0]
|
|
204
|
+
mssql.execute("SELECT %s FROM [%s] WHERE _Period = ?"
|
|
205
|
+
% (", ".join("[%s]" % c for c in columns), table), period)
|
|
206
|
+
width = len(reg.dimensions)
|
|
207
|
+
balance = collections.defaultdict(lambda: [decimal.Decimal(0)] * len(reg.resources))
|
|
208
|
+
for row in mssql.fetchall():
|
|
209
|
+
values = [normal(f(v), types[c])
|
|
210
|
+
for f, v, c in zip(converters, row, reg.dimensions + reg.resources)]
|
|
211
|
+
held_now = balance[tuple(values[:width])]
|
|
212
|
+
for i, value in enumerate(values[width:]):
|
|
213
|
+
held_now[i] += decimal.Decimal(value or 0)
|
|
214
|
+
return table, period, dict((k, v) for k, v in balance.items() if any(v))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def normal(value, pg_type):
|
|
218
|
+
"""One value as psycopg2 returns it for a column of that type."""
|
|
219
|
+
if value is None:
|
|
220
|
+
return None
|
|
221
|
+
if pg_type == "date" and isinstance(value, datetime.datetime):
|
|
222
|
+
return value.date()
|
|
223
|
+
if pg_type in ("numeric", "integer", "bigint") and not isinstance(value, bool):
|
|
224
|
+
return decimal.Decimal(str(value))
|
|
225
|
+
return value
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def state_balance(pg, reg):
|
|
229
|
+
cur = pg.cursor()
|
|
230
|
+
cur.execute("SELECT %s, %s FROM %s"
|
|
231
|
+
% (", ".join(q(d) for d in reg.dimensions),
|
|
232
|
+
", ".join(q(f) for f in reg.resources), reg.state))
|
|
233
|
+
width = len(reg.dimensions)
|
|
234
|
+
return dict((tuple(normal(v, None) if not isinstance(v, decimal.Decimal) else v
|
|
235
|
+
for v in row[:width]),
|
|
236
|
+
[decimal.Decimal(v or 0) for v in row[width:]])
|
|
237
|
+
for row in cur.fetchall())
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def as_of_balance(pg, reg, until):
|
|
241
|
+
"""What `getBalance(asOf)` answers for the day before `until`: the state minus every
|
|
242
|
+
journal row dated on or after it (RegisterReader.GetBalance), per tuple, non-zero only."""
|
|
243
|
+
dims = ", ".join(q(d) for d in reg.dimensions)
|
|
244
|
+
cur = pg.cursor()
|
|
245
|
+
cur.execute("""SELECT %s, %s FROM (
|
|
246
|
+
SELECT %s, %s FROM %s
|
|
247
|
+
UNION ALL
|
|
248
|
+
SELECT %s, %s FROM %s WHERE %s >= %%s) u GROUP BY %s""" % (
|
|
249
|
+
dims, ", ".join("SUM(%s)" % q(f) for f in reg.resources),
|
|
250
|
+
dims, ", ".join(q(f) for f in reg.resources), reg.state,
|
|
251
|
+
dims, ", ".join("-%s * %s" % (q(f), q(reg.direction)) for f in reg.resources),
|
|
252
|
+
reg.journal, q(reg.date), dims), (until,))
|
|
253
|
+
width = len(reg.dimensions)
|
|
254
|
+
out = {}
|
|
255
|
+
for row in cur.fetchall():
|
|
256
|
+
values = [decimal.Decimal(v or 0) for v in row[width:]]
|
|
257
|
+
if any(values):
|
|
258
|
+
out[tuple(row[:width])] = values
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def verify(pg, reg, mapping, source, mssql, month=None):
|
|
263
|
+
"""The state against 1C's current totals, or - given a month - the as-of read at its
|
|
264
|
+
end against 1C's totals row for the month after."""
|
|
265
|
+
if month:
|
|
266
|
+
year, number = [int(p) for p in month.split("-")]
|
|
267
|
+
until = datetime.date(year + number // 12, number % 12 + 1, 1)
|
|
268
|
+
period = datetime.datetime(until.year + migrate.YEAR_OFFSET, until.month, 1)
|
|
269
|
+
table, period, theirs = one_c_balance(mssql, pg, reg, mapping, source, period)
|
|
270
|
+
ours = as_of_balance(pg, reg, until)
|
|
271
|
+
else:
|
|
272
|
+
table, period, theirs = one_c_balance(mssql, pg, reg, mapping, source)
|
|
273
|
+
ours = state_balance(pg, reg)
|
|
274
|
+
both = set(theirs) & set(ours)
|
|
275
|
+
differ = [k for k in both if theirs[k] != ours[k]]
|
|
276
|
+
only_1c = set(theirs) - set(ours)
|
|
277
|
+
only_ours = set(ours) - set(theirs)
|
|
278
|
+
print("%-28s %-15s %s 1C %6d state %6d equal %6d differ %d only 1C %d only state %d"
|
|
279
|
+
% ("%s.%s" % (reg.module, reg.code), table, period.strftime("%Y-%m-%d"),
|
|
280
|
+
len(theirs), len(ours), len(both) - len(differ), len(differ),
|
|
281
|
+
len(only_1c), len(only_ours)))
|
|
282
|
+
for k in sorted(differ, key=str)[:5]:
|
|
283
|
+
print(" differ %s 1C %s state %s" % (k, theirs[k], ours[k]))
|
|
284
|
+
for k in sorted(only_1c, key=str)[:5]:
|
|
285
|
+
print(" only 1C %s %s" % (k, theirs[k]))
|
|
286
|
+
for k in sorted(only_ours, key=str)[:5]:
|
|
287
|
+
print(" only us %s %s" % (k, ours[k]))
|
|
288
|
+
return not (differ or only_1c or only_ours)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# --------------------------------------------------------------------------- the platform
|
|
292
|
+
|
|
293
|
+
# `verify` checks the state by a formula written to imitate RegisterReader. `probe` asks
|
|
294
|
+
# RegisterReader itself, through `modules/register-probe` (one batch action calling
|
|
295
|
+
# getBalance per request), and compares each answer with 1C's totals summed client-side.
|
|
296
|
+
|
|
297
|
+
PROBE_MONTHS = ("2019-03", "2021-06", "2023-12", "2025-12", "2026-07", "2026-08")
|
|
298
|
+
PROBE_BATCH = 100
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def month_end(month):
|
|
302
|
+
"""'2026-07' -> (2026-07-31, the 1C totals period holding the balance at that end)."""
|
|
303
|
+
year, number = [int(p) for p in month.split("-")]
|
|
304
|
+
start = datetime.date(year + number // 12, number % 12 + 1, 1)
|
|
305
|
+
return (start - datetime.timedelta(days=1),
|
|
306
|
+
datetime.datetime(start.year + migrate.YEAR_OFFSET, start.month, 1))
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def sample(reg, tuples, size):
|
|
310
|
+
"""Tuples worth asking about, deterministically: the largest, the negative, the ones
|
|
311
|
+
holding a NULL dimension, and an even spread over the rest."""
|
|
312
|
+
ordered = sorted(tuples, key=str)
|
|
313
|
+
picked = []
|
|
314
|
+
|
|
315
|
+
def take(items, n):
|
|
316
|
+
for t in items:
|
|
317
|
+
if n <= 0:
|
|
318
|
+
break
|
|
319
|
+
if t not in picked:
|
|
320
|
+
picked.append(t)
|
|
321
|
+
n -= 1
|
|
322
|
+
|
|
323
|
+
take(sorted(ordered, key=lambda t: -abs(tuples[t][0])), size // 4)
|
|
324
|
+
take([t for t in ordered if any(v < 0 for v in tuples[t])], size // 4)
|
|
325
|
+
take([t for t in ordered if None in t], size // 4)
|
|
326
|
+
step = max(1, len(ordered) // max(1, size - len(picked)))
|
|
327
|
+
take(ordered[::step], size - len(picked))
|
|
328
|
+
return picked
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def jsonable(value):
|
|
332
|
+
if isinstance(value, decimal.Decimal):
|
|
333
|
+
return float(value)
|
|
334
|
+
if isinstance(value, (datetime.date, datetime.datetime)):
|
|
335
|
+
return value.isoformat()
|
|
336
|
+
return value
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def requests_for(reg, picked):
|
|
340
|
+
"""Dimension filters: each full tuple (a NULL dimension left out, since a read cannot
|
|
341
|
+
ask for NULL by equality), the same tuple with each dimension in turn left out, the
|
|
342
|
+
first dimension alone, and nothing at all - the register's grand total."""
|
|
343
|
+
seen, out = set(), []
|
|
344
|
+
|
|
345
|
+
def add(dims):
|
|
346
|
+
key = tuple(sorted((k, str(v)) for k, v in dims.items()))
|
|
347
|
+
if key not in seen:
|
|
348
|
+
seen.add(key)
|
|
349
|
+
out.append(dims)
|
|
350
|
+
|
|
351
|
+
add({})
|
|
352
|
+
for t in picked:
|
|
353
|
+
full = dict((d, v) for d, v in zip(reg.dimensions, t) if v is not None)
|
|
354
|
+
add(full)
|
|
355
|
+
for d in list(full):
|
|
356
|
+
add(dict((k, v) for k, v in full.items() if k != d))
|
|
357
|
+
first = reg.dimensions[0]
|
|
358
|
+
if first in full:
|
|
359
|
+
add({first: full[first]})
|
|
360
|
+
return out
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def expected(reg, theirs, dims):
|
|
364
|
+
"""1C's answer to a dimension filter: every tuple it matches, summed."""
|
|
365
|
+
index = [(reg.dimensions.index(d), v) for d, v in dims.items()]
|
|
366
|
+
total = [decimal.Decimal(0)] * len(reg.resources)
|
|
367
|
+
for t, values in theirs.items():
|
|
368
|
+
if all(t[i] == v for i, v in index):
|
|
369
|
+
total = [a + b for a, b in zip(total, values)]
|
|
370
|
+
return total
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def probe(pg, reg, mapping, source, mssql, size, months):
|
|
374
|
+
import posting
|
|
375
|
+
cur = pg.cursor()
|
|
376
|
+
cur.execute("""SELECT a.action_id FROM "dForge".action a
|
|
377
|
+
JOIN "dForge".module m ON m.module_id = a.module_id
|
|
378
|
+
WHERE m.module_cd = 'register-probe' AND a.action_cd = 'balances'""")
|
|
379
|
+
found = cur.fetchone()
|
|
380
|
+
if not found:
|
|
381
|
+
raise SystemExit("seedstate probe: install modules/register-probe first")
|
|
382
|
+
action = found[0]
|
|
383
|
+
cur.execute("""SELECT f.folder_id FROM "dForge".folder f
|
|
384
|
+
JOIN "dForge".module m ON m.module_id = f.module_id
|
|
385
|
+
WHERE m.module_cd = 'production' ORDER BY 1 LIMIT 1""")
|
|
386
|
+
folder = str(cur.fetchone()[0])
|
|
387
|
+
# Batch mode runs only over records, so one movement is handed in and not touched.
|
|
388
|
+
cur.execute("SELECT output_shop_id::text FROM production.output_shop LIMIT 1")
|
|
389
|
+
record = {"output_shop_id": cur.fetchone()[0]}
|
|
390
|
+
jwt = posting.token()
|
|
391
|
+
|
|
392
|
+
dates = [(None, None)] + [month_end(m) for m in months]
|
|
393
|
+
theirs = {}
|
|
394
|
+
for as_of, period in dates:
|
|
395
|
+
theirs[as_of] = one_c_balance(mssql, pg, reg, mapping, source, period)[2]
|
|
396
|
+
pool = {}
|
|
397
|
+
for got in theirs.values():
|
|
398
|
+
for t, v in got.items():
|
|
399
|
+
pool.setdefault(t, v)
|
|
400
|
+
asked = requests_for(reg, sample(reg, pool, size))
|
|
401
|
+
|
|
402
|
+
register = "%s.%s" % (reg.module, reg.code)
|
|
403
|
+
compared = equal = nonzero = 0
|
|
404
|
+
failures = []
|
|
405
|
+
for as_of, _ in dates:
|
|
406
|
+
batch = [{"register": register, "dims": dict((k, jsonable(v)) for k, v in d.items()),
|
|
407
|
+
"asOf": as_of.isoformat() if as_of else None} for d in asked]
|
|
408
|
+
answers = []
|
|
409
|
+
for i in range(0, len(batch), PROBE_BATCH):
|
|
410
|
+
reply = posting.call("action.execute", {
|
|
411
|
+
"actionId": action, "entity": "production.output_shop", "keys": [record],
|
|
412
|
+
"params": {"requests": json.dumps(batch[i:i + PROBE_BATCH])}}, folder, jwt)
|
|
413
|
+
messages = (reply.get("result") or {}).get("messages") or []
|
|
414
|
+
if len(messages) != 1 or messages[0].get("level") != "info":
|
|
415
|
+
raise SystemExit("seedstate probe: %s" % json.dumps(reply)[:2000])
|
|
416
|
+
answers.extend(json.loads(messages[0]["text"]))
|
|
417
|
+
for dims, answer in zip(asked, answers):
|
|
418
|
+
compared += 1
|
|
419
|
+
want = expected(reg, theirs[as_of], dims)
|
|
420
|
+
nonzero += any(want)
|
|
421
|
+
if "error" in answer:
|
|
422
|
+
failures.append((as_of, dims, want, answer["error"]))
|
|
423
|
+
continue
|
|
424
|
+
got = [decimal.Decimal(str(answer["ok"].get(n, 0))) for n in reg.resource_names]
|
|
425
|
+
# The action hands numbers back through JS doubles; compare to their precision.
|
|
426
|
+
if all(abs(g - w) <= abs(w) * decimal.Decimal("1e-12") + decimal.Decimal("1e-6")
|
|
427
|
+
for g, w in zip(got, want)):
|
|
428
|
+
equal += 1
|
|
429
|
+
else:
|
|
430
|
+
failures.append((as_of, dims, want, got))
|
|
431
|
+
# 1C's empty date is held as 0001-01-01 (`since_beginning`), and the DSL turns that
|
|
432
|
+
# string into DateTime.MinValue, which Npgsql binds as -infinity: such a tuple reads as
|
|
433
|
+
# zero. The platform's defect, not the seed's - counted apart so it cannot hide another.
|
|
434
|
+
empty_date = [f for f in failures if datetime.date(1, 1, 1) in f[1].values()]
|
|
435
|
+
print("%-28s %3d filters x %d dates %5d compared (%d non-zero) %5d equal %d differ%s"
|
|
436
|
+
% (register, len(asked), len(dates), compared, nonzero, equal, len(failures),
|
|
437
|
+
" (%d on a 0001-01-01 dimension)" % len(empty_date) if empty_date else ""))
|
|
438
|
+
for as_of, dims, want, got in [f for f in failures if f not in empty_date][:10]:
|
|
439
|
+
print(" %s %s 1C %s platform %s" % (as_of or "now", dims, want, got))
|
|
440
|
+
return not failures
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def main():
|
|
444
|
+
migrate.load_env()
|
|
445
|
+
ap = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
|
|
446
|
+
ap.add_argument("command", choices=("run", "check", "verify", "probe"))
|
|
447
|
+
ap.add_argument("module", nargs="*", help="limit to these modules")
|
|
448
|
+
ap.add_argument("--asof", metavar="YYYY-MM",
|
|
449
|
+
help="verify: the as-of read at the end of this month instead of now")
|
|
450
|
+
ap.add_argument("--sample", type=int, default=24, metavar="N",
|
|
451
|
+
help="probe: tuples per register (each also asked with dimensions left out)")
|
|
452
|
+
args = ap.parse_args()
|
|
453
|
+
|
|
454
|
+
pg = migrate.pg_connect()
|
|
455
|
+
found = registers(pg.cursor(), set(args.module))
|
|
456
|
+
if not found:
|
|
457
|
+
print("no journalled balance register%s" % (" in " + ", ".join(args.module)
|
|
458
|
+
if args.module else ""))
|
|
459
|
+
return 0
|
|
460
|
+
clean = True
|
|
461
|
+
if args.command == "run":
|
|
462
|
+
print("%-28s %10s %8s %s" % ("register", "journal", "state", ""))
|
|
463
|
+
for reg in found:
|
|
464
|
+
rows, written, undated = seed(pg, reg)
|
|
465
|
+
print("%-28s %10d %8d %s" % ("%s.%s" % (reg.module, reg.code), rows, written,
|
|
466
|
+
"%d undated" % undated if undated else ""))
|
|
467
|
+
elif args.command == "check":
|
|
468
|
+
cur = pg.cursor()
|
|
469
|
+
for reg in found:
|
|
470
|
+
bad, undated, rows = unstamped(cur, reg)
|
|
471
|
+
off = drift(cur, reg)
|
|
472
|
+
clean = clean and not (bad or off)
|
|
473
|
+
print("%-28s journal %8d unstamped %d undated %d state off by %d tuple(s)"
|
|
474
|
+
% ("%s.%s" % (reg.module, reg.code), rows, bad, undated, off))
|
|
475
|
+
else:
|
|
476
|
+
mapping, source = migrate.column_map(), migrate.source_schema()
|
|
477
|
+
mssql = migrate.mssql_connect().cursor()
|
|
478
|
+
for reg in found:
|
|
479
|
+
if args.command == "probe":
|
|
480
|
+
clean = probe(pg, reg, mapping, source, mssql, args.sample,
|
|
481
|
+
[args.asof] if args.asof else PROBE_MONTHS) and clean
|
|
482
|
+
else:
|
|
483
|
+
clean = verify(pg, reg, mapping, source, mssql, args.asof) and clean
|
|
484
|
+
return 0 if clean else 1
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
if __name__ == "__main__":
|
|
488
|
+
sys.exit(main())
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
How big is the rest of the port?
|
|
5
|
+
|
|
6
|
+
Counts fields, tabular sections and references across the client-specific objects that
|
|
7
|
+
hold data, so the remaining effort can be estimated from the same numbers the transport
|
|
8
|
+
contour was estimated from rather than guessed at.
|
|
9
|
+
|
|
10
|
+
python tools/sizing.py
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import print_function
|
|
13
|
+
|
|
14
|
+
import codecs
|
|
15
|
+
import collections
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
import attrs
|
|
21
|
+
import binding
|
|
22
|
+
|
|
23
|
+
contours = binding.contours()
|
|
24
|
+
import unpack
|
|
25
|
+
|
|
26
|
+
LOWER_PREFIX = re.compile(r"^([a-zа-яёіїє]{2,6})(?=[A-ZА-Я])")
|
|
27
|
+
|
|
28
|
+
# The data model is ported for every object in scope, so "done" no longer means the
|
|
29
|
+
# object - it means its logic. `contours.DONE` is the set whose logic was looked at while
|
|
30
|
+
# the transport contour was designed.
|
|
31
|
+
DONE = contours.DONE
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_INDEX = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def code_index():
|
|
38
|
+
"""out/code/index.json, written once by extract_code.py. Falls back to unpacking on
|
|
39
|
+
demand if the code has not been extracted yet."""
|
|
40
|
+
global _INDEX
|
|
41
|
+
if _INDEX is None:
|
|
42
|
+
path = os.path.join(attrs.OUT, "code", "index.json")
|
|
43
|
+
if os.path.isfile(path):
|
|
44
|
+
with codecs.open(path, "r", "utf-8") as f:
|
|
45
|
+
_INDEX = json.load(f)
|
|
46
|
+
else:
|
|
47
|
+
_INDEX = []
|
|
48
|
+
return _INDEX
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def code_lines(guid):
|
|
52
|
+
"""Non-blank, non-comment lines of the object's own modules (not its forms)."""
|
|
53
|
+
index = code_index()
|
|
54
|
+
if index:
|
|
55
|
+
return sum(r["lines"] for r in index
|
|
56
|
+
if r.get("guid") == guid and r.get("entry") == "text")
|
|
57
|
+
total = 0
|
|
58
|
+
for _, blob in unpack.containers_of(guid):
|
|
59
|
+
for name, content in unpack.entries(blob):
|
|
60
|
+
if name != "text":
|
|
61
|
+
continue
|
|
62
|
+
text = unpack.as_text(content) or ""
|
|
63
|
+
total += sum(1 for line in text.splitlines()
|
|
64
|
+
if line.strip() and not line.strip().startswith("//"))
|
|
65
|
+
return total
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def code_breakdown():
|
|
69
|
+
"""Whose code is in the infobase, and is it business logic or UI."""
|
|
70
|
+
index = code_index()
|
|
71
|
+
if not index:
|
|
72
|
+
return
|
|
73
|
+
owner = collections.Counter()
|
|
74
|
+
kinds = collections.Counter()
|
|
75
|
+
for r in index:
|
|
76
|
+
if "lines" not in r:
|
|
77
|
+
continue
|
|
78
|
+
m = LOWER_PREFIX.match(r["object"] or "")
|
|
79
|
+
tag = m.group(1) if m else None
|
|
80
|
+
if tag in binding.client_prefixes():
|
|
81
|
+
bucket = "client"
|
|
82
|
+
kinds["UI (form modules)" if r["entry"] == "module"
|
|
83
|
+
else "business logic"] += r["lines"]
|
|
84
|
+
elif tag in binding.toolkit_prefixes():
|
|
85
|
+
bucket = "third-party dev toolkit"
|
|
86
|
+
else:
|
|
87
|
+
bucket = "vendor (UTP)"
|
|
88
|
+
owner[bucket] += r["lines"]
|
|
89
|
+
|
|
90
|
+
print("\nextracted code, by owner")
|
|
91
|
+
print("-" * 46)
|
|
92
|
+
for bucket, n in owner.most_common():
|
|
93
|
+
print(" %-26s %9d lines" % (bucket, n))
|
|
94
|
+
print("\nthe client's own code splits into")
|
|
95
|
+
print("-" * 46)
|
|
96
|
+
for kind, n in kinds.most_common():
|
|
97
|
+
print(" %-26s %9d lines" % (kind, n))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main():
|
|
101
|
+
objects = attrs.load_json("objects.json")
|
|
102
|
+
types = attrs.load_json("typemap.json")
|
|
103
|
+
rows = attrs.load_json("report.json")
|
|
104
|
+
by_name = {o["name"]: o for o in objects if o.get("name")}
|
|
105
|
+
|
|
106
|
+
def is_custom(name):
|
|
107
|
+
m = LOWER_PREFIX.match(name or "")
|
|
108
|
+
return bool(m and m.group(1) in binding.client_prefixes() + binding.toolkit_prefixes())
|
|
109
|
+
|
|
110
|
+
scope = [r for r in rows
|
|
111
|
+
if is_custom(r["name"]) and ((r["rows"] or 0) or r["childRows"])]
|
|
112
|
+
|
|
113
|
+
totals = collections.defaultdict(
|
|
114
|
+
lambda: {"objects": 0, "fields": 0, "sections": 0, "refs": 0, "done": 0,
|
|
115
|
+
"code": 0})
|
|
116
|
+
grand = {"fields": 0, "sections": 0, "refs": 0, "remaining_fields": 0,
|
|
117
|
+
"code": 0, "remaining_code": 0}
|
|
118
|
+
|
|
119
|
+
for row in scope:
|
|
120
|
+
name = row["name"]
|
|
121
|
+
obj = by_name.get(name)
|
|
122
|
+
if not obj:
|
|
123
|
+
continue
|
|
124
|
+
_, fields = attrs.fields_of(name, objects, types)
|
|
125
|
+
fields = fields or []
|
|
126
|
+
sections = len({f["section"] for f in fields if f["section"]})
|
|
127
|
+
refs = sum(1 for f in fields if "->" in f["type"])
|
|
128
|
+
code = code_lines(obj["guid"])
|
|
129
|
+
bucket = totals[row["family"]]
|
|
130
|
+
bucket["objects"] += 1
|
|
131
|
+
bucket["fields"] += len(fields)
|
|
132
|
+
bucket["sections"] += sections
|
|
133
|
+
bucket["refs"] += refs
|
|
134
|
+
bucket["code"] += code
|
|
135
|
+
grand["fields"] += len(fields)
|
|
136
|
+
grand["sections"] += sections
|
|
137
|
+
grand["refs"] += refs
|
|
138
|
+
grand["code"] += code
|
|
139
|
+
if name in DONE:
|
|
140
|
+
bucket["done"] += 1
|
|
141
|
+
else:
|
|
142
|
+
grand["remaining_fields"] += len(fields)
|
|
143
|
+
grand["remaining_code"] += code
|
|
144
|
+
|
|
145
|
+
print("%-12s %8s %6s %8s %9s %6s %9s" %
|
|
146
|
+
("family", "objects", "done", "fields", "sections", "refs", "code loc"))
|
|
147
|
+
print("-" * 68)
|
|
148
|
+
for family, b in sorted(totals.items(), key=lambda kv: -kv[1]["fields"]):
|
|
149
|
+
print("%-12s %8d %6d %8d %9d %6d %9d"
|
|
150
|
+
% (family, b["objects"], b["done"], b["fields"], b["sections"],
|
|
151
|
+
b["refs"], b["code"]))
|
|
152
|
+
print("-" * 68)
|
|
153
|
+
print("%-12s %8d %6d %8d %9d %6d %9d"
|
|
154
|
+
% ("total", sum(b["objects"] for b in totals.values()),
|
|
155
|
+
sum(b["done"] for b in totals.values()),
|
|
156
|
+
grand["fields"], grand["sections"], grand["refs"], grand["code"]))
|
|
157
|
+
print("\nthe data model is generated for all of these; what remains is the logic:")
|
|
158
|
+
print("%d lines of 1C object-module code outside the contour already studied"
|
|
159
|
+
% grand["remaining_code"])
|
|
160
|
+
by_module()
|
|
161
|
+
code_breakdown()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def by_module():
|
|
165
|
+
"""How much 1C business logic sits behind each generated dForge module."""
|
|
166
|
+
objects = attrs.load_json("objects.json")
|
|
167
|
+
guid_of = {}
|
|
168
|
+
for obj in objects:
|
|
169
|
+
if obj.get("name"):
|
|
170
|
+
guid_of.setdefault(obj["name"], obj["guid"])
|
|
171
|
+
|
|
172
|
+
print("\n1C object-module code, by the module it was assigned to")
|
|
173
|
+
print("-" * 46)
|
|
174
|
+
totals = []
|
|
175
|
+
for module, contour in contours.CONTOURS.items():
|
|
176
|
+
lines = sum(code_lines(guid_of[name])
|
|
177
|
+
for name in contours.names(contour) if name in guid_of)
|
|
178
|
+
totals.append((module, lines))
|
|
179
|
+
for module, lines in sorted(totals, key=lambda kv: -kv[1]):
|
|
180
|
+
print(" %-26s %9d lines" % (module, lines))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|