@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,774 @@
1
+ # -*- coding: utf-8 -*-
2
+ u"""Read the regulated statements the project declares, and write the module's seed.
3
+
4
+ This is the part of reading a 1C regulated form that every form shares.
5
+
6
+ Three forms in, the per-form scripts had become the same five passes with different constants,
7
+ so the passes live here and a script says only what is particular about its form. What is
8
+ particular turns out to be small: which module, which helpers that module defines, what its
9
+ `2`-suffixed helpers mean, which branches of it are dead, and whatever it does that no regex
10
+ should be asked to read.
11
+
12
+ THE PASSES, in the order they run.
13
+
14
+ 0. The dead branches. A form that offers two accounting policies writes both, and taking both
15
+ double-counts. The live one is decided by measurement, never by preference, and the other
16
+ is cut out of the source text before anything reads it.
17
+
18
+ 1. `П<line><col> = <signed sum of helper calls>` - the bulk of every form.
19
+
20
+ 2. Whatever the script does by hand, given the source text with the dead branches gone.
21
+
22
+ 3. `<object>.Области.П<line><col>.Значение = <signed sum of other lines>` - the running
23
+ totals. Reading only shape 1 once cost a day of believing the Balance's own totals lived in
24
+ a binary макет and were unreachable, so this pass is not optional for any form.
25
+
26
+ 4. The checks: every account named exists in the chart, every line referenced is computed by
27
+ something, every indicator the form sets is covered, and every `2` suffix agrees with the
28
+ column it lands in. None of them fix anything; they say whether the reading is finished.
29
+
30
+ WHY THE `2` SUFFIX IS NOT ONE WINDOW. Each of these forms opens a second totals object over
31
+ the previous period, and what "the previous period" means differs BY FORM:
32
+
33
+ Звіт про фінансові результати НачалоГода(start-12) .. (end-12) the comparable period
34
+ Звіт про рух грошових коштів НачалоГода(start-12) .. КонецГода() the whole prior year
35
+
36
+ Same number for an annual report, different for an interim one. So a script declares what its
37
+ suffix means and the engine holds both windows; nothing here guesses from the column.
38
+ """
39
+ from __future__ import print_function
40
+ import collections
41
+ import io
42
+ import json
43
+ import os
44
+ import re
45
+ import sys
46
+
47
+ import migrate
48
+ import paths
49
+
50
+ # `Счет70_9_1 = ПС.Предопределённый` - the underscores are the subaccount separators, so the
51
+ # code is what is left once they go, and the predefined name is never needed. Some modules
52
+ # write the alias and some the full path, so both are read.
53
+ # The leading С may be the LATIN one. That is not defensive programming, it is a typo in the
54
+ # vendor's own source: Примітки declares `Cчет98 = ПС.НалогНаПрибыль` with U+0043 and uses it
55
+ # the same way two lines later, so 1C is perfectly happy and only a reader that insists on
56
+ # Cyrillic notices. Reading it strictly and then widening beats reading it loosely: the strict
57
+ # pass is what said which account was missing.
58
+ ACCOUNT = re.compile(u'(?m)^\\s*([СC]чет[0-9_]*)\\s*=\\s*(?:ПС|ПланыСчетов\\.Хозрасчетный)'
59
+ u'\\.([A-Za-zА-Яа-я0-9_]+)\\s*;')
60
+
61
+ def _patterns(line=u"[0-9]{4}", column=u"[34]", sep=u""):
62
+ u"""The four indicator shapes, built from how a form spells an indicator.
63
+
64
+ An indicator is `П<line><column>`, and neither half can be assumed:
65
+
66
+ Баланс, Ф2, Ф3, Ф4 `П10013` four digits and one, run together
67
+ Примітки `П010_14` three or four digits, an underscore, one or two
68
+
69
+ Nor can the columns. The first three forms print two - the reporting period and a
70
+ comparison - Звіт про власний капітал prints seven components and a total, and Примітки
71
+ prints up to fifteen. So each part is a regex fragment the form supplies, and the defaults
72
+ reproduce exactly what was fixed before.
73
+
74
+ Without a separator the two halves are told apart only by the line's width being fixed,
75
+ which is why in that shape `line` and `column` cannot both be open-ended.
76
+ """
77
+ pair = u"(%s)%s(%s)" % (line, re.escape(sep), column)
78
+ # What may NOT follow an indicator. Another digit never may, or a two-column form would
79
+ # read column 1 of a fifteen-column one. With a separator, a further separator may not
80
+ # either: Примітки computes `П1242_3` out of `П1242_3_1 .. _4`, and those four are
81
+ # intermediate variables, not references to line 1242 column 3.
82
+ tail = u"(?![0-9_])" if sep else u"(?![0-9])"
83
+ return {
84
+ "assign": re.compile(u'(?m)^\s*П%s\s*=\s*([^;]+);' % pair),
85
+ # A word boundary after the digits matches nothing once the token carries a Cyrillic
86
+ # prefix, which had a self-reference scan silently finding none of ten. So what is
87
+ # meant is said outright.
88
+ "self": re.compile(u'^\s*П%s%s' % (pair, tail)),
89
+ "line_ref": re.compile(u'([+-]?)\s*П%s%s' % (pair, tail)),
90
+ "derived": re.compile(u'Области\.П%s\.Значение\s*=\s*([^;]+);' % pair),
91
+ "derived_term": re.compile(u'([+-]?)\s*(?:ОкруглитьЧислоПоФормату\s*\(\s*)?'
92
+ # the object's name may carry a digit - Примітки keeps one
93
+ # totals object per SECTION, `мРаздел1`..`мРаздел15`
94
+ u'[А-Яа-яA-Za-z0-9_]*\.Области\.П%s\.Значение' % pair),
95
+ }
96
+
97
+
98
+ COMMENT = re.compile(u"(?m)^[ \t]*//.*$")
99
+
100
+
101
+ def uncommented(source):
102
+ u"""The module with its commented-out CODE gone, which is not tidiness.
103
+
104
+ An assignment pattern is anchored at the start of a statement, so a `//` in front of one
105
+ already hides it. Nothing else here is anchored: a standalone call, a running total written
106
+ as `Области.П…Значение = …`, a term in a sum - each is found wherever it appears, and a
107
+ previous version of it left in a comment is found just as well.
108
+
109
+ Звіт про власний капітал keeps the reading it used before 2013 commented out two lines
110
+ above the live one, both filling line 4100 - and that pair was skipped only because the
111
+ live assignment had already claimed the indicator. Had the old version filled a line the
112
+ new one does not, it would have been read as current.
113
+
114
+ Only a comment that starts its own line goes. A trailing `//` may follow real code, and
115
+ cutting at one would need to know whether it sits inside a string literal.
116
+ """
117
+ return COMMENT.sub(u"", source)
118
+
119
+
120
+ _DEFAULT = _patterns()
121
+ ASSIGN = _DEFAULT["assign"]
122
+ SELF = _DEFAULT["self"]
123
+ LINE_REF = _DEFAULT["line_ref"]
124
+ DERIVED = _DEFAULT["derived"]
125
+ DERIVED_TERM = _DEFAULT["derived_term"]
126
+ # The name as the FORM spells it, separator and all - `П10013` or `П070_3`. Truncating at the
127
+ # separator made Примітки look as though it set 25 indicators when it sets 150, and left every
128
+ # one of them looking uncovered.
129
+ INDICATOR = re.compile(u'УстановитьЗначениеПоказателя\\(\\s*"П([0-9][0-9_]*)"')
130
+
131
+ # The helpers these forms define, and what each reads. A form defines the subset it needs.
132
+ MEASURE = {
133
+ u"ДО": ("turnover", "debit"),
134
+ u"КО": ("turnover", "credit"),
135
+ u"ОБ": ("turnover", "correspondence"),
136
+ u"СНД": ("opening", "debit"),
137
+ u"СНК": ("opening", "credit"),
138
+ u"СКД": ("closing", "debit"),
139
+ u"СКК": ("closing", "credit"),
140
+ }
141
+ HELPERS = "|".join(sorted(MEASURE, key=len, reverse=True))
142
+ TERM = re.compile(u'([+-]?)\\s*(' + HELPERS + u')(2?)\\s*\\('
143
+ u'\\s*([A-Za-zА-Яа-я0-9_]+)\\s*'
144
+ u'((?:,\\s*[A-Za-zА-Яа-я0-9_]+\\s*)*)\\)')
145
+
146
+
147
+ class Form(object):
148
+ u"""One 1C regulated form, being read."""
149
+
150
+ def __init__(self, statement, module, title, description, key_base,
151
+ prior_turnover="turnover_prior", by_hand=(), configured=None,
152
+ line=u"[0-9]{4}", column=u"[34]", sep=u"", comparative=u"4"):
153
+ self.statement = statement
154
+ self.module = module
155
+ # where this form's seeded keys start; the project picks, so two forms never collide
156
+ self.key_base = key_base
157
+ self.title = title
158
+ self.description = description
159
+ # what this form's `2`-suffixed TURNOVER helpers mean; the balance ones are always
160
+ # the same period a year earlier, which is `opening_prior` either way
161
+ self.prior_turnover = prior_turnover
162
+ self.by_hand = frozenset(by_hand) # lines pass 1 must leave to the script
163
+ # How this form spells an indicator, and which of its columns compares against another
164
+ # period. Звіт про власний капітал has seven columns and NONE of them is a second
165
+ # period - they are the components of equity - so `comparative=None` turns off the
166
+ # check that pairs a `2`-suffixed helper with a column, there being nothing to pair.
167
+ self.comparative = comparative
168
+ self.sep = sep
169
+ self.pattern = _patterns(line, column, sep)
170
+ self.rules = []
171
+ self.problems = []
172
+ self.unparsed = []
173
+ self.counts = collections.OrderedDict()
174
+ self._cut = (0, 0)
175
+ self.src = io.open(module, encoding="utf-8", errors="replace").read()
176
+ self.live = uncommented(self.src)
177
+ # Every indicator the form is known to set, which the coverage check measures against.
178
+ # Most are set by name; a form that fills some of them from inside a helper adds those
179
+ # as it reads them, because nothing in the source text names them.
180
+ self.wanted = set(INDICATOR.findall(self.live))
181
+ self.accounts = dict(
182
+ (name, name[4:].replace("_", "")) for name, _ in ACCOUNT.findall(self.src))
183
+ if configured:
184
+ # accounts the module reads from a settings register rather than naming, so
185
+ # neither the code nor a regex can say what they are; a script measures them.
186
+ self.accounts.update(configured)
187
+
188
+ # --------------------------------------------------------------- pass 0: the dead branch
189
+ def cut(self, condition, keyword=u"Иначе", must_contain=None, dead=u"else", indent=None):
190
+ u"""Remove one half of every `Если <condition> Тогда … <keyword> … КонецЕсли`.
191
+
192
+ The opening `Если`, its `<keyword>` and its `КонецЕсли` all sit at the SAME
193
+ indentation, which is what makes a text search safe here: everything nested inside is
194
+ deeper, so the first keyword at that indentation after an opening is its own.
195
+
196
+ `dead` which half goes. `"else"` for a form whose live branch is the first - the
197
+ accounting-policy blocks - and `"then"` for one whose live branch is the
198
+ second, which is how a form written for several reporting years keeps the
199
+ old numbering in the `Тогда` and the current one in the `Иначе`.
200
+ `indent` only cut occurrences at this indentation. One condition can appear in two
201
+ places that mean different things - `Если ГодОтчета <= 2013` chooses between
202
+ two sets of balance line numbers in one place and between two filing schema
203
+ names in another - and the nesting is what tells them apart.
204
+
205
+ Cut back to front so positions stay valid while the text shrinks.
206
+ """
207
+ opens = []
208
+ for match in re.finditer(re.escape(condition), self.live):
209
+ bol = self.live.rfind(u"\n", 0, match.start()) + 1
210
+ found = self.live[bol:match.start()]
211
+ if found.strip(): # not the start of a statement
212
+ continue
213
+ if indent is not None and found != indent:
214
+ continue
215
+ opens.append((match.start(), found))
216
+ if not opens:
217
+ raise AssertionError("%s: the condition %r is not in this module%s"
218
+ % (self.statement, condition,
219
+ " at that indentation" if indent is not None else ""))
220
+ lines = 0
221
+ for open_at, found in reversed(opens):
222
+ else_at = self.live.index(u"\n" + found + keyword, open_at)
223
+ self.live.index(u"\n" + found + u"КонецЕсли", else_at) # it has to be there
224
+ if dead == u"else":
225
+ lo, hi = else_at, self.live.index(u"\n" + found + u"КонецЕсли", else_at)
226
+ else:
227
+ lo, hi = self.live.index(u"\n", open_at), else_at
228
+ text = self.live[lo:hi]
229
+ if must_contain and not re.search(must_contain, text):
230
+ raise AssertionError("%s: the branch cut at %d does not look like the dead one"
231
+ % (self.statement, open_at))
232
+ lines += text.count(u"\n")
233
+ self.live = self.live[:lo] + self.live[hi:]
234
+ # a form may cut more than once, so the count is cumulative
235
+ self._cut = (self._cut[0] + len(opens), self._cut[1] + lines)
236
+ self.counts["dead branches cut"] = "%d, %d source lines" % self._cut
237
+ return self
238
+
239
+ # ------------------------------------------------------------------------ adding a rule
240
+ def add(self, line, column, sign, when, side, grain, scope, caption=u""):
241
+ self.rules.append(collections.OrderedDict([
242
+ ("line_no", line), ("column", column), ("caption", caption),
243
+ ("sign", sign), ("when", when), ("side", side), ("grain", grain),
244
+ ("scope", scope),
245
+ ]))
246
+
247
+ def line_scope(self, ref_line, ref_column, column):
248
+ u"""A reference to another cell: a line, and its column when that is not this one.
249
+
250
+ A reference used to be to a line of the SAME column, and one to another column was
251
+ reported as a disagreement, because nothing could represent it. Примітки has 35 of
252
+ them and they are all the same thing - a ROW total, `П710_9 = П710_3 + .. + П710_7` -
253
+ which is what Звіт про власний капітал needed for its column 10 and what the engine
254
+ now carries. So the column is named when it differs and left out when it does not,
255
+ and leaving it out is what the first three forms' 390 references all do.
256
+ """
257
+ scope = collections.OrderedDict([("lines", [ref_line])])
258
+ if ref_column != column:
259
+ scope["column"] = ref_column
260
+ return scope
261
+
262
+ def account_scope(self, debit, credit=None):
263
+ scope = collections.OrderedDict([("include", [debit])])
264
+ if credit is not None:
265
+ scope["includeCredit"] = [credit]
266
+ return scope
267
+
268
+ def window(self, measure, prior):
269
+ read_at, side = MEASURE[measure]
270
+ if not prior:
271
+ return read_at, side
272
+ return (self.prior_turnover if read_at == "turnover" else read_at + "_prior"), side
273
+
274
+ # --------------------------------------------------- pass 1: the signed sums of helpers
275
+ def read_assignments(self, accumulate=False, line_refs=False, extra=None):
276
+ u"""`П<line><col> = …`, with the options a form needs.
277
+
278
+ `accumulate` a second assignment may begin with the indicator itself, which is more
279
+ terms for the same line rather than a new shape.
280
+ `line_refs` the expression may also name other indicators, so a line reads accounts
281
+ AND lines at once; the engine adds the line part to the account base.
282
+ `extra` (regex, handler) for whatever else a form puts in these expressions.
283
+ """
284
+ n_before = len(self.rules)
285
+ accumulated = 0
286
+ for line, column, expr in self.pattern["assign"].findall(self.live):
287
+ if line in self.by_hand:
288
+ continue
289
+ body = expr
290
+
291
+ if accumulate:
292
+ me = self.pattern["self"].match(body)
293
+ if me:
294
+ if (me.group(1), me.group(2)) != (line, column):
295
+ # Not an accumulation - an expression that happens to OPEN with
296
+ # another cell. `П775_4 = П491_4` is a cell that copies one, and it is
297
+ # a line reference like any other, so it is left for that pass. Only a
298
+ # form that does not read line references at all can call this wrong.
299
+ if not line_refs:
300
+ self.problems.append(
301
+ "line %s column %s starts from П%s%s, a different indicator"
302
+ % (line, column, me.group(1), me.group(2)))
303
+ continue
304
+ else:
305
+ body = body[me.end():]
306
+ accumulated += 1
307
+
308
+ if line_refs:
309
+ for sign, ref_line, ref_column in self.pattern["line_ref"].findall(body):
310
+ self.add(line, column, -1 if sign == "-" else 1, "derived", "none",
311
+ "line", self.line_scope(ref_line, ref_column, column))
312
+ body = self.pattern["line_ref"].sub("", body)
313
+
314
+ if extra is not None:
315
+ pattern, handler = extra
316
+ for match in pattern.finditer(body):
317
+ handler(self, line, column, match)
318
+ body = pattern.sub("", body)
319
+
320
+ self.read_terms(line, column, body, whole=expr)
321
+
322
+ self.counts["account reads"] = len(self.rules) - n_before
323
+ if accumulate:
324
+ self.counts["accumulating assigns"] = accumulated
325
+ return self
326
+
327
+ def read_terms(self, line, column, body, whole=None, outer=1):
328
+ u"""A signed sum of helper calls -> rules on one line and column.
329
+
330
+ Its own method in the end, because an assignment is not the only place a form writes
331
+ one. Звіт про власний капітал computes four of its indicators into a COLUMN-LESS
332
+ variable and then prints that into two columns with opposite signs -
333
+ `УстановитьЗначениеПоказателя("П42053", П4205)` and `("П42057", -П4205)` - which is
334
+ Form 2's profit-or-loss split spelled differently: one quantity, two places. The reader
335
+ of that shape hands the variable's expression here, with `outer=-1` for the negated one.
336
+
337
+ `whole` is the text to quote if the expression turns out to hold something this cannot
338
+ read. It defaults to `body`; an assignment passes the expression as it was WRITTEN,
339
+ before its self-reference and its line references were taken out of it, because that is
340
+ what someone reading the complaint would go looking for.
341
+ """
342
+ whole = body if whole is None else whole
343
+ terms = TERM.findall(body)
344
+ if not terms:
345
+ # `= 0` initialises an indicator that later assignments fill; a bare
346
+ # self-reference adds nothing. Neither is an unread shape.
347
+ if body.strip(u" \t\r\n+-()") not in (u"", u"0"):
348
+ self.unparsed.append((line, column, " ".join(whole.split())[:70]))
349
+ return 0
350
+ if TERM.sub("", body).strip(u" \t\r\n+-()"):
351
+ self.unparsed.append((line, column, " ".join(whole.split())[:70]))
352
+ return 0
353
+
354
+ added = 0
355
+ for sign, measure, suffix, first, rest in terms:
356
+ prior = (suffix == u"2")
357
+ if self.comparative is not None and prior != (column == self.comparative):
358
+ self.problems.append(
359
+ "line %s column %s: %s%s in a column that wants %s"
360
+ % (line, column, measure, suffix,
361
+ "the previous period" if column == self.comparative
362
+ else "the reporting period"))
363
+ read_at, side = self.window(measure, prior)
364
+
365
+ args = [x.strip() for x in rest.split(",") if x.strip()]
366
+ second = args[0] if args else None
367
+ if side == "correspondence" and second is None:
368
+ self.problems.append("line %s column %s: %s names one account, needs two"
369
+ % (line, column, measure))
370
+ continue
371
+ if len(args) > 1:
372
+ self.problems.append(
373
+ "line %s column %s: %s passes %d further argument(s); its own body "
374
+ "drops them, which needs deciding rather than copying"
375
+ % (line, column, measure, len(args) - 1))
376
+ continue
377
+ debit = self.accounts.get(first)
378
+ credit = self.accounts.get(second) if second else None
379
+ if not debit or (second and not credit):
380
+ self.problems.append(
381
+ "line %s column %s: %s(%s%s) names an account the module never set"
382
+ % (line, column, measure, first, "," + second if second else ""))
383
+ continue
384
+ self.add(line, column, outer * (-1 if sign == "-" else 1), read_at, side, "scope",
385
+ self.account_scope(debit, credit))
386
+ added += 1
387
+ return added
388
+
389
+ # ------------------------------------------------------- pass 3: the lines from lines
390
+ def read_derived(self):
391
+ n_before = len(self.rules)
392
+ term = self.pattern["derived_term"]
393
+ for line, column, expression in self.pattern["derived"].findall(self.live):
394
+ terms = term.findall(expression)
395
+ if not terms or term.sub("", expression).strip(u" \t\r\n+-()"):
396
+ # a bare variable or a `0`; a script reads those by hand if they matter
397
+ continue
398
+ for sign, ref_line, ref_column in terms:
399
+ self.add(line, column, -1 if sign == "-" else 1, "derived", "none", "line",
400
+ self.line_scope(ref_line, ref_column, column))
401
+ self.counts["derived from lines"] = len(self.rules) - n_before
402
+ return self
403
+
404
+ # ------------------------------------------------------------------ pass 4: the checks
405
+ def report(self):
406
+ cur = migrate.pg_connect().cursor()
407
+ cur.execute('SELECT code FROM masterdata.self_supporting WHERE code IS NOT NULL')
408
+ chart = set(row[0] for row in cur.fetchall())
409
+
410
+ named, referenced = set(), set()
411
+ for rule in self.rules:
412
+ for role in ("include", "includeCredit", "except"):
413
+ value = rule["scope"].get(role)
414
+ if value:
415
+ named.update([value] if isinstance(value, str) else value)
416
+ referenced.update(rule["scope"].get("lines") or [])
417
+ missing = sorted(code for code in named if code not in chart)
418
+ dangling = sorted(referenced - set(r["line_no"] for r in self.rules))
419
+
420
+ wanted = self.wanted
421
+ # spelled the way the FORM spells an indicator, or a separated form would match none
422
+ have = set(r["line_no"] + self.sep + r["column"] for r in self.rules)
423
+
424
+ print("rules %d over %d lines"
425
+ % (len(self.rules), len(set(r["line_no"] for r in self.rules))))
426
+ for key, value in self.counts.items():
427
+ print(" %-23s %s" % (key, value))
428
+ print("by side debit %d, credit %d, correspondence %d, none %d"
429
+ % tuple(sum(1 for r in self.rules if r["side"] == s)
430
+ for s in ("debit", "credit", "correspondence", "none")))
431
+ print("by window %s"
432
+ % dict(collections.Counter(r["when"] for r in self.rules)))
433
+ print("accounts named %d, not in the chart %d %s"
434
+ % (len(named), len(missing), missing))
435
+ print("lines named, computed by nothing: %d %s" % (len(dangling), dangling))
436
+ print("indicators the form sets %d, covered %d, uncovered %d"
437
+ % (len(wanted), len(wanted & have), len(wanted - have)))
438
+ if wanted - have:
439
+ print(" %s" % sorted(wanted - have))
440
+ print("expressions not parsed %d" % len(self.unparsed))
441
+ for row in self.unparsed:
442
+ print(" line %s col %s %s" % row)
443
+ print("disagreements %d" % len(self.problems))
444
+ for row in self.problems[:12]:
445
+ print(" " + row)
446
+ return len(self.problems) + len(self.unparsed)
447
+
448
+ def write(self):
449
+ path = os.path.join(paths.OUT, "statement-%s.json" % self.statement)
450
+ out = io.open(path, "w", encoding="utf-8")
451
+ out.write(json.dumps({
452
+ "statement": self.statement,
453
+ "title": self.title,
454
+ "source": self.module,
455
+ "description": self.description,
456
+ "rules": self.rules,
457
+ }, ensure_ascii=False, indent="\t", sort_keys=True))
458
+ out.write(u"\n")
459
+ out.close()
460
+ print("\n-> " + path)
461
+ return self
462
+
463
+
464
+ # ==========================================================================================
465
+ # From the readings to the module's seed data, and to the index the analytic grain needs.
466
+ #
467
+ # A rule naming ONE account becomes an `include` of that code, because in 1C a total asked of
468
+ # a parent account is the total of its subtree - so "one account" and "a subtree" were never
469
+ # two shapes.
470
+ #
471
+ # Keys are derived from position, not minted: a rule's key is its ordinal within its statement
472
+ # and a scope's its own, so regenerating from the same form produces the same keys and a
473
+ # reinstall does not duplicate rows. They are numbers rather than UUIDs because the seed
474
+ # loader binds a JSON string as `text`, which PostgreSQL will not cast to `uuid` in an INSERT.
475
+ #
476
+ # Each statement gets its own key range - the project says which - so two forms never collide
477
+ # and one can be regenerated without touching the other's rows.
478
+
479
+ SCOPE_OFFSET = 50000 # so a statement holds up to 50 000 rules and 50 000 scope rows
480
+
481
+ # How a scope in the extracted form maps to the rows the module stores. A named account and a
482
+ # subtree are the same role because they mean the same thing.
483
+ ROLE = collections.OrderedDict([
484
+ ("account", "include"),
485
+ ("subtrees", "include"),
486
+ ("include", "include"),
487
+ ("includeCredit", "include_credit"),
488
+ ("except", "exclude"),
489
+ ("exceptCredit", "exclude_credit"),
490
+ ("exceptKind", "exclude_kind"),
491
+ ("lines", "line"),
492
+ # A line reference may name a COLUMN of its own form; without one it means the column the
493
+ # rule is on. That is what a row total is: line 4300 column 10 is line 4300, columns 3..9.
494
+ ("column", "column"),
495
+ # And a rule may read another STATEMENT's line - which statement, which line, which of its
496
+ # columns. Kept in three roles of their own rather than reusing `line`, so that nothing
497
+ # reading `line` has to know the grain to know whose line it is looking at.
498
+ ("statement", "statement"),
499
+ ("statementLine", "statement_line"),
500
+ ("statementColumn", "statement_column"),
501
+ ])
502
+
503
+
504
+ def read(statement):
505
+ path = os.path.join(paths.OUT, "statement-%s.json" % statement)
506
+ if not os.path.exists(path):
507
+ return None
508
+ return json.load(io.open(path, encoding="utf-8"))
509
+
510
+
511
+ def build(statement, base, spec, rules, scopes):
512
+ seen = collections.Counter()
513
+ first_rule, first_scope = len(rules), len(scopes)
514
+ for r in spec["rules"]:
515
+ line, column = r["line_no"], r["column"]
516
+ seen[(line, column)] += 1
517
+ rule_id = base + (len(rules) - first_rule) + 1
518
+ rules.append(collections.OrderedDict([
519
+ ("statement_line_rule_id", rule_id),
520
+ ("statement", statement),
521
+ ("line_no", line),
522
+ ("column_no", column),
523
+ ("caption", r.get("caption") or ""),
524
+ ("sign", r["sign"]),
525
+ ("read_at", r["when"]),
526
+ ("side", r["side"]),
527
+ ("grain", r["grain"]),
528
+ ("rule_no", seen[(line, column)]),
529
+ ]))
530
+
531
+ parts = []
532
+ for key, role in ROLE.items():
533
+ value = r["scope"].get(key)
534
+ if value is None:
535
+ continue
536
+ for code in ([value] if isinstance(value, str) else value):
537
+ parts.append((role, code))
538
+ if not parts:
539
+ raise AssertionError("%s line %s column %s has no scope" % (statement, line, column))
540
+ for role, code in parts:
541
+ scopes.append(collections.OrderedDict([
542
+ ("statement_rule_scope_id",
543
+ base + SCOPE_OFFSET + (len(scopes) - first_scope) + 1),
544
+ ("statement_line_rule_id", rule_id),
545
+ ("role", role),
546
+ ("code", code),
547
+ ]))
548
+
549
+ if len(rules) - first_rule > SCOPE_OFFSET or len(scopes) - first_scope > SCOPE_OFFSET:
550
+ raise AssertionError("%s outgrew its key range: %d rules, %d scope rows against %d"
551
+ % (statement, len(rules) - first_rule,
552
+ len(scopes) - first_scope, SCOPE_OFFSET))
553
+
554
+
555
+ # Where the statements module this port ships keeps what is generated for it.
556
+ MODULE_DIR = os.path.join(paths.MODULES, "statements")
557
+ SEED_RULES = os.path.join(MODULE_DIR, "seed-data", "01-statement_line_rule.json")
558
+ SEED_SCOPES = os.path.join(MODULE_DIR, "seed-data", "02-statement_rule_scope.json")
559
+ INDEX_SQL = os.path.join(MODULE_DIR, "logic", "reports", "30-analytic_index.sql")
560
+
561
+ INDEX_TEMPLATE = u'''-- GENERATED by tools/statements/gen_seed.py - edit that, not this.
562
+ --
563
+ -- The index the analytic grain needs, over the accounts the forms read that way.
564
+ --
565
+ -- Розгорнуте сальдо is netted per (account, analytic), so a form has to read the postings of
566
+ -- a handful of settlement accounts grouped by the account AND its four analytic columns.
567
+ -- Every other read in this module comes from the register's per-period cache, which is keyed
568
+ -- by the account on purpose - 24.3M postings carry 99.8M analytic rows, so a cache keyed by
569
+ -- those would be about as large as the thing it summarises.
570
+ --
571
+ -- Measured, that one read WAS the whole cost of the Balance: 490 thousand rows scattered over
572
+ -- a 14 GB heap, **56 seconds**, against 2.26 with the index below. It is not CPU and it is
573
+ -- not the plan - the rows are found through the register's own account indexes either way. It
574
+ -- is half a million random heap fetches, and the fix is for the index to carry what the read
575
+ -- projects so there are none: measured 4 554 heap fetches out of 490 thousand rows.
576
+ --
577
+ -- WHY THE ACCOUNT LIST IS GENERATED INTO THIS FILE rather than read from the rules.
578
+ --
579
+ -- It was read from `statement_line_rule` at first, which is where it belongs - and that was
580
+ -- wrong, because a module's report SQL runs BEFORE its seed data. On a tenant that had the
581
+ -- rules from an earlier install it worked; on a virgin one the table was empty, the script
582
+ -- concluded that no form reads at the analytic grain, and dropped both indexes. Dropping the
583
+ -- statement tables to widen a column reproduced it exactly, and the Balance went from 3.7
584
+ -- seconds to 7.5 with no error anywhere.
585
+ --
586
+ -- So the derivation moved to where the information actually is: the generator that writes the
587
+ -- rules writes this list too, from the same extracted form, and they cannot drift. What is
588
+ -- generated is the list of ROOT CODES; the subtrees are walked here, against the tenant's own
589
+ -- chart. Excludes are deliberately ignored - an index that covers more accounts than a rule
590
+ -- needs is larger, never wrong.
591
+ --
592
+ -- The index has to be PARTIAL. Covering all 24.3M postings with four jsonb columns comes to
593
+ -- about 9 GB; restricted to these accounts it is 187 MB. And its predicate has to hold
594
+ -- literal uuids, because an index predicate must be immutable and cannot hold a subquery -
595
+ -- which is why this is a DO block that builds the DDL rather than plain DDL.
596
+ --
597
+ -- It is rebuilt only when the set of accounts has actually changed, recorded as a comment on
598
+ -- the index rather than compared against `indexdef`, which PostgreSQL normalises into a form
599
+ -- a text compare would rebuild on every install. An install that changes nothing does
600
+ -- nothing here.
601
+ --
602
+ -- It indexes another module's table, which is a real smell and is deliberate: `accounting`
603
+ -- cannot know which accounts a statement reads at the analytic grain, and `statements` cannot
604
+ -- read them quickly without this. The alternative is 1C's own - it keeps `_AccRgAT2840`, a
605
+ -- balance per account, subconto and period, 1 551 924 rows - and that is the better answer if
606
+ -- this ever needs to survive a form nobody regenerates the index for.
607
+
608
+ DO $$
609
+ DECLARE
610
+ \t-- the root codes every rule of grain `analytic` or `by_kind` names, across every form
611
+ \tv_codes text[] := ARRAY[%(codes)s];
612
+ \tv_accounts text;
613
+ \tv_stamp text;
614
+ \tv_have text;
615
+ BEGIN
616
+ \tWITH RECURSIVE tree AS (
617
+ \t\t\tSELECT a.self_supporting_id AS node
618
+ \t\t\tFROM masterdata.self_supporting a
619
+ \t\t\tWHERE a.code = ANY (v_codes)
620
+ \t\tUNION ALL
621
+ \t\t\tSELECT c.self_supporting_id
622
+ \t\t\tFROM tree t
623
+ \t\t\tJOIN masterdata.self_supporting c ON c.parent = t.node
624
+ \t)
625
+ \tSELECT string_agg(quote_literal(t.node::text) || '::uuid', ', ' ORDER BY t.node)
626
+ \tINTO v_accounts
627
+ \tFROM (SELECT DISTINCT tr.node FROM tree tr) t;
628
+
629
+ \tIF v_accounts IS NULL THEN
630
+ \t\t-- no form reads at the analytic grain, or this chart has none of those accounts
631
+ \t\tDROP INDEX IF EXISTS accounting.sx_statements_analytic_dt;
632
+ \t\tDROP INDEX IF EXISTS accounting.sx_statements_analytic_ct;
633
+ \t\tRETURN;
634
+ \tEND IF;
635
+
636
+ \tv_stamp := md5(v_accounts);
637
+
638
+ \t-- read through the catalog rather than obj_description('…'::regclass), which RAISES when
639
+ \t-- the index is not there yet - the very case this has to handle on a first install
640
+ \tSELECT d.description
641
+ \tINTO v_have
642
+ \tFROM pg_class c
643
+ \tJOIN pg_namespace n ON n.oid = c.relnamespace
644
+ \tLEFT JOIN pg_description d ON d.objoid = c.oid AND d.classoid = 'pg_class'::regclass
645
+ \tWHERE n.nspname = 'accounting' AND c.relname = 'sx_statements_analytic_dt';
646
+
647
+ \tIF v_have IS NOT NULL AND v_have = v_stamp THEN
648
+ \t\tRETURN;
649
+ \tEND IF;
650
+
651
+ \tDROP INDEX IF EXISTS accounting.sx_statements_analytic_dt;
652
+ \tDROP INDEX IF EXISTS accounting.sx_statements_analytic_ct;
653
+
654
+ \tEXECUTE format(
655
+ \t\t'CREATE INDEX sx_statements_analytic_dt ON accounting.posting (debit_account_id, period)'
656
+ \t\t' INCLUDE (amount, dt_analytic1, dt_analytic2, dt_analytic3, dt_analytic4)'
657
+ \t\t' WHERE debit_account_id IN (%%s)', v_accounts);
658
+ \tEXECUTE format(
659
+ \t\t'CREATE INDEX sx_statements_analytic_ct ON accounting.posting (credit_account_id, period)'
660
+ \t\t' INCLUDE (amount, ct_analytic1, ct_analytic2, ct_analytic3, ct_analytic4)'
661
+ \t\t' WHERE credit_account_id IN (%%s)', v_accounts);
662
+
663
+ \tEXECUTE format('COMMENT ON INDEX accounting.sx_statements_analytic_dt IS %%L', v_stamp);
664
+ \tEXECUTE format('COMMENT ON INDEX accounting.sx_statements_analytic_ct IS %%L', v_stamp);
665
+
666
+ \tRAISE NOTICE 'statements: analytic index built for %% account(s)',
667
+ \t\tarray_length(string_to_array(v_accounts, ', '), 1);
668
+ END $$;
669
+ '''
670
+
671
+
672
+ def write_index_sql(specs):
673
+ """The account list the analytic index covers, taken from the same forms as the rules."""
674
+ codes = set()
675
+ for spec in specs:
676
+ for r in spec["rules"]:
677
+ if r["grain"] not in ("analytic", "by_kind"):
678
+ continue
679
+ for key in ("account", "subtrees", "include"):
680
+ value = r["scope"].get(key)
681
+ if value is None:
682
+ continue
683
+ codes.update([value] if isinstance(value, str) else value)
684
+ if not codes:
685
+ print("%-62s no analytic reads, left alone" % INDEX_SQL)
686
+ return
687
+ literal = ", ".join("'%s'" % code for code in sorted(codes))
688
+ out = io.open(INDEX_SQL, "w", encoding="utf-8", newline="\n")
689
+ out.write(INDEX_TEMPLATE % {"codes": literal})
690
+ out.close()
691
+ print("%-62s %d root codes" % (INDEX_SQL, len(codes)))
692
+
693
+
694
+ def write(path, entity, records):
695
+ out = io.open(path, "w", encoding="utf-8", newline="\n")
696
+ out.write(u'{\n\t"entityCode": "%s",\n\t"records": [\n' % entity)
697
+ for i, rec in enumerate(records):
698
+ comma = "," if i < len(records) - 1 else ""
699
+ body = ", ".join(u'"%s": %s' % (k, json.dumps(v, ensure_ascii=False))
700
+ for k, v in rec.items())
701
+ out.write(u"\t\t{ %s }%s\n" % (body, comma))
702
+ out.write(u"\t]\n}\n")
703
+ out.close()
704
+ print("%-62s %d records" % (path, len(records)))
705
+
706
+
707
+
708
+
709
+ def generate(forms):
710
+ """Every form's reading -> the module's two seed files, and the index the rules imply."""
711
+ rules, scopes, specs = [], [], []
712
+ for form in forms:
713
+ spec = read(form.statement)
714
+ if spec is None:
715
+ print("%-10s not extracted, skipped" % form.statement)
716
+ continue
717
+ before = len(rules)
718
+ build(form.statement, form.key_base, spec, rules, scopes)
719
+ specs.append(spec)
720
+ print("%-10s %d rules over %d lines"
721
+ % (form.statement, len(rules) - before,
722
+ len(set(r["line_no"] for r in spec["rules"]))))
723
+
724
+ write(SEED_RULES, "statement_line_rule", rules)
725
+ write(SEED_SCOPES, "statement_rule_scope", scopes)
726
+
727
+ # The index covers every form at once, so it may only be rewritten when every form was
728
+ # read. An index written from one form would drop the cover the others need.
729
+ if len(specs) == len(forms):
730
+ write_index_sql(specs)
731
+ else:
732
+ print("%-62s left alone: only %d of %d forms read"
733
+ % (INDEX_SQL, len(specs), len(forms)))
734
+
735
+ assert len(set(r["statement_line_rule_id"] for r in rules)) == len(rules), "rule keys collide"
736
+ assert len(set(s["statement_rule_scope_id"] for s in scopes)) == len(scopes), "scope keys collide"
737
+ print("roles: %s" % dict(collections.Counter(s["role"] for s in scopes)))
738
+ print("reads: %s" % dict(collections.Counter(r["read_at"] for r in rules)))
739
+
740
+
741
+ def main():
742
+ """Read every regulated form the project declares, then write the module's seed data.
743
+
744
+ python tools/1c.py statements # every form
745
+ python tools/1c.py statements balance # one, leaving the others' seed rows alone
746
+ """
747
+ import binding
748
+
749
+ wanted = sys.argv[1:]
750
+ builders = binding.forms()
751
+ if not builders:
752
+ print("the project at %s declares no forms - see binding.REQUIRED" % paths.PROJECT)
753
+ return 1
754
+
755
+ forms, problems = [], 0
756
+ for builder in builders:
757
+ form = builder()
758
+ if wanted and form.statement not in wanted:
759
+ forms.append(form) # still counted, so the index is not narrowed to one form
760
+ continue
761
+ print("=== %s <- %s" % (form.statement, form.module))
762
+ problems += form.report()
763
+ form.write()
764
+ forms.append(form)
765
+ print("")
766
+
767
+ generate(forms)
768
+ print("")
769
+ print("%d problem(s) across %d form(s)" % (problems, len(forms)))
770
+ return 1 if problems else 0
771
+
772
+
773
+ if __name__ == "__main__":
774
+ sys.exit(main() or 0)