@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,164 @@
1
+ # The 1C porting toolkit
2
+
3
+ A Python library that reads a 1C:Enterprise 8 infobase, either a server infobase on MS SQL
4
+ Server or a file infobase (`1Cv8.1CD`). It needs no 1C software and no licence. From the
5
+ infobase it:
6
+
7
+ - generates dForge modules;
8
+ - copies the rows;
9
+ - checks the result.
10
+
11
+ It was built on a real port: an УТП-based configuration, 16 contours, 30 M+ rows,
12
+ reconciled to the row and the kopeck. It is meant to be pointed at the next infobase
13
+ unchanged.
14
+
15
+ ```
16
+ scripts/
17
+ 1c.py the one entry point: python 1c.py <command> [args]
18
+ lib/ the library: 1C storage, metadata, generation, loading, checks
19
+ project-template/ what a new port starts its project package from
20
+ ```
21
+
22
+ ## Two layers: export, then everything else
23
+
24
+ Only one step reads 1C itself. `dump1c` exports the infobase to `out/`, and every later
25
+ step reads that export, never the infobase.
26
+
27
+ | layer | modules | reads | writes |
28
+ |---|---|---|---|
29
+ | export | `dump1c` · `source_mssql` · `source_file` · `onecd` | the infobase: MS SQL (`DF1C_SERVER`/`DF1C_DB`) or a `.1CD` file (`DF1C_FILE`) | `out/config/`, `dbnames.json`, `schema.json`, `data/<table>.parquet`, `export.json` |
30
+ | everything else | `store` and every other module | `out/`. Rows are queried with DuckDB through `store.connect()` | modules, the tenant, reports |
31
+
32
+ The export looks the same whichever kind of infobase it came from:
33
+
34
+ - names are spelt the MS SQL way (`_Reference12`, `_Fld345RRef`). A file infobase keeps
35
+ them in capitals, and DBNames supplies the spelling back;
36
+ - the schema uses MS SQL type names (`binary(16)`, `nvarchar`, `numeric`, `datetime`);
37
+ - booleans are `binary(1)`.
38
+
39
+ Rows are kept as 1C stores them. `export.json` records the infobase's year offset: usually
40
+ 2000 on MS SQL, always 0 in a file infobase. It also records the horizon (`MIN_ACTIVE_ROWVERSION()`)
41
+ taken before each table was read.
42
+
43
+ - **SQL over the export is DuckDB's.** That includes a project's `sources.QUERIES`. Quote
44
+ names with double quotes (case does not matter), use `LIMIT` instead of `TOP`, write
45
+ a binary constant as `from_hex('01')` instead of `0x01`, and use `CAST` instead of
46
+ `CONVERT`. A port whose queries were written in T-SQL has to rewrite them.
47
+ - **`dump1c data --changes` reads only what 1C changed** since the export (or `--since
48
+ <day>`). It works table by table:
49
+ - objects are re-read by `_Version` above the mark, and deletions are found by key;
50
+ - tabular parts are re-read through their changed owners;
51
+ - register movements are re-read through their changed recorders;
52
+ - anything else is re-read whole.
53
+
54
+ It rewrites each table's file to what a full export would hold. On an infobase of 2.7 M
55
+ objects, it reads the keys in a few seconds instead of a full export of 1.5 h.
56
+
57
+ On an accounting register it also compares each recorder's count of inactive entries.
58
+ Switching entries on or off ("Включить/выключить проводки") rewrites them without
59
+ writing the document, so `_Version` does not show it.
60
+ - **Totals and document journals are left out by default** (`_AccumRgT`, `_AccumRgTn`,
61
+ `_AccRgAT`, `_AccRgCT`, `_DocumentJournal`). 1C derives them from other tables, they
62
+ have no `_Version`, and `--changes` would read them in full on every run: 53.7 M of
63
+ 313 M rows. Name one, or pass `--derived`, to export it. `seedstate verify` says which
64
+ totals table it needs.
65
+ - **`delta` needs an MS SQL export.** In a file infobase `_Version` is a counter per table,
66
+ not a rowversion for the whole database, so a file export has no horizon. Re-export it
67
+ and reload with `migrate run <module> --truncate`.
68
+ - **Read a file infobase while 1C is not writing to it.** Copy the `.1CD` first, or close
69
+ the infobase.
70
+
71
+ ## Library and project
72
+
73
+ - **`lib/` knows 1C and dForge**: how a configuration is stored, how its metadata
74
+ parses, what the physical tables mean, and how rows are copied and checked.
75
+ - **The project knows this infobase**:
76
+ - which object goes to which module (`contours.py`);
77
+ - which 1C column feeds an authored column (`sources.py`);
78
+ - the integrator's prefixes;
79
+ - the hand-written modules (`authored/<module>/`).
80
+
81
+ `lib/binding.py` lists everything a project may provide. Only `contours` and `sources`
82
+ are required.
83
+
84
+ **Never put project facts in `lib/`.** That covers infobase names, tenant names, the
85
+ company's abbreviations and prefixes. `python 1c.py --boundary` checks that no library
86
+ module imports a project module or names the project's world.
87
+
88
+ **Never hand-edit generated output.** Put the change in the project, either as a spec
89
+ override or as an authored file under `authored/<module>/` that `genmodule` merges.
90
+ Then regenerate. If a port keeps needing hand edits, the generator is wrong.
91
+
92
+ ## Setup
93
+
94
+ - Python 3.7+.
95
+ - `pyarrow` and `duckdb`, to write and read the export (on 3.7: `pyarrow==12.0.1`,
96
+ `duckdb==1.1.3`).
97
+ - `pyodbc` and an ODBC driver for SQL Server (driver 17 or 18), only to export a server
98
+ infobase. A file infobase needs nothing more.
99
+ - `psycopg2`, to load and check the tenant.
100
+ - `jsonschema`, for `validate`.
101
+ - Node with `liquidjs` in `PRINT_NODE_DIR`, only for `printgen preview`.
102
+
103
+ On Windows, set `PYTHONIOENCODING=utf-8`.
104
+
105
+ Lay out a port directory, copy `project-template/.env.example` to `<root>/.env` and fill it in, and export `DF1C_ROOT` / `DF1C_PROJECT` in the shell:
106
+
107
+ ```
108
+ myport/
109
+ .env credentials (never committed)
110
+ project/ copied from project-template/, then filled in
111
+ out/ what the extraction writes (DF1C_OUT, default <root>/out)
112
+ modules/ generated modules (DF1C_MODULES, default <root>/modules)
113
+ ```
114
+
115
+ ```
116
+ DF1C_ROOT=/path/to/myport
117
+ DF1C_PROJECT=/path/to/myport/project
118
+ DF1C_SERVER=<sql server> DF1C_DB=<infobase db> DF1C_USER= DF1C_PASS= # read-only login; Windows auth when USER is empty
119
+ DF1C_FILE=<path to 1Cv8.1CD, or its folder> # a file infobase instead of the two above
120
+ DF_TENANT=<tenant code> DF_PG_HOST= DF_PG_PORT= DF_PG_USER= DF_PG_PASS= # the tenant's Postgres, for load and checks
121
+ ```
122
+
123
+ None of these has a default. A default that named one infobase would make the toolkit
124
+ quietly read or write that infobase when a variable was forgotten.
125
+
126
+ ## The pipeline
127
+
128
+ Each step reads what the previous one wrote to `out/`. Every command has `--help` or a
129
+ docstring with its options, and `python 1c.py --list` lists them all.
130
+
131
+ | # | step | command |
132
+ |---|---|---|
133
+ | 1 | Export the infobase: configuration, DBNames, schema, rows | `dump1c probe` · `dump1c all`, or one at a time: `dump1c dbnames` · `schema` · `extract` · `data [table ...]` |
134
+ | 2 | Parse the metadata, resolve type GUIDs, join the physical schema | `meta allobjects` · `typemap` · `analyze` · `attrs <Object>` · `explore <Object>` |
135
+ | 3 | Extract the module texts (posting handlers, print procedures) | `extract_code` · `unpack <Object> --code` |
136
+ | 4 | Inventory: what exists, what is used, what is the client's | `inventory` · `docusage` · `custom` · `sizing` · `subsystems` |
137
+ | 5 | Read the posting handlers: which registers each document writes and from where | `handlers [--client]` · `callgraph` · `recorders` · `recorded` · `idioms` · `spec` |
138
+ | 6 | Decide the module boundaries | `subsystems --contours --acyclic`, copied into `project/contours.py` and corrected |
139
+ | 7 | Generate and validate the modules | `autospec [module]` · `genmodule` · `validate` · `deps` · `layers` |
140
+ | 8 | Install them | `npx -y @dforge-core/dforge-cli@latest module install ...`, or `rebuild --code <tenant> --install` |
141
+ | 9 | Copy the rows and reconcile | `migrate plan\|check\|run\|verify\|fill <module>` · `integrity` · `columnmap <module>` |
142
+ | 10 | Post state and registers | `posted [--mark]` · `posting` · `seedstate` · `totals` · `coverage` |
143
+ | 11 | Catch up with what 1C changed (MS SQL exports only) | once: `dump1c data --objects` (delta needs every object's keys); then `dump1c data --changes`, `delta --baseline` · `delta [--apply]` |
144
+ | 12 | Print forms and reports | `mxl list [--client]` · `mxl dump <Object>` · `printgen <spec>` · `printgen preview <spec> <form> <key>` · `reportgen <spec>` |
145
+ | 13 | External reports and processings stored in the infobase | `external` |
146
+
147
+ `rebuild --code <tenant>` runs steps 8–10 from nothing, which makes a tenant
148
+ reproducible.
149
+
150
+ ## Rules the toolkit assumes
151
+
152
+ - **The infobase is read-only.** Use a SELECT-only login. Read a file infobase from a copy.
153
+ - **Open ad-hoc database connections in autocommit.** A read transaction left open
154
+ blocks the next install's `ALTER`.
155
+ - **Resolve a physical column (`_FldNNN`) only through DBNames** (`attrs`, `columnmap`).
156
+ Never resolve it by cardinality.
157
+ - **Stop on a 401 from the tenant.** Ask for a fresh login, then re-run
158
+ `posted --mark` for documents loaded in the meantime.
159
+
160
+ ## Source
161
+
162
+ This copy is kept in step with the port it was developed on (`tools/lib`, `tools/1c.py`
163
+ there). Fixes made in a port go back to the library here. A project's own files never
164
+ come here.
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Join the physical schema against DBNames to see which parts of the configuration
5
+ the client actually uses. A stock UTP ships hundreds of catalogs and documents;
6
+ only the populated ones are worth porting.
7
+ """
8
+ import codecs
9
+ import collections
10
+ import json
11
+ import os
12
+ import re
13
+ import sys
14
+
15
+ import paths
16
+
17
+ OUT = paths.OUT
18
+
19
+ # Physical table families that carry business data, in the order we care about.
20
+ DATA_KINDS = ("Reference", "Document", "InfoRg", "AccumRg", "AccRg", "Chrc", "Acc",
21
+ "Enum", "DocumentJournal", "Node", "BPr", "Task", "Const")
22
+
23
+ SPLIT = re.compile(r"^_([A-Za-z]+?)(\d+)(?:_(.+))?$")
24
+
25
+
26
+ def load(name):
27
+ with codecs.open(os.path.join(OUT, name), "r", "utf-8") as f:
28
+ return json.load(f)
29
+
30
+
31
+ def classify(table_name):
32
+ """_Document123_VT456 -> ('Document', 123, 'VT456')"""
33
+ m = SPLIT.match(table_name)
34
+ if not m:
35
+ return None, None, None
36
+ return m.group(1), int(m.group(2)), m.group(3)
37
+
38
+
39
+ def main():
40
+ schema = load("schema.json")
41
+ tables = {t["name"]: t for t in schema["tables"]}
42
+
43
+ summary = collections.OrderedDict((k, {"tables": 0, "used": 0, "rows": 0}) for k in DATA_KINDS)
44
+ detail = collections.defaultdict(list)
45
+
46
+ for name, t in tables.items():
47
+ kind, number, suffix = classify(name)
48
+ if kind not in summary or suffix: # skip tabular sections, roll them up separately
49
+ continue
50
+ rows = t["rows"] or 0
51
+ # a document's tabular sections belong to the document's footprint
52
+ child_rows = sum((tables[n]["rows"] or 0) for n in tables
53
+ if n.startswith("_%s%d_" % (kind, number)))
54
+ summary[kind]["tables"] += 1
55
+ summary[kind]["rows"] += rows
56
+ if rows:
57
+ summary[kind]["used"] += 1
58
+ detail[kind].append({"table": name, "number": number, "rows": rows,
59
+ "childRows": child_rows, "columns": len(t["columns"])})
60
+
61
+ print("%-18s %8s %8s %14s" % ("kind", "tables", "used", "rows"))
62
+ print("-" * 52)
63
+ for kind, s in summary.items():
64
+ if s["tables"]:
65
+ print("%-18s %8d %8d %14s" % (kind, s["tables"], s["used"], "{:,}".format(s["rows"])))
66
+
67
+ top = int(sys.argv[1]) if len(sys.argv) > 1 else 25
68
+ for kind in ("Document", "Reference", "AccumRg", "InfoRg"):
69
+ rows = sorted(detail[kind], key=lambda d: -(d["rows"] + d["childRows"]))[:top]
70
+ print("\n== %s: top %d by volume ==" % (kind, top))
71
+ for d in rows:
72
+ if not (d["rows"] or d["childRows"]):
73
+ continue
74
+ print(" %-22s rows=%-12s child=%-12s cols=%d"
75
+ % (d["table"], "{:,}".format(d["rows"]),
76
+ "{:,}".format(d["childRows"]), d["columns"]))
77
+
78
+ with codecs.open(os.path.join(OUT, "usage.json"), "w", "utf-8") as f:
79
+ json.dump({"summary": summary, "detail": detail}, f, ensure_ascii=False, indent="\t")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Extract the field list of a metadata object, with references resolved.
5
+
6
+ Every typed thing in a 1C object file - a catalog attribute, a register dimension
7
+ or resource, a tabular-section column - is encoded the same way:
8
+
9
+ ["2", ["1", ["0","0",<own guid>], "<Name>", ["1","ru","<Synonym>"], ...],
10
+ ["Pattern", <type...>]]
11
+
12
+ so the collector looks for that shape anywhere in the tree rather than hard-coding
13
+ per-kind offsets, and takes the nearest named ancestor as the tabular section.
14
+
15
+ python attrs.py <ObjectName> one object, to the console
16
+ python attrs.py --model <file> <names...> several objects, to a markdown file
17
+ """
18
+ from __future__ import print_function
19
+
20
+ import codecs
21
+ import json
22
+ import os
23
+ import paths
24
+ import sys
25
+
26
+ import meta
27
+
28
+ OUT = meta.OUT
29
+
30
+ PRIMITIVE = {"S": "string", "N": "number", "B": "bool", "D": "date"}
31
+
32
+ # Collection class GUIDs -> what the fields in them mean, confirmed by checking a
33
+ # register whose meaning is unambiguous (ТоварыНаСкладах: Количество is the resource,
34
+ # Склад/Номенклатура the dimensions). Unknown GUIDs print truncated so they show up.
35
+ ROLE = {
36
+ "cf4abea7-37b2-11d4-940f-008048da11f9": "attr", # catalog attribute
37
+ "45e46cbc-3e24-4165-8b7b-cc98a6f80211": "attr", # document attribute
38
+ "888744e1-b616-11d4-9436-004095e12fc7": "column", # tabular section column
39
+ "b64d9a41-1642-11d6-a3c7-0050bae0a776": "resource", # accumulation register
40
+ "b64d9a42-1642-11d6-a3c7-0050bae0a776": "attr",
41
+ "b64d9a43-1642-11d6-a3c7-0050bae0a776": "dimension",
42
+ "13134202-f60b-11d5-a3c7-0050bae0a776": "resource", # information register
43
+ "13134203-f60b-11d5-a3c7-0050bae0a776": "dimension",
44
+ "13134204-f60b-11d5-a3c7-0050bae0a776": "attr",
45
+ }
46
+
47
+ ROLE_ORDER = ("dimension", "resource", "attr", "column")
48
+
49
+
50
+ def is_identity(node):
51
+ """["1", ["0","0",<guid>], "<Name>", ["1","ru","<Synonym>"], "", "0", "0"]"""
52
+ if not (isinstance(node, list) and len(node) >= 3 and isinstance(node[1], list)):
53
+ return False
54
+ if len(node[1]) != 3 or not isinstance(node[1][2], str) or not meta.GUID.match(node[1][2]):
55
+ return False
56
+ return isinstance(node[2], str) and bool(node[2]) and not node[2].isdigit()
57
+
58
+
59
+ def find_identity(node):
60
+ """First identity block in a header subtree, depth first."""
61
+ if not isinstance(node, list):
62
+ return None
63
+ if is_identity(node):
64
+ return node
65
+ for item in node:
66
+ found = find_identity(item)
67
+ if found is not None:
68
+ return found
69
+ return None
70
+
71
+
72
+ def is_field(node):
73
+ """A field is an identity block immediately followed by its type pattern."""
74
+ return (isinstance(node, list) and len(node) >= 3 and is_identity(node[1])
75
+ and isinstance(node[2], list) and node[2] and node[2][0] == "Pattern")
76
+
77
+
78
+ def name_of(node):
79
+ return node[1][2]
80
+
81
+
82
+ def guid_of(node):
83
+ """The attribute's own GUID, from its identity block.
84
+
85
+ This is what DBNames keys its `Fld` entries by, so it is how a ported column is
86
+ traced back to the `_FldNNN` the data actually sits in.
87
+ """
88
+ ident = node[1]
89
+ head = ident[1] if len(ident) > 1 else None
90
+ return head[2] if isinstance(head, list) and len(head) > 2 else None
91
+
92
+
93
+ def synonym_of(node):
94
+ syn = node[1][3] if len(node[1]) > 3 else None
95
+ return syn[2] if isinstance(syn, list) and len(syn) >= 3 else None
96
+
97
+
98
+ def is_collection(node):
99
+ """[<class guid>, <count>, member, member, ...] - attributes, dimensions, resources."""
100
+ return (isinstance(node, list) and len(node) > 2 and isinstance(node[0], str)
101
+ and meta.GUID.match(node[0]) and isinstance(node[1], str) and node[1].isdigit())
102
+
103
+
104
+ def decode_type(pattern, types):
105
+ """['Pattern', ['S','30','1'], guid, ...] -> readable type."""
106
+ parts, refs = [], []
107
+ for item in pattern[1:]:
108
+ if isinstance(item, str) and meta.GUID.match(item):
109
+ refs.append(item)
110
+ elif isinstance(item, list) and item:
111
+ head = item[0]
112
+ if head == "S":
113
+ parts.append("string(%s)" % (item[1] if len(item) > 1 else "?"))
114
+ elif head == "N":
115
+ parts.append("number(%s,%s)" % (item[1] if len(item) > 1 else "?",
116
+ item[2] if len(item) > 2 else "0"))
117
+ elif head == "B":
118
+ parts.append("bool")
119
+ elif head == "D":
120
+ parts.append("date(%s)" % (item[1] if len(item) > 1 else "DateTime"))
121
+ else:
122
+ refs.extend(s for s in item if isinstance(s, str) and meta.GUID.match(s))
123
+ for guid in refs:
124
+ target = types.get(guid)
125
+ parts.append("-> %s.%s" % (target["family"], target["name"]) if target
126
+ else "-> <%s>" % guid[:8])
127
+ return ", ".join(parts) or "?"
128
+
129
+
130
+ def has_pattern(node):
131
+ if not isinstance(node, list):
132
+ return False
133
+ if node and node[0] == "Pattern":
134
+ return True
135
+ return any(has_pattern(item) for item in node)
136
+
137
+
138
+ def tabular_section_info(node):
139
+ """A tabular section is [<header>, ..., <columns collection>]: a named header that
140
+ carries no type of its own, followed by a collection of typed columns.
141
+
142
+ Returns (name, guid). The GUID is what DBNames keys the section's `_VT` table and
143
+ its `_LineNo` column by.
144
+ """
145
+ if len(node) < 3 or not isinstance(node[0], list) or not is_collection(node[2]):
146
+ return None
147
+ if has_pattern(node[0]):
148
+ return None
149
+ ident = find_identity(node[0])
150
+ if ident is None:
151
+ return None
152
+ head = ident[1] if len(ident) > 1 else None
153
+ guid = head[2] if isinstance(head, list) and len(head) > 2 else None
154
+ return ident[2], guid
155
+
156
+
157
+ def tabular_section_name(node):
158
+ found = tabular_section_info(node)
159
+ return found[0] if found else None
160
+
161
+
162
+ def collect(node, section, collection, types, fields, section_guid=None):
163
+ if not isinstance(node, list):
164
+ return
165
+ if is_field(node):
166
+ fields.append({
167
+ "section": section,
168
+ "sectionGuid": section_guid,
169
+ "collection": collection,
170
+ "name": name_of(node),
171
+ "guid": guid_of(node),
172
+ "synonym": synonym_of(node),
173
+ "type": decode_type(node[2], types),
174
+ })
175
+ return
176
+ found = tabular_section_info(node)
177
+ if found:
178
+ section, section_guid = found
179
+ if is_collection(node):
180
+ collection = node[0]
181
+ node = node[2:]
182
+ for item in node:
183
+ collect(item, section, collection, types, fields, section_guid)
184
+
185
+
186
+ def enum_values(tree, object_name):
187
+ """Enum members carry no type pattern, so they are plain identity blocks."""
188
+ values = []
189
+ def walk(node):
190
+ if not isinstance(node, list):
191
+ return
192
+ if is_identity(node):
193
+ if node[2] != object_name and node[2] not in values:
194
+ values.append(node[2])
195
+ return
196
+ for item in node:
197
+ walk(item)
198
+ walk(tree)
199
+ return values
200
+
201
+
202
+ def load_json(name):
203
+ with codecs.open(os.path.join(OUT, name), "r", "utf-8") as f:
204
+ return json.load(f)
205
+
206
+
207
+ def fields_of(name, objects, types):
208
+ guid = next((o["guid"] for o in objects if o.get("name") == name), None)
209
+ if not guid:
210
+ return None, None
211
+ text = meta.load(guid)
212
+ if text is None:
213
+ return guid, None
214
+ fields = []
215
+ collect(meta.parse(text), None, None, types, fields)
216
+ return guid, fields
217
+
218
+
219
+ def main():
220
+ objects = load_json("objects.json")
221
+ types = load_json("typemap.json")
222
+ report = {r["name"]: r for r in load_json("report.json")}
223
+
224
+ if sys.argv[1] == "--model":
225
+ out_path, names = sys.argv[2], sys.argv[3:]
226
+ lines = ["# Transport & shipping contour - extracted model", "",
227
+ "Generated from the `%s` infobase without 1C software."
228
+ % (os.environ.get("DF1C_DB") or os.path.basename(os.environ.get("DF1C_FILE") or "source")), ""]
229
+ for name in names:
230
+ guid, fields = fields_of(name, objects, types)
231
+ row = report.get(name, {})
232
+ if fields is None:
233
+ lines += ["## %s *(not found)*" % name, ""]
234
+ continue
235
+ lines += ["## %s.%s - %s" % (row.get("family", "?"), name, row.get("synonym") or ""),
236
+ "",
237
+ "`%s` - %s rows, %s child rows" % (
238
+ row.get("physical"),
239
+ "{:,}".format(row.get("rows") or 0),
240
+ "{:,}".format(row.get("childRows") or 0)), ""]
241
+ if not fields:
242
+ values = enum_values(meta.parse(meta.load(guid)), name)
243
+ if values:
244
+ lines += ["Values: " + ", ".join(values), ""]
245
+ continue
246
+ sections = [None] + sorted({f["section"] for f in fields if f["section"]})
247
+ for section in sections:
248
+ chunk = [f for f in fields if f["section"] == section]
249
+ if not chunk:
250
+ continue
251
+ chunk.sort(key=lambda f: ROLE_ORDER.index(ROLE[f["collection"]])
252
+ if ROLE.get(f["collection"]) in ROLE_ORDER else 9)
253
+ lines += ["**%s**" % (section or "header"), "",
254
+ "| role | field | synonym | type |", "|---|---|---|---|"]
255
+ for f in chunk:
256
+ lines.append("| %s | %s | %s | %s |" % (
257
+ ROLE.get(f["collection"], "?"), f["name"],
258
+ f["synonym"] or "", f["type"]))
259
+ lines.append("")
260
+ with codecs.open(out_path, "w", "utf-8") as f:
261
+ f.write("\n".join(lines))
262
+ print("wrote %s (%d objects)" % (out_path, len(names)))
263
+ return
264
+
265
+ name = sys.argv[1]
266
+ guid, fields = fields_of(name, objects, types)
267
+ if fields is None:
268
+ print("no such object: %s" % name)
269
+ return
270
+ print("%s (%s) fields=%d\n" % (name, guid, len(fields)))
271
+ for f in fields:
272
+ print(" %-10s %-22s %-34s %-28s %s"
273
+ % (ROLE.get(f["collection"], (f["collection"] or "")[:8]),
274
+ f["section"] or "", f["name"], f["synonym"] or "", f["type"]))
275
+
276
+
277
+ if __name__ == "__main__":
278
+ main()