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