@dforge-core/dforge-mcp 0.2.45 → 0.2.46-next
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +2 -1
- package/package.json +48 -46
- package/scripts/install-skills.mjs +1 -0
- package/skills/dforge-1c-import/SKILL.md +374 -0
- package/skills/dforge-1c-import/references/environment.md +97 -0
- package/skills/dforge-1c-import/references/loading.md +161 -0
- package/skills/dforge-1c-import/references/measurements.md +210 -0
- package/skills/dforge-1c-import/references/print-forms.md +148 -0
- package/skills/dforge-1c-import/scripts/1c.py +178 -0
- package/skills/dforge-1c-import/scripts/README.md +110 -0
- package/skills/dforge-1c-import/scripts/lib/analyze.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/attrs.py +278 -0
- package/skills/dforge-1c-import/scripts/lib/autospec.py +964 -0
- package/skills/dforge-1c-import/scripts/lib/binding.py +257 -0
- package/skills/dforge-1c-import/scripts/lib/bsl.py +811 -0
- package/skills/dforge-1c-import/scripts/lib/callgraph.py +717 -0
- package/skills/dforge-1c-import/scripts/lib/cli.py +53 -0
- package/skills/dforge-1c-import/scripts/lib/columnmap.py +346 -0
- package/skills/dforge-1c-import/scripts/lib/coverage.py +242 -0
- package/skills/dforge-1c-import/scripts/lib/custom.py +83 -0
- package/skills/dforge-1c-import/scripts/lib/delta.py +741 -0
- package/skills/dforge-1c-import/scripts/lib/deps.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/docusage.py +105 -0
- package/skills/dforge-1c-import/scripts/lib/dump1c.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/explore.py +55 -0
- package/skills/dforge-1c-import/scripts/lib/external.py +201 -0
- package/skills/dforge-1c-import/scripts/lib/extract_code.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/forms.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/genmodule.py +1432 -0
- package/skills/dforge-1c-import/scripts/lib/handlers.py +202 -0
- package/skills/dforge-1c-import/scripts/lib/handmap.py +142 -0
- package/skills/dforge-1c-import/scripts/lib/i18n_coverage.py +87 -0
- package/skills/dforge-1c-import/scripts/lib/idioms.py +273 -0
- package/skills/dforge-1c-import/scripts/lib/integrity.py +264 -0
- package/skills/dforge-1c-import/scripts/lib/inventory.py +535 -0
- package/skills/dforge-1c-import/scripts/lib/layers.py +106 -0
- package/skills/dforge-1c-import/scripts/lib/meta.py +315 -0
- package/skills/dforge-1c-import/scripts/lib/migrate.py +1299 -0
- package/skills/dforge-1c-import/scripts/lib/mxl.py +368 -0
- package/skills/dforge-1c-import/scripts/lib/naming.py +251 -0
- package/skills/dforge-1c-import/scripts/lib/paths.py +90 -0
- package/skills/dforge-1c-import/scripts/lib/peek.py +70 -0
- package/skills/dforge-1c-import/scripts/lib/posted.py +218 -0
- package/skills/dforge-1c-import/scripts/lib/posting.py +655 -0
- package/skills/dforge-1c-import/scripts/lib/printgen.py +239 -0
- package/skills/dforge-1c-import/scripts/lib/rebuild.py +194 -0
- package/skills/dforge-1c-import/scripts/lib/recorded.py +170 -0
- package/skills/dforge-1c-import/scripts/lib/recorders.py +94 -0
- package/skills/dforge-1c-import/scripts/lib/reftargets.py +95 -0
- package/skills/dforge-1c-import/scripts/lib/reportgen.py +157 -0
- package/skills/dforge-1c-import/scripts/lib/roles.py +33 -0
- package/skills/dforge-1c-import/scripts/lib/seedstate.py +488 -0
- package/skills/dforge-1c-import/scripts/lib/sizing.py +184 -0
- package/skills/dforge-1c-import/scripts/lib/spec.py +400 -0
- package/skills/dforge-1c-import/scripts/lib/statements.py +774 -0
- package/skills/dforge-1c-import/scripts/lib/subsystems.py +480 -0
- package/skills/dforge-1c-import/scripts/lib/terms.py +451 -0
- package/skills/dforge-1c-import/scripts/lib/terms_uk.py +555 -0
- package/skills/dforge-1c-import/scripts/lib/totals.py +387 -0
- package/skills/dforge-1c-import/scripts/lib/typemap.py +73 -0
- package/skills/dforge-1c-import/scripts/lib/unpack.py +136 -0
- package/skills/dforge-1c-import/scripts/lib/validate.py +508 -0
- package/skills/dforge-1c-import/scripts/project-template/contours.py +47 -0
- package/skills/dforge-1c-import/scripts/project-template/sources.py +14 -0
- package/skills/dforge-mcp-author/SKILL.md +5 -0
|
@@ -0,0 +1,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)
|