@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.
- package/CHANGELOG.md +73 -0
- package/README.md +3 -2
- package/dist/server.js +109 -84
- package/package.json +48 -46
- package/resources/docs/conventions.md +17 -14
- package/resources/docs/dsl-reference.md +15 -5
- package/resources/schemas/entity.schema.json +1 -1
- package/resources/schemas/registers.schema.json +8 -3
- 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 +8 -2
- package/skills/dforge-mcp-author/examples/column-security/entities/product.json +1 -1
- package/skills/dforge-mcp-author/examples/column-security/manifest.json +0 -1
- package/skills/dforge-mcp-author/examples/column-security/security/roles.json +2 -2
- package/skills/dforge-mcp-author/references/action-dsl.md +20 -7
- package/skills/dforge-mcp-author/references/conventions.md +7 -1
- package/skills/dforge-mcp-author/references/field-types.md +3 -2
- package/skills/dforge-mcp-author/references/formulas.md +5 -1
- package/skills/dforge-mcp-author/references/print-templates.md +5 -2
- package/skills/dforge-mcp-author/references/queries.md +1 -1
- package/skills/dforge-mcp-author/references/registers.md +18 -3
- package/skills/dforge-mcp-author/references/reports.md +4 -1
- package/skills/dforge-mcp-author/references/schema-import.md +1 -1
- package/skills/dforge-mcp-author/references/security.md +1 -1
- package/skills/dforge-mcp-author/references/translations.md +38 -15
- package/skills/dforge-mcp-author/references/validation-checklist.md +2 -3
- package/skills/dforge-module-build/SKILL.md +10 -1
- package/skills/dforge-module-ship/SKILL.md +31 -3
- package/skills/dforge-mcp-author/examples/column-security/translations/en-US.json +0 -35
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Write a module's ported 1C print forms from one compact spec, and preview them.
|
|
3
|
+
|
|
4
|
+
A print form reads its data from the module's stored procedures (a print template's
|
|
5
|
+
`datasets`, each a procedure keyed by the record): the procedures' signatures and columns go
|
|
6
|
+
in `logic/stored_procedures.json`, the rights to run them in `security/roles.json`, the
|
|
7
|
+
template in `ui/print_templates.json` beside its HTML and CSS under `print_templates/`. This
|
|
8
|
+
writes the first three from one spec into the project's `authored/<module>/` tree, which
|
|
9
|
+
`genmodule` lays over the generated module; the template's HTML and CSS are the project's own
|
|
10
|
+
files there. A procedure's columns are read off its `RETURNS TABLE` in the module's SQL, so the
|
|
11
|
+
declaration cannot drift from the function.
|
|
12
|
+
|
|
13
|
+
`ui/print_templates.json` is written only when the platform takes `datasets` on a template
|
|
14
|
+
(PRINT_DATASETS=1): until then the schema refuses the key, and a template without its data
|
|
15
|
+
would print empty, so the forms wait and the procedures are installed and runnable on their
|
|
16
|
+
own.
|
|
17
|
+
|
|
18
|
+
A spec module (tools/project/prints_<module>.py) defines MODULE, ROLES, SQL (the files that
|
|
19
|
+
declare its procedures) and PRINTS, a list of `print_form(...)`.
|
|
20
|
+
|
|
21
|
+
python tools/lib/printgen.py tools/project/prints_warehouse.py
|
|
22
|
+
python tools/1c.py printgen preview tools/project/prints_warehouse.py <form> <key>
|
|
23
|
+
# render one record with LiquidJS, screenshot it
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import print_function
|
|
26
|
+
|
|
27
|
+
import codecs
|
|
28
|
+
import collections
|
|
29
|
+
import decimal
|
|
30
|
+
import imp
|
|
31
|
+
import io
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
|
|
38
|
+
O = collections.OrderedDict
|
|
39
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
40
|
+
|
|
41
|
+
# a procedure column's pg type -> (fieldTypeCd, baseDatatypeCd)
|
|
42
|
+
PG = {"text": ("text", "string"), "integer": ("number", "number"), "numeric": ("number", "number"),
|
|
43
|
+
"boolean": ("checkbox", "bool"), "date": ("date", "date"), "timestamptz": ("datetime", "timestamp"),
|
|
44
|
+
"uuid": ("text", "string")}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def dataset(name, sp, single=False):
|
|
48
|
+
"""A template's dataset: its name in the template, the procedure, one row or many."""
|
|
49
|
+
return dict(name=name, sp=sp, single=single)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def print_form(code, entity, en, uk, file, datasets, key, css=None, orientation="portrait",
|
|
53
|
+
size="A4", source=None):
|
|
54
|
+
"""A print form on `entity` (the module's own, or `module.entity`), its procedures called
|
|
55
|
+
with the record's `key` column."""
|
|
56
|
+
return dict(code=code, entity=entity, en=en, uk=uk, file=file, css=css, datasets=datasets,
|
|
57
|
+
key=key, orientation=orientation, size=size, source=source)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read(path):
|
|
61
|
+
if not os.path.isfile(path):
|
|
62
|
+
return O()
|
|
63
|
+
with codecs.open(path, "r", "utf-8") as f:
|
|
64
|
+
return json.load(f, object_pairs_hook=O)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _write(path, doc):
|
|
68
|
+
directory = os.path.dirname(path)
|
|
69
|
+
if not os.path.isdir(directory):
|
|
70
|
+
os.makedirs(directory)
|
|
71
|
+
with codecs.open(path, "w", "utf-8") as f:
|
|
72
|
+
f.write(json.dumps(doc, ensure_ascii=False, indent="\t") + "\n")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def signatures(sql_paths):
|
|
76
|
+
"""{function name: (params [(name, pg type)], columns [(name, pg type)])} off the SQL."""
|
|
77
|
+
out = {}
|
|
78
|
+
pattern = re.compile(r"CREATE OR REPLACE FUNCTION\s+\w+\.(\w+)\s*\(([^)]*)\)\s*RETURNS TABLE\s*\((.*?)\)\s*LANGUAGE",
|
|
79
|
+
re.S | re.I)
|
|
80
|
+
for path in sql_paths:
|
|
81
|
+
with io.open(path, encoding="utf-8") as f:
|
|
82
|
+
text = f.read()
|
|
83
|
+
for name, params, columns in pattern.findall(text):
|
|
84
|
+
def pairs(spec):
|
|
85
|
+
return [tuple(part.strip().split()[:2]) for part in spec.split(",") if part.strip()]
|
|
86
|
+
out[name] = (pairs(params), pairs(columns))
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build(spec, root):
|
|
91
|
+
sql = [os.path.join(root, p) for p in spec.SQL]
|
|
92
|
+
sigs = signatures(sql)
|
|
93
|
+
sps = _read(os.path.join(root, "logic", "stored_procedures.json"))
|
|
94
|
+
for k in [k for k in sps if k.startswith("prn_")]:
|
|
95
|
+
del sps[k]
|
|
96
|
+
rights = O()
|
|
97
|
+
templates = O()
|
|
98
|
+
uk = O()
|
|
99
|
+
for form in spec.PRINTS:
|
|
100
|
+
for ds in form["datasets"]:
|
|
101
|
+
if ds["sp"] not in sigs:
|
|
102
|
+
raise SystemExit("printgen: %s is not declared in %s" % (ds["sp"], ", ".join(spec.SQL)))
|
|
103
|
+
params, columns = sigs[ds["sp"]]
|
|
104
|
+
sps[ds["sp"]] = O([
|
|
105
|
+
("description", "%s: %s" % (form["en"], ds["name"])), ("functionName", ds["sp"]),
|
|
106
|
+
("params", [O([("paramCd", p), ("pgType", t), ("fieldTypeCd", PG.get(t, PG["text"])[0]),
|
|
107
|
+
("label", p), ("required", True)]) for p, t in params]),
|
|
108
|
+
("columns", [O([("columnCd", c), ("label", c), ("fieldTypeCd", PG.get(t, PG["text"])[0]),
|
|
109
|
+
("baseDatatypeCd", PG.get(t, PG["text"])[1])]) for c, t in columns])])
|
|
110
|
+
rights["sp:" + ds["sp"]] = "E"
|
|
111
|
+
entry = O([("entityCd", form["entity"]), ("description", form["uk"]), ("file", form["file"])])
|
|
112
|
+
if form["css"]:
|
|
113
|
+
entry["css"] = form["css"]
|
|
114
|
+
entry["pageSettings"] = O([("size", form["size"]), ("orientation", form["orientation"]),
|
|
115
|
+
("margins", O([("top", "8mm"), ("right", "8mm"), ("bottom", "8mm"), ("left", "8mm")]))])
|
|
116
|
+
entry["datasets"] = O((ds["name"], O([("sp", ds["sp"]), ("params", O([(sigs[ds["sp"]][0][0][0], form["key"])])),
|
|
117
|
+
("single", ds["single"])])) for ds in form["datasets"])
|
|
118
|
+
templates[form["code"]] = entry
|
|
119
|
+
uk[form["code"]] = {"label": form["uk"]}
|
|
120
|
+
|
|
121
|
+
_write(os.path.join(root, "logic", "stored_procedures.json"), sps)
|
|
122
|
+
path = os.path.join(root, "security", "roles.json")
|
|
123
|
+
doc = _read(path)
|
|
124
|
+
for role in spec.ROLES:
|
|
125
|
+
r = doc.setdefault(role, O([("rights", O())]))
|
|
126
|
+
r.setdefault("rights", O())
|
|
127
|
+
for key in [k for k in r["rights"] if k.startswith("sp:prn_")]:
|
|
128
|
+
del r["rights"][key]
|
|
129
|
+
r["rights"].update(rights)
|
|
130
|
+
_write(path, doc)
|
|
131
|
+
path = os.path.join(root, "translations", "uk-UA.json")
|
|
132
|
+
translations = _read(path)
|
|
133
|
+
translations["print_templates"] = uk
|
|
134
|
+
_write(path, translations)
|
|
135
|
+
target = os.path.join(root, "ui", "print_templates.json")
|
|
136
|
+
if os.environ.get("PRINT_DATASETS") == "1":
|
|
137
|
+
_write(target, templates)
|
|
138
|
+
elif os.path.isfile(target):
|
|
139
|
+
os.remove(target)
|
|
140
|
+
return templates
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# --------------------------------------------------------------------------- preview
|
|
144
|
+
|
|
145
|
+
RENDER_JS = r"""
|
|
146
|
+
const { Liquid } = require('liquidjs');
|
|
147
|
+
const fs = require('fs');
|
|
148
|
+
const [tpl, css, data, out] = process.argv.slice(2);
|
|
149
|
+
const engine = new Liquid();
|
|
150
|
+
const nf = (v, d) => (v === null || v === undefined || v === '') ? '' :
|
|
151
|
+
Number(v).toLocaleString('uk-UA', { minimumFractionDigits: d || 0, maximumFractionDigits: d || 0 });
|
|
152
|
+
engine.registerFilter('format_number', (v, d) => nf(v, d));
|
|
153
|
+
engine.registerFilter('format_currency', (v, c, d) => nf(v, d === undefined ? 2 : d) + (c ? ' ' + c : ''));
|
|
154
|
+
engine.registerFilter('format_date', (v) => v === null || v === undefined ? '' : String(v));
|
|
155
|
+
const ctx = JSON.parse(fs.readFileSync(data, 'utf8'));
|
|
156
|
+
engine.parseAndRender(fs.readFileSync(tpl, 'utf8'), ctx).then(html => {
|
|
157
|
+
fs.writeFileSync(out, '<!doctype html><html><head><meta charset="utf-8"><style>' +
|
|
158
|
+
(css ? fs.readFileSync(css, 'utf8') : '') + '</style></head><body>' + html + '</body></html>');
|
|
159
|
+
});
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
EDGE = r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _plain(value):
|
|
166
|
+
if isinstance(value, decimal.Decimal):
|
|
167
|
+
return float(value)
|
|
168
|
+
if hasattr(value, "isoformat"):
|
|
169
|
+
return value.isoformat()
|
|
170
|
+
return value
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def preview(spec, root, code, key, out_dir, node_dir):
|
|
174
|
+
"""Render one record's form with LiquidJS (the editor's engine) to HTML and a PNG."""
|
|
175
|
+
import migrate
|
|
176
|
+
migrate.load_env()
|
|
177
|
+
form = [f for f in spec.PRINTS if f["code"] == code][0]
|
|
178
|
+
pg = migrate.pg_connect()
|
|
179
|
+
pg.autocommit = True
|
|
180
|
+
cur = pg.cursor()
|
|
181
|
+
# the module's SQL as written, in a transaction rolled back after the read: the functions the
|
|
182
|
+
# installer owns are left as they are
|
|
183
|
+
cur.execute("BEGIN")
|
|
184
|
+
for path in spec.SQL:
|
|
185
|
+
with io.open(os.path.join(root, path), encoding="utf-8") as f:
|
|
186
|
+
cur.execute(f.read())
|
|
187
|
+
ctx = {}
|
|
188
|
+
for ds in form["datasets"]:
|
|
189
|
+
cur.execute("SELECT * FROM %s.%s(%%s)" % (spec.SCHEMA, ds["sp"]), (key,))
|
|
190
|
+
names = [d[0] for d in cur.description]
|
|
191
|
+
rows = [dict((n, _plain(v)) for n, v in zip(names, r)) for r in cur.fetchall()]
|
|
192
|
+
ctx[ds["name"]] = (rows[0] if rows else None) if ds["single"] else rows
|
|
193
|
+
cur.execute("ROLLBACK")
|
|
194
|
+
if not os.path.isdir(out_dir):
|
|
195
|
+
os.makedirs(out_dir)
|
|
196
|
+
data = os.path.join(out_dir, code + ".json")
|
|
197
|
+
with io.open(data, "w", encoding="utf-8") as f:
|
|
198
|
+
f.write(json.dumps(ctx, ensure_ascii=False))
|
|
199
|
+
script = os.path.join(node_dir, "render.js")
|
|
200
|
+
with io.open(script, "w", encoding="utf-8") as f:
|
|
201
|
+
f.write(RENDER_JS)
|
|
202
|
+
html = os.path.join(out_dir, code + ".html")
|
|
203
|
+
tpl = os.path.join(root, "print_templates", form["file"])
|
|
204
|
+
css = os.path.join(root, "print_templates", form["css"]) if form["css"] else ""
|
|
205
|
+
subprocess.check_call(["node", script, tpl, css, data, html], cwd=node_dir)
|
|
206
|
+
png = os.path.join(out_dir, code + ".png")
|
|
207
|
+
width = 1400 if form["orientation"] == "landscape" else 1000
|
|
208
|
+
subprocess.call([EDGE, "--headless=new", "--disable-gpu", "--screenshot=" + png,
|
|
209
|
+
"--window-size=%d,1300" % width, "file:///" + html.replace("\\", "/")],
|
|
210
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
211
|
+
return html, png
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load(spec_path):
|
|
215
|
+
return imp.load_source(os.path.splitext(os.path.basename(spec_path))[0], spec_path)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main(argv):
|
|
219
|
+
if len(argv) > 1 and argv[1] == "preview":
|
|
220
|
+
spec = load(argv[2])
|
|
221
|
+
root = os.path.normpath(os.path.join(HERE, "..", "project", "authored", spec.MODULE))
|
|
222
|
+
out = os.environ.get("PRINT_PREVIEW_DIR") or os.path.join(HERE, "..", "..", "out", "prints")
|
|
223
|
+
node = os.environ.get("PRINT_NODE_DIR")
|
|
224
|
+
if not node:
|
|
225
|
+
raise SystemExit("printgen preview: set PRINT_NODE_DIR to a folder with liquidjs installed")
|
|
226
|
+
for path in preview(spec, root, argv[3], argv[4], os.path.normpath(out), node):
|
|
227
|
+
print(path)
|
|
228
|
+
return 0
|
|
229
|
+
for spec_path in argv[1:]:
|
|
230
|
+
spec = load(spec_path)
|
|
231
|
+
root = os.path.normpath(os.path.join(HERE, "..", "project", "authored", spec.MODULE))
|
|
232
|
+
templates = build(spec, root)
|
|
233
|
+
print("%s: %d print form(s)%s" % (spec.MODULE, len(templates),
|
|
234
|
+
"" if os.environ.get("PRINT_DATASETS") == "1" else ", templates held until the platform takes datasets"))
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
sys.exit(main(sys.argv))
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Rebuild a tenant from nothing: install every module, then load every contour.
|
|
5
|
+
|
|
6
|
+
Written the day `docker/reset.sh` wiped the tenant and the recipe turned out to live in
|
|
7
|
+
one person's head and one section of a status document. Both are worse than a command:
|
|
8
|
+
the order is computed rather than remembered, each step says what it did, and a failure
|
|
9
|
+
stops at the module that failed instead of leaving half a tenant behind.
|
|
10
|
+
|
|
11
|
+
python tools/1c.py rebuild --code <tenant> # install, then load, everything
|
|
12
|
+
python tools/1c.py rebuild --code <tenant> --install # modules only
|
|
13
|
+
python tools/1c.py rebuild --code <tenant> --load # data only
|
|
14
|
+
python tools/1c.py rebuild --code <tenant> --from production # resume at a module
|
|
15
|
+
python tools/1c.py rebuild --code <tenant> --only planning # one module, on its own
|
|
16
|
+
python tools/1c.py rebuild --code <tenant> --plan # print the order and stop
|
|
17
|
+
|
|
18
|
+
**Install-then-load is the wrong order for a register that caches its totals.** A `totals`
|
|
19
|
+
cache is rebuilt when the register is installed or its shape changes, and never again - there
|
|
20
|
+
is no rebuild command - so rows loaded afterwards never reach it and `getBalance` as of a date
|
|
21
|
+
comes back short with no error at all. None of the registers declared here has a `totals`
|
|
22
|
+
cache, which is the only reason this order is safe; the day one does, its contour has to be
|
|
23
|
+
loaded before the module that declares it is installed.
|
|
24
|
+
|
|
25
|
+
It does NOT create the tenant. That needs an admin user to exist first and is one
|
|
26
|
+
command in the platform's own CLI, which says plainly what it wants:
|
|
27
|
+
|
|
28
|
+
curl -s -X POST http://localhost:5002/api/auth/register -H "Content-Type: application/json" \\
|
|
29
|
+
-d '{"email":"...","password":"...","firstName":"...","lastName":"..."}'
|
|
30
|
+
npx -y @dforge-core/dforge-cli@latest tenant create \\
|
|
31
|
+
--code <code> --name <name> --admin <email>
|
|
32
|
+
|
|
33
|
+
Installing runs the platform's published CLI on this machine (tools/lib/cli.py), straight
|
|
34
|
+
from this repository's module directories.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import print_function
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import os
|
|
40
|
+
import subprocess
|
|
41
|
+
import sys
|
|
42
|
+
import time
|
|
43
|
+
|
|
44
|
+
import cli
|
|
45
|
+
import deps
|
|
46
|
+
import paths
|
|
47
|
+
|
|
48
|
+
def install_order():
|
|
49
|
+
"""Every generated module, in an order where nothing precedes what it depends on."""
|
|
50
|
+
sequence, stuck = deps.order(deps.graph())
|
|
51
|
+
if stuck:
|
|
52
|
+
raise SystemExit("rebuild: %s cannot be ordered - a cycle in the manifests"
|
|
53
|
+
% ", ".join(sorted(stuck)))
|
|
54
|
+
return [code for code in sequence
|
|
55
|
+
if os.path.isdir(os.path.join(paths.MODULES, code))]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def remote(tenant):
|
|
59
|
+
"""`--url` and `--token` for an install through the running API, when DFORGE_URL is set.
|
|
60
|
+
|
|
61
|
+
A local install writes the tenant's metadata straight to the database, and the running
|
|
62
|
+
dforge-api never hears of it: it goes on serving the old action scripts and register
|
|
63
|
+
bindings until its cache expires or it restarts - two engine replays on 2026-10-19 ran
|
|
64
|
+
the previous code. Through the API the same request invalidates them. A new tenant has
|
|
65
|
+
no session to sign in with yet, so this stays opt-in.
|
|
66
|
+
"""
|
|
67
|
+
url = os.environ.get("DFORGE_URL")
|
|
68
|
+
if not url:
|
|
69
|
+
return []
|
|
70
|
+
import posting
|
|
71
|
+
named = os.environ.get("DF_TENANT")
|
|
72
|
+
os.environ["DF_TENANT"] = tenant
|
|
73
|
+
try:
|
|
74
|
+
return ["--url", url, "--token", posting.token()]
|
|
75
|
+
finally:
|
|
76
|
+
if named is None:
|
|
77
|
+
del os.environ["DF_TENANT"]
|
|
78
|
+
else:
|
|
79
|
+
os.environ["DF_TENANT"] = named
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def install(tenant, code, verbose=False):
|
|
83
|
+
rc, out = cli.run(["module", "install", "--code", tenant,
|
|
84
|
+
"--path", os.path.join(paths.MODULES, code), "--force"] + remote(tenant))
|
|
85
|
+
if verbose:
|
|
86
|
+
print(out)
|
|
87
|
+
# a local install reports in prose, one through the API in JSON
|
|
88
|
+
if rc != 0 or not ("installed successfully" in out or '"success":true' in out):
|
|
89
|
+
tail = [line for line in out.splitlines() if line.strip()][-12:]
|
|
90
|
+
raise SystemExit("rebuild: installing %s failed\n %s"
|
|
91
|
+
% (code, "\n ".join(tail)))
|
|
92
|
+
# The one line worth keeping from a hundred: what the registrar actually did.
|
|
93
|
+
return next((l.strip() for l in out.splitlines() if "Registers:" in l), "")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load(tenant, code, verbose=False):
|
|
97
|
+
"""Copy the contour's rows. `migrate` reads the tenant's own shape, so it must run
|
|
98
|
+
after the install rather than beside it.
|
|
99
|
+
|
|
100
|
+
**The tenant has to be handed over explicitly.** `migrate` picks its target from
|
|
101
|
+
`DF_TENANT`, which has no default, and it takes no tenant argument - so a `load` that
|
|
102
|
+
only passed the module loaded whatever `.env` pointed at while reporting the tenant
|
|
103
|
+
named on the command line. It truncates as it goes, so the wrong tenant is not a
|
|
104
|
+
no-op: `rebuild --code <other> --load` emptied and reloaded whatever DF_TENANT named and left the other empty.
|
|
105
|
+
"""
|
|
106
|
+
command = [sys.executable, os.path.join(paths.ROOT, "tools", "1c.py"),
|
|
107
|
+
"migrate", "run", code, "--truncate"]
|
|
108
|
+
# Both names, and both pinned rather than cleared. `migrate.load_env()` fills from `.env`
|
|
109
|
+
# with `setdefault`, so an unset variable is not a safe default - it is an invitation:
|
|
110
|
+
# clearing DF_PG_DB would let a DF_PG_DB in `.env` win over the DF_TENANT set here, and
|
|
111
|
+
# the load would truncate that database while reporting this tenant.
|
|
112
|
+
env = dict(os.environ, PYTHONIOENCODING="utf-8",
|
|
113
|
+
DF_TENANT=tenant, DF_PG_DB="dforge_%s" % tenant)
|
|
114
|
+
proc = subprocess.Popen(command, cwd=paths.ROOT, env=env,
|
|
115
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
116
|
+
out = proc.communicate()[0].decode("utf-8", "replace")
|
|
117
|
+
if verbose:
|
|
118
|
+
print(out)
|
|
119
|
+
if proc.returncode != 0:
|
|
120
|
+
tail = [line for line in out.splitlines() if line.strip()][-12:]
|
|
121
|
+
raise SystemExit("rebuild: loading %s failed\n %s" % (code, "\n ".join(tail)))
|
|
122
|
+
# A balance register whose journal was just reloaded has a state nothing else rebuilds:
|
|
123
|
+
# 1C has no state table to copy, and the platform only moves it by posting.
|
|
124
|
+
seeded = subprocess.Popen(
|
|
125
|
+
[sys.executable, os.path.join(paths.ROOT, "tools", "1c.py"), "seedstate", "run", code],
|
|
126
|
+
cwd=paths.ROOT, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
127
|
+
seed_out = seeded.communicate()[0].decode("utf-8", "replace")
|
|
128
|
+
if verbose:
|
|
129
|
+
print(seed_out)
|
|
130
|
+
if seeded.returncode != 0:
|
|
131
|
+
raise SystemExit("rebuild: seeding the balance state of %s failed\n %s"
|
|
132
|
+
% (code, seed_out.strip()))
|
|
133
|
+
for line in reversed(out.splitlines()):
|
|
134
|
+
parts = line.split()
|
|
135
|
+
if len(parts) >= 2 and parts[1] == "entities":
|
|
136
|
+
return line.strip()
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def run(codes, doing, action, verbose=False):
|
|
141
|
+
started = time.time()
|
|
142
|
+
for index, code in enumerate(codes, 1):
|
|
143
|
+
mark = time.time()
|
|
144
|
+
note = action(code, verbose)
|
|
145
|
+
print(" %2d/%d %-12s %6.1fs %s"
|
|
146
|
+
% (index, len(codes), code, time.time() - mark, note))
|
|
147
|
+
sys.stdout.flush()
|
|
148
|
+
print(" %s: %d module(s) in %.0fs" % (doing, len(codes), time.time() - started))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main():
|
|
152
|
+
parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0])
|
|
153
|
+
parser.add_argument("--code", required=True, help="tenant code, as the platform knows it")
|
|
154
|
+
parser.add_argument("--install", action="store_true", help="modules only")
|
|
155
|
+
parser.add_argument("--load", action="store_true", help="data only")
|
|
156
|
+
parser.add_argument("--from", dest="start", help="resume at this module")
|
|
157
|
+
parser.add_argument("--only", help="this module alone, dependencies assumed installed")
|
|
158
|
+
parser.add_argument("--plan", action="store_true", help="print the order and stop")
|
|
159
|
+
parser.add_argument("--verbose", action="store_true")
|
|
160
|
+
args = parser.parse_args()
|
|
161
|
+
|
|
162
|
+
codes = install_order()
|
|
163
|
+
if args.only and args.start:
|
|
164
|
+
raise SystemExit("rebuild: --only and --from ask for different things")
|
|
165
|
+
if args.only:
|
|
166
|
+
# One module, after a declaration changed. Its dependencies are already installed -
|
|
167
|
+
# reinstalling them would be an upgrade in place, which is safe but not free, and
|
|
168
|
+
# reloading their rows would be minutes of nothing.
|
|
169
|
+
if args.only not in codes:
|
|
170
|
+
raise SystemExit("rebuild: %r is not one of %s" % (args.only, ", ".join(codes)))
|
|
171
|
+
codes = [args.only]
|
|
172
|
+
if args.start:
|
|
173
|
+
if args.start not in codes:
|
|
174
|
+
raise SystemExit("rebuild: %r is not one of %s" % (args.start, ", ".join(codes)))
|
|
175
|
+
codes = codes[codes.index(args.start):]
|
|
176
|
+
|
|
177
|
+
print("[i] tenant %s, modules from %s" % (args.code, paths.MODULES))
|
|
178
|
+
print("[i] %d module(s): %s" % (len(codes), " -> ".join(codes)))
|
|
179
|
+
if args.plan:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
both = not (args.install or args.load)
|
|
183
|
+
if args.install or both:
|
|
184
|
+
print("\ninstalling")
|
|
185
|
+
run(codes, "installed",
|
|
186
|
+
lambda code, verbose: install(args.code, code, verbose), args.verbose)
|
|
187
|
+
if args.load or both:
|
|
188
|
+
print("\nloading into %s" % args.code)
|
|
189
|
+
run(codes, "loaded",
|
|
190
|
+
lambda code, verbose: load(args.code, code, verbose), args.verbose)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
main()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
u"""What each document type actually wrote: register rows by recorder type, every register.
|
|
4
|
+
|
|
5
|
+
The call graph (`callgraph.py`) says what the code CAN write. This says what 1C DID write,
|
|
6
|
+
and the difference between the two is the call graph's error, measured rather than argued:
|
|
7
|
+
|
|
8
|
+
* a register the code reaches but no document of that type ever wrote is either a branch
|
|
9
|
+
this client never took or an over-reach of the analysis - the chain in callgraph.json
|
|
10
|
+
tells which;
|
|
11
|
+
* a register a document wrote that the code does not reach is a miss, always a defect.
|
|
12
|
+
|
|
13
|
+
A register with several recorder types carries `_RecorderTRef`, whose low two bytes are the
|
|
14
|
+
recorder's document table number. One with a single recorder type has no TRef; its type is
|
|
15
|
+
the one document table whose keys its recorders match.
|
|
16
|
+
|
|
17
|
+
python tools/1c.py recorded # writes out/recorded.json
|
|
18
|
+
python tools/1c.py recorded --compare # and scores out/callgraph.json against it
|
|
19
|
+
|
|
20
|
+
Read-only, and `NOLOCK`: a count does not need to wait for anyone.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import print_function
|
|
23
|
+
|
|
24
|
+
import codecs
|
|
25
|
+
import collections
|
|
26
|
+
import io
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
import meta
|
|
32
|
+
import migrate
|
|
33
|
+
import paths
|
|
34
|
+
|
|
35
|
+
TARGET = os.path.join(paths.OUT, "recorded.json")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def registers():
|
|
39
|
+
"""(family, name, physical table) for every register in the configuration."""
|
|
40
|
+
with io.open(os.path.join(paths.OUT, "objects.json"), encoding="utf-8") as f:
|
|
41
|
+
objects = json.load(f)
|
|
42
|
+
index = meta.dbnames_index()
|
|
43
|
+
found = []
|
|
44
|
+
for obj in objects:
|
|
45
|
+
if obj.get("family") not in ("AccumRg", "InfoRg", "AccRg", "CRg"):
|
|
46
|
+
continue
|
|
47
|
+
entry = index.get(obj["guid"])
|
|
48
|
+
if entry and entry["type"] == obj["family"]:
|
|
49
|
+
found.append((obj["family"], obj["name"], entry["physical"]))
|
|
50
|
+
return sorted(found)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def documents():
|
|
54
|
+
"""Document table number -> (name, physical table)."""
|
|
55
|
+
with io.open(os.path.join(paths.OUT, "objects.json"), encoding="utf-8") as f:
|
|
56
|
+
objects = json.load(f)
|
|
57
|
+
index = meta.dbnames_index()
|
|
58
|
+
found = {}
|
|
59
|
+
for obj in objects:
|
|
60
|
+
if obj.get("family") != "Document":
|
|
61
|
+
continue
|
|
62
|
+
entry = index.get(obj["guid"])
|
|
63
|
+
if entry and entry["type"] == "Document":
|
|
64
|
+
found[int(entry["number"])] = (obj["name"], entry["physical"])
|
|
65
|
+
return found
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def columns(cur, table):
|
|
69
|
+
cur.execute("SELECT name FROM sys.columns WHERE object_id = OBJECT_ID(?)", "dbo." + table)
|
|
70
|
+
return set(row[0] for row in cur.fetchall())
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def single_recorder(cur, table, docs):
|
|
74
|
+
"""The one document type a TRef-less register's recorders belong to."""
|
|
75
|
+
cur.execute("SELECT DISTINCT TOP (20) _RecorderRRef FROM dbo.[%s] WITH (NOLOCK)" % table)
|
|
76
|
+
refs = [bytes(r[0]) for r in cur.fetchall()]
|
|
77
|
+
if not refs:
|
|
78
|
+
return None
|
|
79
|
+
marks = ",".join("?" * len(refs))
|
|
80
|
+
for number, (name, physical) in sorted(docs.items()):
|
|
81
|
+
cur.execute("SELECT COUNT(*) FROM dbo.[%s] WHERE _IDRRef IN (%s)" % (physical, marks),
|
|
82
|
+
*refs)
|
|
83
|
+
if cur.fetchone()[0]:
|
|
84
|
+
return number
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def measure():
|
|
89
|
+
migrate.load_env()
|
|
90
|
+
cur = migrate.mssql_connect().cursor()
|
|
91
|
+
docs = documents()
|
|
92
|
+
result = collections.defaultdict(dict) # document -> register -> rows
|
|
93
|
+
unmatched = []
|
|
94
|
+
for family, name, table in registers():
|
|
95
|
+
cols = columns(cur, table)
|
|
96
|
+
if "_RecorderRRef" not in cols:
|
|
97
|
+
continue # not subordinate to a recorder
|
|
98
|
+
if "_RecorderTRef" in cols:
|
|
99
|
+
cur.execute("SELECT _RecorderTRef, COUNT_BIG(*) FROM dbo.[%s] WITH (NOLOCK)"
|
|
100
|
+
" GROUP BY _RecorderTRef" % table)
|
|
101
|
+
for tref, rows in cur.fetchall():
|
|
102
|
+
number = int.from_bytes(bytes(tref), "big") & 0xFFFF
|
|
103
|
+
if number in docs:
|
|
104
|
+
result[docs[number][0]][name] = int(rows)
|
|
105
|
+
else:
|
|
106
|
+
unmatched.append((name, number, int(rows)))
|
|
107
|
+
else:
|
|
108
|
+
cur.execute("SELECT COUNT_BIG(*) FROM dbo.[%s] WITH (NOLOCK)" % table)
|
|
109
|
+
rows = int(cur.fetchone()[0])
|
|
110
|
+
if not rows:
|
|
111
|
+
continue
|
|
112
|
+
number = single_recorder(cur, table, docs)
|
|
113
|
+
if number is None:
|
|
114
|
+
unmatched.append((name, None, rows))
|
|
115
|
+
else:
|
|
116
|
+
result[docs[number][0]][name] = rows
|
|
117
|
+
sys.stderr.write(".")
|
|
118
|
+
sys.stderr.write("\n")
|
|
119
|
+
return dict(result), unmatched
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def compare(recorded, out):
|
|
123
|
+
"""Score the call graph against what 1C wrote, per document that wrote anything."""
|
|
124
|
+
with io.open(os.path.join(paths.OUT, "callgraph.json"), encoding="utf-8") as f:
|
|
125
|
+
graph = dict((r["document"], set(r["registers"])
|
|
126
|
+
) for r in json.load(f)["documents"])
|
|
127
|
+
hit = miss = extra = 0
|
|
128
|
+
lines = []
|
|
129
|
+
for document in sorted(recorded):
|
|
130
|
+
actual = set(recorded[document])
|
|
131
|
+
static = graph.get(document, set())
|
|
132
|
+
missed = actual - static
|
|
133
|
+
over = static - actual
|
|
134
|
+
hit += len(actual & static)
|
|
135
|
+
miss += len(missed)
|
|
136
|
+
extra += len(over)
|
|
137
|
+
if missed or over:
|
|
138
|
+
lines.append((document, sorted(missed), sorted(over)))
|
|
139
|
+
out.write(u"%d documents wrote registers in 1C. Register pairs: %d found by the call "
|
|
140
|
+
u"graph, %d missed, %d reached by code but never written\n\n"
|
|
141
|
+
% (len(recorded), hit, miss, extra))
|
|
142
|
+
for document, missed, over in lines:
|
|
143
|
+
if missed:
|
|
144
|
+
out.write(u"%-44s MISSED %s\n" % (document[:44], u", ".join(missed)))
|
|
145
|
+
out.write(u"\n")
|
|
146
|
+
for document, missed, over in lines:
|
|
147
|
+
if over:
|
|
148
|
+
out.write(u"%-44s never written: %s\n" % (document[:44], u", ".join(over)[:150]))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def main():
|
|
152
|
+
out = codecs.getwriter("utf-8")(sys.stdout.buffer)
|
|
153
|
+
if os.path.exists(TARGET) and "--measure" not in sys.argv:
|
|
154
|
+
with io.open(TARGET, encoding="utf-8") as f:
|
|
155
|
+
saved = json.load(f)
|
|
156
|
+
recorded = saved["documents"]
|
|
157
|
+
else:
|
|
158
|
+
recorded, unmatched = measure()
|
|
159
|
+
with codecs.open(TARGET, "w", "utf-8") as f:
|
|
160
|
+
json.dump({"documents": recorded, "unmatched": unmatched}, f,
|
|
161
|
+
ensure_ascii=False, indent="\t", sort_keys=True)
|
|
162
|
+
out.write(u"%d document types wrote registers; %d register/type pairs unmatched\n"
|
|
163
|
+
% (len(recorded), len(unmatched)))
|
|
164
|
+
out.write(u"written %s\n" % TARGET)
|
|
165
|
+
if "--compare" in sys.argv:
|
|
166
|
+
compare(recorded, out)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
main()
|