@dforge-core/dforge-mcp 0.2.46-next → 0.2.48
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 +68 -1
- package/dist/server.js +71 -3
- package/package.json +47 -48
- package/resources/docs/dsl-reference.md +1 -1
- package/resources/schemas/data-views.schema.json +50 -0
- package/skills/dforge-1c-import/SKILL.md +3 -2
- package/skills/dforge-1c-import/references/environment.md +2 -1
- package/skills/dforge-1c-import/references/loading.md +5 -2
- package/skills/dforge-1c-import/references/measurements.md +1 -1
- package/skills/dforge-1c-import/scripts/README.md +88 -7
- package/skills/dforge-1c-import/scripts/lib/__pycache__/attrs.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/delta.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/meta.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/migrate.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/paths.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/posted.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/posting.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/seedstate.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/store.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/totals.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/__pycache__/transactions.cpython-313.pyc +0 -0
- package/skills/dforge-1c-import/scripts/lib/attrs.py +2 -2
- package/skills/dforge-1c-import/scripts/lib/changes.py +326 -0
- package/skills/dforge-1c-import/scripts/lib/delta.py +109 -52
- package/skills/dforge-1c-import/scripts/lib/docusage.py +5 -4
- package/skills/dforge-1c-import/scripts/lib/dump1c.py +249 -92
- package/skills/dforge-1c-import/scripts/lib/external.py +4 -3
- package/skills/dforge-1c-import/scripts/lib/meta.py +12 -2
- package/skills/dforge-1c-import/scripts/lib/migrate.py +93 -74
- package/skills/dforge-1c-import/scripts/lib/onecd.py +269 -0
- package/skills/dforge-1c-import/scripts/lib/paths.py +21 -0
- package/skills/dforge-1c-import/scripts/lib/peek.py +3 -5
- package/skills/dforge-1c-import/scripts/lib/recorded.py +11 -11
- package/skills/dforge-1c-import/scripts/lib/recorders.py +9 -12
- package/skills/dforge-1c-import/scripts/lib/seedstate.py +21 -16
- package/skills/dforge-1c-import/scripts/lib/source_file.py +227 -0
- package/skills/dforge-1c-import/scripts/lib/source_mssql.py +158 -0
- package/skills/dforge-1c-import/scripts/lib/store.py +126 -0
- package/skills/dforge-1c-import/scripts/lib/totals.py +2 -4
- package/skills/dforge-1c-import/scripts/lib/transactions.py +28 -0
- package/skills/dforge-1c-import/scripts/project-template/.env.example +24 -0
- package/skills/dforge-1c-import/scripts/project-template/sources.py +10 -0
- package/skills/dforge-mcp-author/references/action-dsl.md +49 -46
- package/skills/dforge-mcp-author/references/data-views.md +38 -2
- package/skills/dforge-mcp-author/references/formulas.md +38 -6
- package/skills/dforge-mcp-author/references/jobs.md +1 -1
- package/skills/dforge-mcp-author/references/print-templates.md +30 -5
- package/skills/dforge-mcp-author/references/queries.md +17 -2
- package/skills/dforge-mcp-author/references/registers.md +3 -0
- package/skills/dforge-mcp-author/references/reports.md +1 -1
- package/skills/dforge-mcp-author/references/security.md +2 -0
- package/skills/dforge-mcp-author/references/validation-checklist.md +7 -8
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
The infobase as everything past the export sees it: `out/data/<table>.parquet`, one view
|
|
4
|
+
per table, queried with DuckDB. Nothing here knows whether the export came from MS SQL or
|
|
5
|
+
from a file infobase - `dump1c` made them the same - and nothing past here talks to 1C.
|
|
6
|
+
|
|
7
|
+
cur = store.connect().cursor()
|
|
8
|
+
cur.execute('SELECT "_IDRRef", "_EnumOrder" FROM "_Enum12"')
|
|
9
|
+
|
|
10
|
+
The SQL is DuckDB's, so a project's `sources.QUERIES` are too: names in double quotes
|
|
11
|
+
(DuckDB matches them without regard to case), `LIMIT` for `TOP`, `from_hex('..')` - see
|
|
12
|
+
`literal` - for a binary constant. The values are as 1C stores them: a server infobase's
|
|
13
|
+
dates still carry its year offset (`year_offset()`), a boolean is binary(1).
|
|
14
|
+
|
|
15
|
+
The cursor takes parameters the way pyodbc did, `execute(sql, a, b)` or `execute(sql, [a, b])`,
|
|
16
|
+
so the readers of the infobase kept their shape when they stopped reading MS SQL.
|
|
17
|
+
"""
|
|
18
|
+
import codecs
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
import paths
|
|
23
|
+
|
|
24
|
+
DATA = os.path.join(paths.OUT, "data")
|
|
25
|
+
MANIFEST = os.path.join(paths.OUT, "export.json")
|
|
26
|
+
|
|
27
|
+
# A boolean as 1C stores it, binary(1): `_Posted`, `_Marked`, `_Folder`.
|
|
28
|
+
TRUE = b"\x01"
|
|
29
|
+
|
|
30
|
+
_MANIFEST = []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def manifest():
|
|
34
|
+
if not _MANIFEST:
|
|
35
|
+
if not os.path.exists(MANIFEST):
|
|
36
|
+
raise SystemExit("no export in %s - run `1c.py dump1c all` first" % paths.OUT)
|
|
37
|
+
with codecs.open(MANIFEST, "r", "utf-8") as f:
|
|
38
|
+
_MANIFEST.append(json.load(f))
|
|
39
|
+
return _MANIFEST[0]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def year_offset():
|
|
43
|
+
"""What 1C adds to a year when it stores a date: 2000 on MS SQL as a rule, 0 in a file."""
|
|
44
|
+
return int(manifest().get("year_offset", 0))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def source_kind():
|
|
48
|
+
return manifest()["source"]["kind"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def horizon():
|
|
52
|
+
"""The oldest horizon any exported table was read after, or None for a file export.
|
|
53
|
+
|
|
54
|
+
Every change 1C committed below it is in the export. One mark for the whole export
|
|
55
|
+
rather than one per table is the cautious reading: a declared query may join tables
|
|
56
|
+
read at different moments, and a mark too low only re-reads, never misses.
|
|
57
|
+
"""
|
|
58
|
+
marks = [t.get("horizon") for t in manifest()["tables"].values()]
|
|
59
|
+
if not marks or any(m is None for m in marks):
|
|
60
|
+
return None
|
|
61
|
+
return bytes.fromhex(min(marks))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def literal(raw):
|
|
65
|
+
"""A binary constant in SQL - what `0x...` was in T-SQL."""
|
|
66
|
+
return "from_hex('%s')" % bytes(raw).hex()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def quote(name):
|
|
70
|
+
return '"%s"' % name.replace('"', '""')
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Cursor(object):
|
|
74
|
+
def __init__(self, con):
|
|
75
|
+
self.con = con
|
|
76
|
+
|
|
77
|
+
def execute(self, sql, *params):
|
|
78
|
+
if len(params) == 1 and isinstance(params[0], (list, tuple)):
|
|
79
|
+
params = params[0]
|
|
80
|
+
self.con.execute(sql, list(params))
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def description(self):
|
|
85
|
+
return self.con.description
|
|
86
|
+
|
|
87
|
+
def fetchone(self):
|
|
88
|
+
return self.con.fetchone()
|
|
89
|
+
|
|
90
|
+
def fetchall(self):
|
|
91
|
+
return self.con.fetchall()
|
|
92
|
+
|
|
93
|
+
def fetchmany(self, size):
|
|
94
|
+
return self.con.fetchmany(size)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Database(object):
|
|
98
|
+
def __init__(self):
|
|
99
|
+
import duckdb
|
|
100
|
+
self.con = duckdb.connect()
|
|
101
|
+
# Spill under out/, and only so far. A query that loses its window - a LATERAL join
|
|
102
|
+
# DuckDB filters above rather than at the scan - reads all 99.8 M analytic rows and
|
|
103
|
+
# spilled 17 GB before it died, nearly the whole of the disk it shared with the system.
|
|
104
|
+
spill = os.path.join(paths.OUT, ".duckdb")
|
|
105
|
+
self.con.execute("SET temp_directory = '%s'" % spill.replace("\\", "/").replace("'", "''"))
|
|
106
|
+
self.con.execute("SET max_temp_directory_size = '%s'" % os.environ.get("DF1C_DUCKDB_TEMP", "8GB"))
|
|
107
|
+
tables = manifest()["tables"]
|
|
108
|
+
for name in sorted(tables):
|
|
109
|
+
path = os.path.join(DATA, name + ".parquet")
|
|
110
|
+
if os.path.exists(path):
|
|
111
|
+
self.con.execute("CREATE VIEW %s AS SELECT * FROM read_parquet('%s')"
|
|
112
|
+
% (quote(name), path.replace("\\", "/").replace("'", "''")))
|
|
113
|
+
self.tables = tables
|
|
114
|
+
|
|
115
|
+
def cursor(self):
|
|
116
|
+
return Cursor(self.con.cursor())
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_DB = []
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def connect():
|
|
123
|
+
"""One DuckDB per process; each cursor is its own connection to it."""
|
|
124
|
+
if not _DB:
|
|
125
|
+
_DB.append(Database())
|
|
126
|
+
return _DB[0]
|
|
@@ -50,6 +50,7 @@ import sys
|
|
|
50
50
|
import time
|
|
51
51
|
|
|
52
52
|
import migrate
|
|
53
|
+
import transactions
|
|
53
54
|
|
|
54
55
|
GRANULARITIES = ("day", "month", "quarter", "year")
|
|
55
56
|
|
|
@@ -334,8 +335,7 @@ def rebuild(pg, plan):
|
|
|
334
335
|
|
|
335
336
|
insert = ("INSERT INTO %s (%s) VALUES (%s)"
|
|
336
337
|
% (plan.totals, ", ".join(key + cols), ", ".join(["%s"] * (len(key) + len(cols)))))
|
|
337
|
-
pg
|
|
338
|
-
try:
|
|
338
|
+
with transactions.writing(pg):
|
|
339
339
|
for year in years:
|
|
340
340
|
cur.execute("SET LOCAL session_replication_role = 'replica'")
|
|
341
341
|
cur.execute("DELETE FROM %s WHERE %s >= %%s AND %s < %%s"
|
|
@@ -344,8 +344,6 @@ def rebuild(pg, plan):
|
|
|
344
344
|
if by_year.get(year):
|
|
345
345
|
cur.executemany(insert, by_year[year])
|
|
346
346
|
pg.commit()
|
|
347
|
-
finally:
|
|
348
|
-
pg.autocommit = True
|
|
349
347
|
print("%-28s %7d rows written over %d year(s) checks %.0fs, fold %.0fs, write %.0fs"
|
|
350
348
|
% (name(plan), len(rows), len(years), scanned - started, read - scanned,
|
|
351
349
|
time.time() - read))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Transaction cleanup shared by the migration settlement writers."""
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConnectionRecoveryError(RuntimeError):
|
|
6
|
+
"""The connection cannot safely be reused; abort rather than try another module."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def recover(pg):
|
|
10
|
+
"""Rollback before changing mode: psycopg2 forbids it in an active transaction."""
|
|
11
|
+
try:
|
|
12
|
+
pg.rollback()
|
|
13
|
+
pg.autocommit = True
|
|
14
|
+
except Exception as error:
|
|
15
|
+
raise ConnectionRecoveryError("cannot restore migration connection: %s" % error) from error
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@contextmanager
|
|
19
|
+
def writing(pg):
|
|
20
|
+
"""Writers commit explicitly; on any failure, rollback before restoring autocommit."""
|
|
21
|
+
pg.autocommit = False
|
|
22
|
+
try:
|
|
23
|
+
yield
|
|
24
|
+
except BaseException:
|
|
25
|
+
recover(pg)
|
|
26
|
+
raise
|
|
27
|
+
else:
|
|
28
|
+
pg.autocommit = True
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copy to <port root>/.env and fill in. `.env` never goes into a commit.
|
|
2
|
+
#
|
|
3
|
+
# None of these has a default, deliberately: a default naming one infobase would make the
|
|
4
|
+
# toolkit quietly read or write that infobase whenever a variable was forgotten.
|
|
5
|
+
|
|
6
|
+
# DF1C_ROOT and DF1C_PROJECT are exported in the shell, not set here: this file is found
|
|
7
|
+
# through DF1C_ROOT (<root>/.env), so it cannot say where the root is.
|
|
8
|
+
|
|
9
|
+
# The infobase, read-only: a server infobase on MS SQL (with DF1C_USER empty, Windows
|
|
10
|
+
# authentication is used) ...
|
|
11
|
+
DF1C_SERVER=
|
|
12
|
+
DF1C_DB=
|
|
13
|
+
DF1C_USER=
|
|
14
|
+
DF1C_PASS=
|
|
15
|
+
# ... or a file infobase: the 1Cv8.1CD, or the folder holding it. Set this or the four above.
|
|
16
|
+
# Point it at a copy, or close the infobase first.
|
|
17
|
+
DF1C_FILE=
|
|
18
|
+
|
|
19
|
+
# The dForge tenant and its Postgres, for loading and checks
|
|
20
|
+
DF_TENANT=
|
|
21
|
+
DF_PG_HOST=localhost
|
|
22
|
+
DF_PG_PORT=
|
|
23
|
+
DF_PG_USER=
|
|
24
|
+
DF_PG_PASS=
|
|
@@ -6,6 +6,16 @@ records each column's 1C source as it emits the column. These four names exist b
|
|
|
6
6
|
`binding.py` requires them; they stay empty for as long as no module here is written by
|
|
7
7
|
hand. If one ever is, this is where its columns say where they came from - see
|
|
8
8
|
`tools/project/sources.py` for the shape.
|
|
9
|
+
|
|
10
|
+
A query in QUERIES runs on the export (`lib/store.py`), so it is DuckDB SQL, not T-SQL:
|
|
11
|
+
`"_Fld123"` rather than `[_Fld123]`, `LIMIT` rather than `TOP`, `from_hex('01')` rather
|
|
12
|
+
than `0x01`.
|
|
13
|
+
|
|
14
|
+
Put a `/*window:<alias>*/` marker in the WHERE of the scan it narrows, and do not turn
|
|
15
|
+
T-SQL's CROSS APPLY into a LATERAL join: DuckDB applies a filter written above a LATERAL
|
|
16
|
+
after the join, so a query narrowed to one recorder still reads every analytic row - on
|
|
17
|
+
99.8 M it ran out of memory and spilled 17 GB. A nested SELECT over the table, with the
|
|
18
|
+
marker in its own WHERE, ran in under a second. Spill is capped at DF1C_DUCKDB_TEMP (8GB).
|
|
9
19
|
"""
|
|
10
20
|
|
|
11
21
|
ENUMS = {}
|
|
@@ -117,53 +117,54 @@ Two consequences worth designing around:
|
|
|
117
117
|
- **`canExecute: false` is the idiom for an action only automation may fire.** The
|
|
118
118
|
server refuses it through `action.execute`; triggers and scheduled jobs invoke
|
|
119
119
|
the script engine directly and are unaffected.
|
|
120
|
-
- **
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
`[
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
120
|
+
- **The server evaluates the whole formula, and refuses what it cannot read.**
|
|
121
|
+
(Needs platform 1.28.12; older platforms let these through, so the gate is not enforced there.)
|
|
122
|
+
Comparisons between fields and literals (with `AND` / `OR` / `NOT`, a bare
|
|
123
|
+
`true` / `false`) are checked in-process; anything richer — arithmetic, `IN`,
|
|
124
|
+
`BETWEEN`, formula functions, a bare boolean field, `[ref].[col]`
|
|
125
|
+
navigation — is computed in SQL over the record, by the same translator as formula
|
|
126
|
+
columns (chained hops are followed up to a depth cap; a null ref gives `NULL`, which
|
|
127
|
+
counts as not true). A condition the server cannot evaluate (`$[setting]` references, set
|
|
128
|
+
aggregates `SUM` / `COUNT` / `AVG`, an unknown function name, a typo included)
|
|
129
|
+
**refuses the action** with `ACTION_EXECUTION_FAIL` ("cannot be evaluated by the
|
|
130
|
+
server") instead of allowing it. A `NULL` result counts as not true.
|
|
131
|
+
Comparisons themselves are null-safe and two-valued (see `formulas.md`), so an empty
|
|
132
|
+
field does not make `NOT ([x] = 'A')` unknown.
|
|
133
|
+
- **The client has gaps of its own, and its failures enable the button.** It
|
|
134
|
+
throws (and its `catch` **enables** the button) on **ref navigation**
|
|
135
|
+
(`[ref].target`), on **`SUM` / `COUNT` / `AVG`** (deliberately not formula
|
|
136
|
+
functions — counting is a Generated `G` column's job) and on **any name it does
|
|
137
|
+
not know, a typo included** (install refuses the last two, see below).
|
|
138
|
+
- **`$[setting]` references** resolve to `null` in the client (an ordered comparison
|
|
139
|
+
against null is `false`, so it tends to *disable* the button) and are refused by
|
|
140
|
+
the server and at install (platform 1.28.12 and later). Keep settings out of `canExecute` and read them in `execute:` instead.
|
|
141
|
+
- **`TODAY()`** names the requester's calendar day on the server too (their time
|
|
142
|
+
zone: the request's, else the user profile's, else the workspace's, else UTC; a profile zone of
|
|
143
|
+
`UTC` counts as unset). The button is evaluated in the browser and is meant to name the same
|
|
144
|
+
day; `execute:` stays the authority.
|
|
145
|
+
- **Install refuses** a `canExecute` that uses `$[setting]`, `SUM` / `COUNT` / `AVG`
|
|
146
|
+
or an unknown function name (a typo included), since the server could never run
|
|
147
|
+
the action. Read settings and count children in `execute:` instead.
|
|
148
|
+
|
|
149
|
+
| construct | client | server |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| comparisons between fields and literals, `AND` / `OR` / `NOT`, bare `true` / `false` | evaluates | evaluates |
|
|
152
|
+
| arithmetic, `IN`, `BETWEEN`, formula functions, bare boolean field | evaluates | evaluates (SQL) |
|
|
153
|
+
| `[ref].[col]` navigation | **throws, button enabled** | evaluates (SQL) |
|
|
154
|
+
| `SUM` / `COUNT` / `AVG`, unknown function (a typo included) | **throws, button enabled** | **refused at install**; if one is already installed, `action.execute` refuses it |
|
|
155
|
+
| `$[setting]` references | evaluates as null | **refused at install**; if already installed, `action.execute` refuses it |
|
|
156
|
+
|
|
157
|
+
Both evaluators agreeing is the healthy shape; the one to design around is a
|
|
158
|
+
construct the client throws on, which leaves a button that is enabled and then
|
|
159
|
+
refused. Count with a `G` column and compare the scalar (`[detail_count] > 0`);
|
|
160
|
+
denormalise instead of navigating; put anything genuinely unexpressible in
|
|
161
|
+
`execute:` behind `error()`.
|
|
160
162
|
|
|
161
163
|
> Never make `canExecute` the *only* guard for something that must hold. Between
|
|
162
|
-
> the
|
|
163
|
-
>
|
|
164
|
-
>
|
|
165
|
-
>
|
|
166
|
-
> first write.
|
|
164
|
+
> the enqueue-only check on background runs and the fact that the client evaluates
|
|
165
|
+
> over the grid row (which does not carry extension columns) while the server
|
|
166
|
+
> evaluates over a full record load, the authoritative check belongs in
|
|
167
|
+
> `execute:` — validate and `error()` before the first write.
|
|
167
168
|
|
|
168
169
|
```
|
|
169
170
|
canExecute:
|
|
@@ -226,6 +227,8 @@ var note = params[note] // no quotes around param name in this DSL
|
|
|
226
227
|
|
|
227
228
|
### Data operations
|
|
228
229
|
|
|
230
|
+
**No entity rights are checked inside a script.** `insert()`, `update()`, `delete()` and `query()` do not look at the signed-in user's `S`/`I`/`U`/`D` on the entity, nor at column security or the folder's row filter. Only `E` on the action is checked, when it is run. A user with `S` only on an entity can still change it through an action they may execute (core #1580). Treat `E` on an action as write access to everything its script touches; to keep a log free of changes, grant `E` on no action that writes to it.
|
|
231
|
+
|
|
229
232
|
| Function | Returns | Description |
|
|
230
233
|
|---|---|---|
|
|
231
234
|
| `insert('entity', { field: value, … })` | Inserted row object | Insert a new record. Returns the full row including auto-filled columns (PK, audit, number sequences). Access returned fields with `result.field_name`. |
|
|
@@ -359,7 +362,7 @@ error('Insufficient stock. Available: {available}, requested: {requested}',
|
|
|
359
362
|
|
|
360
363
|
| Function | Returns | Description |
|
|
361
364
|
|---|---|---|
|
|
362
|
-
| `printPdf('module.entity', key, templateCd)` | `{ fileId, fileName, mimeType, size, checksum }` | Render a print template to a PDF **as the current user** (the record is read under their rights and the folder's row filter) and archive it. A descriptor, never the bytes: attach it with `sendEmail()`. Always pass a qualified entity code. Not available in a scheduled job or a background action, refused in a computing-only run, max 10 per run; each render holds the action's transaction open. |
|
|
365
|
+
| `printPdf('module.entity', key, templateCd)` | `{ fileId, fileName, mimeType, size, checksum }`, plus `warnings` when a value printed empty because the user may not read it | Render a print template to a PDF **as the current user** (the record is read under their rights and the folder's row filter) and archive it. A descriptor, never the bytes: attach it with `sendEmail()`. Always pass a qualified entity code. Not available in a scheduled job or a background action, refused in a computing-only run, max 10 per run; each render holds the action's transaction open. |
|
|
363
366
|
| `downloadText(content, fileName, encoding?, mimeType?)` | URL string | Hand the user a text file the script built. `encoding` is `utf-8` by default; `windows-1251`, `cp866`, `koi8-u` and other code pages work, and a character the encoding cannot represent is an error. Line endings are written as given (join with `'\r\n'` for CR LF), no BOM. Returns the signed download URL (valid an hour). An action has one download: a later call replaces the earlier. In a background or scheduled run nobody receives it, so pass the URL on in `notify()`. Max 10 MB per file, 10 files per run. |
|
|
364
367
|
| `attachText(content, fileName, encoding?, mimeType?)` | descriptor | Build a text file for `sendEmail()`'s fourth argument, keeping its `mimeType` (e.g. an `.ics` as `text/calendar`). Same encodings as `downloadText()`; max 10 MB per file, 10 files per run shared with the download functions. Works in a scheduled job. |
|
|
365
368
|
| `attachBase64(base64, fileName, mimeType?)` | descriptor | `attachText()` for bytes. |
|
|
@@ -118,8 +118,8 @@ See [flags.md](flags.md) for the flag string, [column-types.md](column-types.md)
|
|
|
118
118
|
|
|
119
119
|
| `viewType` | Description | `viewConfig` | When to use |
|
|
120
120
|
|---|---|---|---|
|
|
121
|
-
| `grid` | Spreadsheet-style table with sortable columns |
|
|
122
|
-
| `list` | Compact list view |
|
|
121
|
+
| `grid` | Spreadsheet-style table with sortable columns | `groupByField`, `groupByOptions`, `collapsedGroups` (all optional) | Default for most entities; bulk data |
|
|
122
|
+
| `list` | Compact list view | `groupByField`, `groupByOptions`, `collapsedGroups`, `inlineAdd`, `levels` (all optional) | Mobile-friendly, card-sized items |
|
|
123
123
|
| `kanban` | Columns grouped by a status field | `groupByField` | Workflow entities with stages (deals, tickets) |
|
|
124
124
|
| `gallery` | Image-forward card grid | — | Image-heavy entities (products, real estate) |
|
|
125
125
|
| `tree-grid` | Hierarchical grid with expand/collapse | — | Tree structures (folders, categories, org charts) |
|
|
@@ -151,6 +151,42 @@ Some view types need additional configuration via the `viewConfig` property:
|
|
|
151
151
|
|
|
152
152
|
`groupByField` — the dropdown/status column to group cards by. Each distinct value becomes a kanban column.
|
|
153
153
|
|
|
154
|
+
### Grid and list grouping
|
|
155
|
+
|
|
156
|
+
A `grid` or `list` view can ship a default grouping. Rows split under a header per value (colour or icon, label, count),
|
|
157
|
+
and a header click folds its group away. Every key is optional; without them the view is ungrouped.
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"tickets_grid": {
|
|
162
|
+
"viewType": "grid",
|
|
163
|
+
"label": "Tickets",
|
|
164
|
+
"dataSources": [{
|
|
165
|
+
"entityCode": "ticket",
|
|
166
|
+
"columns": ["subject", "status", "priority", "assignee_id"]
|
|
167
|
+
}],
|
|
168
|
+
"viewConfig": {
|
|
169
|
+
"groupByField": "status",
|
|
170
|
+
"groupByOptions": ["status", "priority", "assignee_id"],
|
|
171
|
+
"collapsedGroups": ["closed"]
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- `groupByField`: the column the rows are grouped by. It must be a dropdown, flags, reference (`columnType: "R"`) or
|
|
178
|
+
`user` column.
|
|
179
|
+
- `groupByOptions`: the columns the Group menu offers. Each follows the same rule, and the list must include
|
|
180
|
+
`groupByField`. Leave it out to offer every groupable column.
|
|
181
|
+
- `collapsedGroups`: values of `groupByField` whose groups start folded. It needs a valid `groupByField`, and for a
|
|
182
|
+
dropdown each entry must be one of its option values.
|
|
183
|
+
- `inlineAdd` (`list` only): an add row in each group, or at the top when ungrouped.
|
|
184
|
+
|
|
185
|
+
The install refuses a view that breaks these rules, and `dforge_module_validate` reports it first. The default is a
|
|
186
|
+
single level. A user can stack more levels (priority, then status) from the gear menu; that choice lives in their
|
|
187
|
+
session, not in the module. A grouped grid loads up to 2,000 rows instead of paging and warns past that. Needs platform
|
|
188
|
+
1.28.11.
|
|
189
|
+
|
|
154
190
|
### Calendar
|
|
155
191
|
|
|
156
192
|
```json
|
|
@@ -11,11 +11,12 @@ Formulas are used in:
|
|
|
11
11
|
`F` column otherwise: it must parse, call only implemented functions, and not
|
|
12
12
|
aggregate over a 1:N set. See `security.md` → Column-level security.
|
|
13
13
|
- **Action `canExecute:` blocks** — availability checks. Evaluated on both sides
|
|
14
|
-
and **enforced** by `action.execute
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
section in
|
|
14
|
+
and **enforced** by `action.execute`: the server computes the whole formula
|
|
15
|
+
(functions, arithmetic, navigation included) and **refuses** the call when
|
|
16
|
+
it cannot read it (`$[setting]` reads, `SUM` / `COUNT` / `AVG`, unknown
|
|
17
|
+
functions). The client throws on navigation, set aggregates and unknown names,
|
|
18
|
+
which leaves the button enabled. See the `canExecute:` section in
|
|
19
|
+
[action-dsl.md](action-dsl.md).
|
|
19
20
|
- **Default values** — a setting's `formula` (e.g. `"formula": "TODAY()"`) or a formula (`F`) column. Note: entity *data* columns have **no** `default`/`defaultValue` key — model a default with an `F` column or set it in action/trigger logic.
|
|
20
21
|
- **Filter expressions** (partially)
|
|
21
22
|
- **Validation expressions** (partially)
|
|
@@ -142,11 +143,42 @@ The whole date family evaluates identically **client-side and SQL-time** (report
|
|
|
142
143
|
`'bool'`/`'boolean'`, `'date'`, `'datetime'`/`'timestamp'`/`'timestamptz'`
|
|
143
144
|
- `CURRENT_USER_ID()` — the id of the requesting user
|
|
144
145
|
|
|
146
|
+
### Register functions
|
|
147
|
+
|
|
148
|
+
- `SLICE('module.register', 'resource', date, 'dimension', value, …)` — an information
|
|
149
|
+
register's value in effect on `date` (1C `СрезПоследних`): the resource of the newest record
|
|
150
|
+
at or before the date whose dimensions equal the values, null when none is. The register,
|
|
151
|
+
resource and dimension names are text literals; the date and the values are expressions over
|
|
152
|
+
the row, so a document line reads its price on its document's date:
|
|
153
|
+
`SLICE('pricing.item_price', 'price', [doc].[doc_date], 'item_id', [item_id], 'price_type_id', [doc].[price_type_id])`.
|
|
154
|
+
Every dimension must be given; a register without `period` takes `null` as the date.
|
|
155
|
+
- **Computed by the database only** — in a print form, a report, and `data.get` naming the
|
|
156
|
+
column. The browser does not recompute it on edit; it keeps the value the server loaded.
|
|
157
|
+
- Read with the user's rights on the register's records: Select in the folder, the folder's
|
|
158
|
+
row filter, the resource and every dimension column visible in the folder's view. Otherwise
|
|
159
|
+
the cell is empty and the response carries a warning. That includes a row filter over the
|
|
160
|
+
records that cannot be applied (a dotted path or an extension column): the `SLICE()` comes
|
|
161
|
+
back empty with a warning instead of failing the whole query.
|
|
162
|
+
- Install refuses a register that is not an `info` register of this module or of a
|
|
163
|
+
dependency (a register of another kind "has no slice"), an unknown resource or dimension, a
|
|
164
|
+
missing dimension, and a literal `null` as a dimension value or as the date of a register
|
|
165
|
+
that declares a `period` (it matches no record). A `null` that only appears at runtime, such
|
|
166
|
+
as a line with an empty price type, still gives an empty cell, with no warning.
|
|
167
|
+
`dforge_module_validate` and `dforge_module_pack` (the CLI's static checks) catch only a
|
|
168
|
+
malformed call, such as a name that is not a text literal or a dimension without its value;
|
|
169
|
+
whether the register, resource and dimensions exist is known at install.
|
|
170
|
+
- Refused at install in a `G` column: it is computed when a row is written and would not
|
|
171
|
+
follow a record added to the register later. Use an `F` column.
|
|
172
|
+
- Newest record only: there is no `first` mode as `getSlice` has. Each row is one index
|
|
173
|
+
descent of the register's `(dimensions, period)` index, which the platform creates.
|
|
174
|
+
- The date is compared as `period <= date`: against a `timestamp` period, a date-only
|
|
175
|
+
document date is midnight, so a record later that day is not in the slice.
|
|
176
|
+
|
|
145
177
|
### Client-side vs SQL-time evaluation
|
|
146
178
|
|
|
147
179
|
An `F` column is evaluated **client-side** in grids and cards, but translated to **SQL** when a
|
|
148
180
|
report selects it, or when it is used in a filter or sort. **Every function listed here is
|
|
149
|
-
implemented in both
|
|
181
|
+
implemented in both** — except `SLICE()`, which only the database computes — so a column reads the same in a grid and in a report; a test executes
|
|
150
182
|
each one against PostgreSQL and diffs the result against the client runtime.
|
|
151
183
|
|
|
152
184
|
Four deliberate points of agreement, because the engines disagree by nature:
|
|
@@ -33,7 +33,7 @@ Lives in: `logic/jobs.json` at the module root. Schema: [`docs/schemas/jobs.sche
|
|
|
33
33
|
| `timeout` | Yes | | Hard timeout in seconds. Range `(0, 3600]`. **No implicit default.** `timeout > 300` requires `class: "long_running"`. |
|
|
34
34
|
| `class` | No | `"standard"` | `"standard"` (≤ 300 s) or `"long_running"` (≤ 3600 s). Explicit opt-in for heavy jobs. |
|
|
35
35
|
| `concurrency` | No | `1` | Max parallel runs `[1, 5]`. `1` = single-instance. |
|
|
36
|
-
| `idempotencyKey` | No | |
|
|
36
|
+
| `idempotencyKey` | No | | **Not enforced yet.** Accepted and stored; the Scheduler does not read it. Scheduled slots deduplicate `(job, scheduled_at)`, not business keys across slots. Guard business idempotency inside the action; manual triggers skip the slot check. |
|
|
37
37
|
| `timeZone` | No | tenant tz | IANA name. Falls back to `auth.tenant.time_zone`. |
|
|
38
38
|
| `params` | No | `{}` | Static parameters passed to the action as `__params`. Manifest-fixed — an admin cannot change them. For tenant-configurable values use module settings, resolved from the job's folder (below). |
|
|
39
39
|
| `enabled` | No | `true` | `false` fully disables — no scheduler, no manual trigger. |
|
|
@@ -73,7 +73,9 @@ A printed set, nested or not, needs **Select** on its entity in the folder, as `
|
|
|
73
73
|
|
|
74
74
|
A currency column prints its glyph for the common codes (`EUR` → `€`, `USD` → `$`, `GBP` → `£`, `UAH` → `₴`, `PLN` → `zł`); other codes print as the code. `format_currency` follows the same mapping.
|
|
75
75
|
|
|
76
|
-
Formula columns (`F`) are read in the same SQL as the record, as `data.get` does, so a formula that navigates references
|
|
76
|
+
Formula columns (`F`) are read in the same SQL as the record, as `data.get` does, so a formula that navigates references prints what the grid shows, and `TODAY()` / `NOW()` work. A formula uses the built-in formula functions only: it cannot call a module's SQL function. A formula the SQL translator cannot express, or one reading what the user may not read, prints empty, and `print.render` / `print.renderPdf` return it in `warnings` (a line column's prefixed with its set's name); the card's PDF button shows them.
|
|
77
|
+
|
|
78
|
+
A value that changes over time — a price, an employee's position, an organization's code — lives in an information register: print it as it stood on the document's date with a `SLICE()` formula column on the line or the document (see formulas.md). Each line reads its own value in the query that loads the set.
|
|
77
79
|
|
|
78
80
|
**Sets: loading and order.** A set is loaded when the template's Liquid code (`{% … %}` or `{{ … }}`) mentions its name anywhere: a `for`, a filter, a condition, `.size`, `.first`. Plain text, `{% comment %}` / `{% raw %}` blocks, string literals and the member of a path (`rec.lines`) do not count, and a snippet's code is not scanned (mention the set in the host template). Rows come in primary-key order; sort in the template:
|
|
79
81
|
|
|
@@ -85,6 +87,21 @@ Formula columns (`F`) are read in the same SQL as the record, as `data.get` does
|
|
|
85
87
|
|
|
86
88
|
`sort` takes a column code and sorts the value as the template sees it (a dropdown or reference sorts by its label). Text order follows the engine, not the database collation. `{# … #}` after an expression in examples is a note for the reader, not syntax: write a real comment as `{% comment %} … {% endcomment %}`.
|
|
87
89
|
|
|
90
|
+
## Computed values and grouped rows
|
|
91
|
+
|
|
92
|
+
| The form needs | Do |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| A value of the record or of a record it references (the organization's requisites as one block) | An `F` column on the entity: references and built-in functions |
|
|
95
|
+
| A value per line (places, weight through the product's units) | An `F` column on the line entity, printed as `line.<col>` |
|
|
96
|
+
| One total over a set | Over a stored line column (`D`, or a `G` on the line): a `G` column on the document (`SUM([lines].[amount])`). Over a line `F` column: a `plus` accumulator in the loop, `{% assign s = 0 %}{% for l in lines %}{% assign s = s \| plus: l.places %}{% endfor %}`, since a `G` cannot sum an `F` child (install fails with `column old.<field> does not exist`) |
|
|
97
|
+
| Lines grouped by a column, totals per group (by product and series, by VAT rate, a packing list by pallet) | **Not available in the print context yet.** It prints the record and its sets as stored |
|
|
98
|
+
|
|
99
|
+
For the last row, do not work around it:
|
|
100
|
+
|
|
101
|
+
- **Not in Liquid filters.** `where`, `sum`, `group_by` and `where_exp` run in the LiquidJS preview, but Scriban (the print) fails with "The function `where` was not found". Filter and total with `{% if %}` and `plus` inside a `for`, which both engines run.
|
|
102
|
+
- **Not a print table filled by an action.** It stores a copy of the document that drifts from it.
|
|
103
|
+
- **Not a stored procedure.** The grouping itself is a grouped query (`references/queries.md`): `group` on the column, `sum` over a line `F` column.
|
|
104
|
+
|
|
88
105
|
## Custom filters
|
|
89
106
|
|
|
90
107
|
| Filter | Example | Output |
|
|
@@ -113,7 +130,7 @@ Full mechanics + an EPC SEPA QR worked example: [`docs/ui/print-qr-codes.md`](..
|
|
|
113
130
|
|
|
114
131
|
### Amount in words
|
|
115
132
|
|
|
116
|
-
The platform has no number-to-words filter. The `common-words` module (`modules/common-words`, a dependency to declare) ships snippets that write an amount or a plain quantity out in Ukrainian, English or German. Set the inputs, then include:
|
|
133
|
+
The platform has no number-to-words filter. The `common-words` module (`modules/common-words`, a dependency to declare) ships snippets that write an amount or a plain quantity out in Ukrainian, Russian, English or German. Set the inputs, then include:
|
|
117
134
|
|
|
118
135
|
```liquid
|
|
119
136
|
{% assign words_amount = total %}
|
|
@@ -122,9 +139,17 @@ The platform has no number-to-words filter. The `common-words` module (`modules/
|
|
|
122
139
|
{% include 'common-words.money' %}
|
|
123
140
|
```
|
|
124
141
|
|
|
125
|
-
1234.56 EUR prints «eintausendzweihundertvierunddreißig Euro 56 Cent». `words_amount` must be a numeric field, not text. `words_currency` is an ISO 4217 code in any case; an unlisted currency prints its code. `words_lang` is `uk` (default), `en` or `de`; an unknown code prints `[common-words: unknown language xx]`. `words_cap = true` capitalises the first letter. `common-words.money` only picks the language; `money_uk` / `money_en` / `money_de` can be included directly, and `common-words.quantity` writes a count of things («двадцять одна штука», «twenty-one pieces»): `words_number` (numeric, a fraction rounds half up), optional `words_unit` (forms joined by `|`: three for Ukrainian, two for English and German) and `words_gender` (`m` / `f` / `n`), plus `words_lang` and `words_cap` as above. The text lives in the module, so a fix or a new currency is a module update; a new language is a new `money_xx.html` plus a line in `money.html`.
|
|
142
|
+
1234.56 EUR prints «eintausendzweihundertvierunddreißig Euro 56 Cent». `words_amount` must be a numeric field, not text. `words_currency` is an ISO 4217 code in any case; an unlisted currency prints its code. `words_lang` is `uk` (default), `ru`, `en` or `de`; an unknown code prints `[common-words: unknown language xx]`. `words_cap = true` capitalises the first letter. `common-words.money` only picks the language; `money_uk` / `money_ru` / `money_en` / `money_de` can be included directly, and `common-words.quantity` writes a count of things («двадцять одна штука», «twenty-one pieces»): `words_number` (numeric, a fraction rounds half up), optional `words_unit` (forms joined by `|`: three for Ukrainian and Russian, two for English and German) and `words_gender` (`m` / `f` / `n`), plus `words_lang` and `words_cap` as above; `quantity_uk` / `quantity_ru` can be included directly too. The text lives in the module, so a fix or a new currency is a module update; a new language is a new `money_xx.html` plus a line in `money.html`.
|
|
143
|
+
|
|
144
|
+
`quantity_uk` and `quantity_ru` also write a fraction in words. With `words_minor` + `words_minor_digits` (1–6) the fraction gets its own unit, as 1C's ЧислоПрописью does for a weight: «одна тысяча двести пятьдесят кг. пятьсот г.» (a zero fraction prints «ноль г.»). With `words_decimal = true` the number is a decimal fraction up to three digits, trailing zeros dropped: «двадцять одна ціла дванадцять сотих тонни». The unit comes in the 2–4 form, or as `words_unit_fraction` ('кілограма' for a Ukrainian masculine unit). The grammar is correct where 1C's is not: 1.001 is «одна ціла одна тисячна». English and German take neither input.
|
|
145
|
+
|
|
146
|
+
Snippets run on Scriban (print) and LiquidJS (preview), so write them for both:
|
|
126
147
|
|
|
127
|
-
|
|
148
|
+
- No `floor` on big numbers: Scriban's goes through a double and rounds them up.
|
|
149
|
+
- No `modulo: 1` on a fraction without snapping the whole part with `round`. LiquidJS 10.30 leaves float noise: `0.44 | modulo: 1` is 0.43999999999999995.
|
|
150
|
+
- Round a fraction half up on its decimal digits, read from the number's text (`append: ''`; the server may print a comma; a browser double under 1e-6 prints as `5e-7`), never by `times` / `plus: 0.5` / `floor`. A double's text is the shortest decimal it stands for, so this matches the server's decimals both on a tie (960.015 → 02) and just below one (1000000000.0049999 → 00). No epsilon can do both.
|
|
151
|
+
- No number taken from `split` / `slice`. A digit is read as `'x0123456789' | split: ch | first | size | minus: 1`.
|
|
152
|
+
- Plain `{% comment %}` without trim dashes.
|
|
128
153
|
|
|
129
154
|
## Module package layout
|
|
130
155
|
|
|
@@ -187,7 +212,7 @@ my_module/
|
|
|
187
212
|
- **Writing an amount in words by hand.** Include `common-words.money` instead of a custom filter or lookup table.
|
|
188
213
|
- **Module-installed template renamed in editor.** The editor preserves the original `template_cd` so `{% include %}` references stay intact — don't try to work around it.
|
|
189
214
|
|
|
190
|
-
Module-installed print-template rows can't be deleted from the editor
|
|
215
|
+
Module-installed print-template rows can't be deleted from the editor. A template or snippet the package no longer declares is deleted on upgrade (dropped from `print_templates.json`, moved to another entity, or the file gone entirely); tenant-authored rows are never touched. To retire a template, remove it from the package, and remove or repoint any scheduled print subscription that names it: nothing references a template by key, so the subscription is not blocked, and its next run fails and suspends it.
|
|
191
216
|
|
|
192
217
|
## Reference
|
|
193
218
|
|
|
@@ -12,7 +12,8 @@ Lives in: `ui/queries.json`
|
|
|
12
12
|
| Simple aggregation — "totals by X" | **Pre-built query** (`ui/queries.json`) |
|
|
13
13
|
| Ad-hoc exploration by the user | **Query Builder** (runtime, no module authoring needed) |
|
|
14
14
|
| Complex multi-dataset visualizations with charts/KPIs | **Report** (`ui/reports.json`) |
|
|
15
|
-
|
|
|
15
|
+
| Totals grouped by a reference, or summing a value computed per row (lines by product, totals by VAT rate) | **Pre-built query**: `group` on the reference, `sum` over an `F` column of the entity (see *Calculated columns*) |
|
|
16
|
+
| Full SQL with window functions, CTEs, cross-schema, or a formula over an aggregate (`CEIL(SUM(qty) / box)`) | **Report with stored procedure** |
|
|
16
17
|
|
|
17
18
|
Queries fill the gap between views (flat, no aggregation) and reports (heavy, need layout config).
|
|
18
19
|
|
|
@@ -131,6 +132,17 @@ Columns can traverse references up to 2 levels deep:
|
|
|
131
132
|
|
|
132
133
|
The query engine automatically JOINs the referenced entities.
|
|
133
134
|
|
|
135
|
+
A reference path can be grouped or aggregated like any column: `{ "c": "product.unit", "agg": "group" }`.
|
|
136
|
+
|
|
137
|
+
### Calculated columns
|
|
138
|
+
|
|
139
|
+
A value computed per row is aggregated in two ways:
|
|
140
|
+
|
|
141
|
+
- **An `F` column of the entity**, named by its code: `{ "c": "places", "agg": "sum" }`. Its formula may navigate references (`[qty] / [product].[box_qty]`). This is the way for anything that reads another entity.
|
|
142
|
+
- **An ad-hoc formula** in the query: `{ "formula": "[qty] * [price]", "agg": "sum", "alias": "amount" }`. It reads the root entity's own columns only: a navigation path fails the request.
|
|
143
|
+
|
|
144
|
+
The aggregate is applied to the row value, `SUM(formula)`. A formula over the aggregate (`SUM([a]) / COUNT([a])`, `CEIL(SUM([qty]) / [box_qty])`) is not expressible: in grouped mode a column without `agg` is dropped, and `SUM` / `COUNT` are not formula functions. That needs a stored procedure. The query is grouped when at least one column, plain or formula, has `agg: "group"`; there is no `grouping` switch. Aggregates without a `group` column are not a grand total: the request runs as an ungrouped row query and the aggregates are ignored, with no error.
|
|
145
|
+
|
|
134
146
|
### Filters
|
|
135
147
|
|
|
136
148
|
Filters use the standard JSON filter format (see `references/filters.md`): `{ "c": column, "o": operator, "v": value }`.
|
|
@@ -306,7 +318,10 @@ Query names and descriptions are translated in each non-English `translations/<l
|
|
|
306
318
|
|
|
307
319
|
- Putting SQL in the `query` object — **wrong**. Queries use the JSON format, not raw SQL. For raw SQL, use stored procedures in reports.
|
|
308
320
|
- Using `"column"` instead of `"c"` in columns/filters — **wrong**. Use the short keys.
|
|
309
|
-
- Forgetting `"agg"` on columns in grouped mode —
|
|
321
|
+
- Forgetting `"agg"` on columns in grouped mode — a column without one is dropped from the result.
|
|
322
|
+
- Only aggregates, no `group` column, expecting one total row — **runs ungrouped**, the aggregates ignored and no error. Group by a constant-valued column, or total in a `G` column or a report.
|
|
323
|
+
- Writing a stored procedure to group lines and sum them — **wrong tool**. Grouping by a reference and summing an `F` column is a grouped query, and it runs under the user's entity, column and row-level security, which a stored procedure bypasses. Reach for SQL only for what the JSON cannot say (a formula over an aggregate, window functions, CTEs).
|
|
324
|
+
- Navigating a reference inside an ad-hoc `formula` (`[product].[box_qty]`) — **fails**. Declare an `F` column on the entity and aggregate it by code.
|
|
310
325
|
- Referencing columns from unrelated entities — queries can only traverse the root entity's references.
|
|
311
326
|
- Using `@TODAY`, `@CURRENT_USER`, or any `@placeholder` syntax in filter values — **wrong**. Queries do not support parameter substitution. Use the function operator instead: `{ "c": "due_date", "o": "f", "v": { "fn": "isPast" } }`.
|
|
312
327
|
- Using a function as the value of a comparison operator (`<`, `>`, `=`, `!=`, `between`) — **wrong**. Functions are standalone conditions, valid only with `"o": "f"` (full detail + examples under "Dynamic date and user filters" above). `{ "c": "due_date", "o": "<", "v": { "fn": "isToday" } }` is invalid; use `{ "c": "due_date", "o": "f", "v": { "fn": "isPast" } }`.
|