@dforge-core/dforge-mcp 0.2.44 → 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.
Files changed (89) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +3 -2
  3. package/dist/server.js +109 -84
  4. package/package.json +48 -46
  5. package/resources/docs/conventions.md +17 -14
  6. package/resources/docs/dsl-reference.md +15 -5
  7. package/resources/schemas/entity.schema.json +1 -1
  8. package/resources/schemas/registers.schema.json +8 -3
  9. package/scripts/install-skills.mjs +1 -0
  10. package/skills/dforge-1c-import/SKILL.md +374 -0
  11. package/skills/dforge-1c-import/references/environment.md +97 -0
  12. package/skills/dforge-1c-import/references/loading.md +161 -0
  13. package/skills/dforge-1c-import/references/measurements.md +210 -0
  14. package/skills/dforge-1c-import/references/print-forms.md +148 -0
  15. package/skills/dforge-1c-import/scripts/1c.py +178 -0
  16. package/skills/dforge-1c-import/scripts/README.md +110 -0
  17. package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
  18. package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
  19. package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
  20. package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
  21. package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
  22. package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
  23. package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
  24. package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
  25. package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
  26. package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
  27. package/skills/dforge-1c-import/scripts/lib/delta.py +741 -0
  28. package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
  29. package/skills/dforge-1c-import/scripts/lib/docusage.py +105 -0
  30. package/skills/dforge-1c-import/scripts/lib/dump1c.py +251 -0
  31. package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
  32. package/skills/dforge-1c-import/scripts/lib/external.py +201 -0
  33. package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
  34. package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
  35. package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
  36. package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
  37. package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
  38. package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
  39. package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
  40. package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
  41. package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
  42. package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
  43. package/skills/dforge-1c-import/scripts/lib/meta.py +315 -0
  44. package/skills/dforge-1c-import/scripts/lib/migrate.py +1299 -0
  45. package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
  46. package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
  47. package/skills/dforge-1c-import/scripts/lib/paths.py +90 -0
  48. package/skills/dforge-1c-import/scripts/lib/peek.py +70 -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 +94 -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 +488 -0
  59. package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
  60. package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
  61. package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
  62. package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
  63. package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
  64. package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
  65. package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
  66. package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
  67. package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
  68. package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
  69. package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
  70. package/skills/dforge-1c-import/scripts/project-template/sources.py +14 -0
  71. package/skills/dforge-mcp-author/SKILL.md +8 -2
  72. package/skills/dforge-mcp-author/examples/column-security/entities/product.json +1 -1
  73. package/skills/dforge-mcp-author/examples/column-security/manifest.json +0 -1
  74. package/skills/dforge-mcp-author/examples/column-security/security/roles.json +2 -2
  75. package/skills/dforge-mcp-author/references/action-dsl.md +20 -7
  76. package/skills/dforge-mcp-author/references/conventions.md +7 -1
  77. package/skills/dforge-mcp-author/references/field-types.md +3 -2
  78. package/skills/dforge-mcp-author/references/formulas.md +5 -1
  79. package/skills/dforge-mcp-author/references/print-templates.md +5 -2
  80. package/skills/dforge-mcp-author/references/queries.md +1 -1
  81. package/skills/dforge-mcp-author/references/registers.md +18 -3
  82. package/skills/dforge-mcp-author/references/reports.md +4 -1
  83. package/skills/dforge-mcp-author/references/schema-import.md +1 -1
  84. package/skills/dforge-mcp-author/references/security.md +1 -1
  85. package/skills/dforge-mcp-author/references/translations.md +38 -15
  86. package/skills/dforge-mcp-author/references/validation-checklist.md +2 -3
  87. package/skills/dforge-module-build/SKILL.md +10 -1
  88. package/skills/dforge-module-ship/SKILL.md +31 -3
  89. package/skills/dforge-mcp-author/examples/column-security/translations/en-US.json +0 -35
@@ -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()