@dforge-core/dforge-mcp 0.2.42 → 0.2.44

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 CHANGED
@@ -4,6 +4,43 @@ All notable changes to `@dforge-core/dforge-mcp`. This project uses semver-ish
4
4
  `0.1.0-rc.N` pre-release tags; the published version is set at publish time via
5
5
  the release workflow, so committed `package.json` versions are placeholders.
6
6
 
7
+ ## 0.2.44 — 2026-10-21
8
+
9
+ **Schemas follow metadata 0.0.44.** `dforge://schema/translations` describes `storedProcedures`
10
+ (result-column headers of a stored procedure, shared by every report bound to it) and a report's
11
+ `desc`. `dforge://schema/reports` accepts `fieldTypeCd`, `format` and `baseDatatypeCd` on a dataset's
12
+ `columnsDef`. `dforge://schema/entity` accepts a trait declared in the module's own `traits.json`.
13
+
14
+ **DSL builtins.** `printPdf(entityCd, key, templateCd)` renders a print template to a PDF as the current
15
+ user; `sendEmail()` takes a fourth `attachments` argument (a `printPdf()` result or an array of them);
16
+ `downloadText()` / `downloadBase64()` hand the user a file the script built, in any code page.
17
+
18
+ **Needs `@dforge-core/dforge-cli` 0.2.35**, which compiles the above, requires a translation per
19
+ stored-procedure column on `dforge_module_pack` / `dforge_module_validate`, refuses a request filter
20
+ with an unknown operator instead of dropping it, and reads and generates every module file with LF
21
+ line endings on every platform.
22
+
23
+ ## 0.2.43 — 2026-10-19
24
+
25
+ **Schemas follow metadata 0.0.43.** `dforge://schema/registers` describes `analyticTotals` (an
26
+ accounting register's cache keyed by analytics as well, for the accounts its chart flags), and
27
+ `dforge://schema/entity` describes `lineDateField` (an `A` column dating each line's movement by a
28
+ line column instead of `dateField`).
29
+
30
+ **Print-forms reference.** `references/print-templates.md` now covers what the platform does:
31
+ a child set loads wherever the template names it (`sort`, `.size`, `.first`), with the recipe for
32
+ sorting by one or several columns; formula columns are read from SQL like `data.get`, so `TODAY()`
33
+ works and a reference-navigating formula prints; and an amount or quantity written in words with the
34
+ `common-words` module (`common-words.money` / `common-words.quantity`, languages `uk`, `en`, `de`, the
35
+ inputs, the engine pitfalls). The two mistakes the old text taught (a set loads only from a `for`;
36
+ `TODAY()` is skipped in print) are removed.
37
+
38
+ **Registers reference.** `references/registers.md` describes `analyticTotals` and `lineDateField`.
39
+
40
+ **Needs `@dforge-core/dforge-cli` 0.2.33**, which installs both, adds `register rebuild-totals --cache`
41
+ and `module install --allow-rewrite` (a type change that rewrites a table over 1 GB is refused
42
+ without it).
43
+
7
44
  ## 0.2.41 — 2026-10-15
8
45
 
9
46
  **Schemas follow metadata 0.0.42.** `dforge://schema/entity` describes `periodGuards` (a closed-period
package/dist/server.js CHANGED
@@ -31002,7 +31002,7 @@ var import_node_crypto = require("crypto");
31002
31002
  var fs2 = __toESM(require("fs"));
31003
31003
  var path2 = __toESM(require("path"));
31004
31004
 
31005
- // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.32/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31005
+ // node_modules/.pnpm/@dforge-core+dforge-cli@0.2.35/node_modules/@dforge-core/dforge-cli/dist/lib.mjs
31006
31006
  function buildManifest(opts, moduleId) {
31007
31007
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
31008
31008
  const manifest = {
@@ -31290,7 +31290,7 @@ function buildZedTasks() {
31290
31290
  var fs = __toESM(require("fs"));
31291
31291
  var path = __toESM(require("path"));
31292
31292
 
31293
- // node_modules/.pnpm/@dforge-core+metadata@0.0.42/node_modules/@dforge-core/metadata/dist/index.js
31293
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.44/node_modules/@dforge-core/metadata/dist/index.js
31294
31294
  var AggType = {
31295
31295
  Sum: "sum",
31296
31296
  Count: "count",
@@ -34622,7 +34622,7 @@ var path10 = __toESM(require("path"));
34622
34622
  var fs7 = __toESM(require("fs"));
34623
34623
  var path9 = __toESM(require("path"));
34624
34624
 
34625
- // node_modules/.pnpm/@dforge-core+metadata@0.0.42/node_modules/@dforge-core/metadata/dist/dsl/index.js
34625
+ // node_modules/.pnpm/@dforge-core+metadata@0.0.44/node_modules/@dforge-core/metadata/dist/dsl/index.js
34626
34626
  var PUNCT3 = ["===", "!=="];
34627
34627
  var PUNCT2 = [
34628
34628
  "==",
@@ -35474,9 +35474,9 @@ var BUILTINS = [
35474
35474
  },
35475
35475
  {
35476
35476
  name: "sendEmail",
35477
- signature: "sendEmail(to, subjectOrTemplate, dataOrBody?)",
35477
+ signature: "sendEmail(to, subjectOrTemplate, dataOrBody?, attachments?)",
35478
35478
  returns: "void",
35479
- doc: "Queue an outbound email. Three arguments, and the **second** picks the mode: `sendEmail(to, 'order.shipped', { ref: [code] })` sends a template, `sendEmail(to, 'Your order shipped', '<p>\u2026</p>')` sends raw HTML. A second argument with no spaces that contains `.` or `_` is read as a template code \u2014 so a raw subject like `'Shipped.'` silently becomes one; give a raw subject spaces. There is no fourth argument."
35479
+ doc: "Queue an outbound email. Three arguments, and the **second** picks the mode: `sendEmail(to, 'order.shipped', { ref: [code] })` sends a template, `sendEmail(to, 'Your order shipped', '<p>\u2026</p>')` sends raw HTML. A second argument with no spaces that contains `.` or `_` is read as a template code \u2014 so a raw subject like `'Shipped.'` silently becomes one; give a raw subject spaces. The optional **fourth** argument attaches files: a `printPdf()` result, or an array of them (`[a, b]`; pass a single file as itself, because a one-name `[a]` reads as a field reference in the DSL). Only a `printPdf()` result can be attached, and only by the user who generated it (or a workspace admin); at most 5 files and 10 MB in all per email (`Email:MaxAttachments`, `Email:MaxAttachmentBytes`). A file the sender may not attach fails the action. Not available in a scheduled job, whose emails with attachments are not queued."
35480
35480
  },
35481
35481
  // External calls
35482
35482
  {
@@ -35515,7 +35515,19 @@ var BUILTINS = [
35515
35515
  name: "download",
35516
35516
  signature: "download(url, fileName?)",
35517
35517
  returns: "void",
35518
- doc: "Set a download URL on the action result; the browser opens it when the action returns. It fetches nothing and stores nothing \u2014 the URL must already serve the bytes, e.g. the `/api/temp-file/{token}` a service handed back. To put a remote file INTO storage, fetch it with `callApi()` and write it to a file column."
35518
+ doc: "Set a download URL on the action result; the browser opens it when the action returns. It fetches nothing and stores nothing \u2014 the URL must already serve the bytes, e.g. the `/api/temp-file/{token}` a service handed back. To hand over a file the script builds, use `downloadText()` / `downloadBase64()`. To put a remote file INTO storage, fetch it with `callApi()` and write it to a file column."
35519
+ },
35520
+ {
35521
+ name: "downloadText",
35522
+ signature: "downloadText(content, fileName, encoding?, mimeType?)",
35523
+ returns: "string",
35524
+ doc: "Hand the user a text file the script built: encodes `content` in `encoding` (`utf-8` by default; `windows-1251`, `cp866`, `koi8-u`\u2026 also work), stores it in temp storage and sets it as the action's download. Line endings are written as given (join with `'\\r\\n'` for CR LF), no BOM. Returns the signed `/api/temp-file/{token}` URL, valid for an hour (`FileStorage:TempTokenExpirationMinutes`). An action has one download: a later call replaces the earlier. In a background or scheduled run nobody receives the download, so pass the URL on in `notify()`. Max 10 MB per file, 10 files per run."
35525
+ },
35526
+ {
35527
+ name: "downloadBase64",
35528
+ signature: "downloadBase64(base64, fileName, mimeType?)",
35529
+ returns: "string",
35530
+ doc: "`downloadText()` for bytes: decodes `base64` and hands it to the user as `fileName` (`application/octet-stream` unless `mimeType` says otherwise). Returns the signed download URL."
35519
35531
  },
35520
35532
  {
35521
35533
  name: "getSetting",
@@ -35568,6 +35580,13 @@ var BUILTINS = [
35568
35580
  returns: "link",
35569
35581
  entityArg: 0,
35570
35582
  doc: "Build a clickable link to a record, for an `entitylink` column. Always pass a **qualified** code \u2014 `entity_cd` is only unique per module."
35583
+ },
35584
+ {
35585
+ name: "printPdf",
35586
+ signature: "printPdf(entityCd, key, templateCd)",
35587
+ returns: "object",
35588
+ entityArg: 0,
35589
+ doc: "Render a print template to a PDF **as the current user** and return the stored file as `{ fileId, fileName, mimeType, size, checksum }` \u2014 a descriptor, never the bytes. `key` is the record's `{ column: value }` key (or a scalar for a single-column key); `templateCd` is the template's code on that entity. The record is read under the user's rights and the folder's row filter, as the Generate PDF button reads it. Always pass a **qualified** entity code. Not available in a scheduled job or a background action (no signed-in user), refused in a computing-only run, and limited to 10 per execution; each render holds the action's transaction open while it works."
35571
35590
  }
35572
35591
  ];
35573
35592
  var BUILTIN_BY_NAME = new Map(BUILTINS.map((b) => [b.name, b]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dforge-core/dforge-mcp",
3
- "version": "0.2.42",
3
+ "version": "0.2.44",
4
4
  "description": "MCP server for dForge module authoring. Exposes 35 scaffold/patch/validate/pack/install tools and schema resources so AI agents (Claude Code, Cursor, Zed) can create and ship dForge modules.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/iash44/dForge-core",
@@ -26,8 +26,8 @@
26
26
  "node": ">=18"
27
27
  },
28
28
  "dependencies": {
29
- "@dforge-core/dforge-cli": "^0.2.32",
30
- "@dforge-core/metadata": "^0.0.42",
29
+ "@dforge-core/dforge-cli": "^0.2.35",
30
+ "@dforge-core/metadata": "^0.0.44",
31
31
  "@modelcontextprotocol/sdk": "^1.30.0",
32
32
  "zod": "^4.4.3"
33
33
  },
@@ -503,8 +503,8 @@ Send a notification to a specific user's inbox.
503
503
  notify([approver_id], 'New purchase request from ' + [requester_name] + ' awaiting approval')
504
504
  ```
505
505
 
506
- #### `sendEmail(to, subjectOrTemplateCd, bodyOrData?)`
507
- Two modes:
506
+ #### `sendEmail(to, subjectOrTemplateCd, bodyOrData?, attachments?)`
507
+ Two modes (a second argument with no spaces that contains `.` or `_` is read as a template code, so give a raw subject spaces). The optional fourth argument attaches files: a `printPdf()` result, or an array of them (a single file as itself, not `[a]`). Max 5 files and 10 MB; only the user who generated a PDF, or a workspace admin, can attach it; not in a scheduled job.
508
508
 
509
509
  ```dsl
510
510
  // Raw mode: (to, subject, body)
@@ -567,8 +567,25 @@ if (info.fileSize > 5 * 1024 * 1024) { error('Receipt larger than 5 MB') }
567
567
  if (info.mimeType != 'application/pdf') { error('Receipt must be PDF') }
568
568
  ```
569
569
 
570
+ #### `printPdf(entityCd, key, templateCd)`
571
+ Render a print template to a PDF as the current user and return the archived file as `{ fileId, fileName, mimeType, size, checksum }`; attach it with `sendEmail()`. Pass a qualified entity code. Not in a scheduled job or a background action; max 10 per run.
572
+
573
+ ```dsl
574
+ execute:
575
+ var pdf = printPdf('fin.invoice', { invoice_id: [invoice_id] }, 'invoice')
576
+ sendEmail([customer_id].[email], 'Your invoice ' + [number], '<p>Attached.</p>', pdf)
577
+ ```
578
+
579
+ #### `downloadText(content, fileName, encoding?, mimeType?)` / `downloadBase64(base64, fileName, mimeType?)`
580
+ Hand the user a file the script built. `encoding` defaults to `utf-8`; code pages such as `windows-1251` and `cp866` work; line endings are written as given, no BOM. Both return the signed download URL, which a background or scheduled run can pass on in `notify()`. One download per action (a later call replaces the earlier); max 10 MB per file, 10 files per run.
581
+
582
+ ```dsl
583
+ execute:
584
+ downloadText(csv, 'payments.txt', 'windows-1251')
585
+ ```
586
+
570
587
  #### `download(url, fileName?)`
571
- Set HTTP download response. Only valid in UI-triggered actions; the user's browser receives the file.
588
+ Set HTTP download response. Only valid in UI-triggered actions; the user's browser receives the file. It fetches nothing: the URL must already serve the bytes.
572
589
 
573
590
  ```dsl
574
591
  execute:
@@ -1180,7 +1197,8 @@ The `audit` trait auto-maintains `last_updated` on INSERT/UPDATE if the column e
1180
1197
  | Run SQL | `query('SELECT ... WHERE c = @v', { v: value })` |
1181
1198
  | Get setting | `getSetting('setting_cd')` |
1182
1199
  | Send toast | `info('msg')`, `warn('msg')` |
1183
- | Send email | `sendEmail(to, subj, body)` or `sendEmail(to, templateCd, dataObj)` |
1200
+ | Send email | `sendEmail(to, subj, body)` or `sendEmail(to, templateCd, dataObj)`; add a `printPdf()` result as the 4th argument to attach |
1201
+ | Give the user a built file | `downloadText(content, 'f.txt', 'windows-1251')` |
1184
1202
  | Send inbox notification | `notify(userId, 'msg')` |
1185
1203
  | Call a shared helper | `round2([amount])` from `logic/functions/*.dsl` |
1186
1204
  | Call another module's helper | `cdy_accounting.roleOf(...)`, imported in `deps/<module>.json` |
@@ -108,10 +108,20 @@
108
108
  },
109
109
  "traits": {
110
110
  "type": "array",
111
- "description": "Entity traits expanded during module install (authoring shortcut). Trait definitions loaded from DB (seeded by metadata module).",
111
+ "description": "Entity traits expanded during module install (authoring shortcut): a platform trait, or one the module declares in its own traits.json. Pack and install refuse a name that is neither; the schema cannot see traits.json, so it accepts any trait-shaped name and lists only the platform traits.",
112
112
  "items": {
113
- "type": "string",
114
- "enum": ["identity", "audit", "audit-full", "soft-delete", "sorting", "postable", "accumulation", "ledger", "period", "file-library"]
113
+ "anyOf": [
114
+ {
115
+ "type": "string",
116
+ "enum": ["identity", "audit", "audit-full", "soft-delete", "sorting", "postable", "accumulation", "ledger", "period", "file-library"],
117
+ "description": "Platform trait."
118
+ },
119
+ {
120
+ "type": "string",
121
+ "pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$",
122
+ "description": "A trait declared in the module's own traits.json."
123
+ }
124
+ ]
115
125
  },
116
126
  "uniqueItems": true
117
127
  },
@@ -359,6 +369,7 @@
359
369
  "map": { "type": "object", "additionalProperties": { "oneOf": [ { "type": "string" }, { "type": "object", "required": ["debit", "credit"], "additionalProperties": false, "properties": { "debit": { "type": "string" }, "credit": { "type": "string" } } } ] }, "description": "Register dimension or resource name -> this document's column carrying it. On an accounting (L) column a resource or dimension the register splits by side maps to { \"debit\": \"<line column>\", \"credit\": \"<line column>\" } instead of one column. Every resource the register declares must be mapped (install refuses otherwise); a dimension left out binds to a column of the same name. Only meaningful with 'register'. On an accumulation (A) column a value may be a one-hop reference path, '<reference column>.<target column>', read through the reference at posting time (off the line when the binding posts from lines), or an expression: a string starting with '=' holding an F-column formula over the same row with one-hop reference reads — \"=COALESCE([transit_warehouse_id], [dest_warehouse_id])\"." },
360
370
  "headerMap": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Register dimension or resource name -> this DOCUMENT's column carrying it, for a binding that posts from LINES. A line binding reads every other value off the line, and a dimension that belongs to the document (a dealer, an organisation, a date) cannot be put on the line: a G column is a same-row expression or an aggregate over a child set, so it cannot read a parent. Explicit rather than a fallback, because 'not on the line, try the header' turns a typo into a silent read of an unrelated header column with the same name. Install refuses: a name on neither table, a field also named in 'map', a column also read off the line by 'map', a column that is part of the line's key, a column 'lockedFields' does not freeze (the posting is keyed by it, so it has to hold still while the document is posted), and a RESOURCE unless the register is 'info' (a quantity from the document would be added once per line; an information register's records replace rather than add). A value may be a one-hop reference path off the document, whose reference key column 'lockedFields' must then freeze, or an expression over the document ('=...', see 'map')." },
361
371
  "groupLinesBy": { "type": "array", "items": { "type": "string" }, "minItems": 1, "uniqueItems": true, "description": "Line columns whose distinct values each post ONE movement, instead of one per line — a production report posting 'this unit worked today' once per unit with a count of 1. 'map' is then read over a group row: the grouped line columns, every column of the document, and one-hop reads through a reference of either, so an expression may mix the document and the group key. A quoted constant is stamped once per group; a numeric line column mapped to a resource is summed over the group. Install refuses: a binding that posts from the document, an L column, a grouped column the line does not store or 'lockedLineFields' does not freeze, a line column read that is neither grouped nor a summed resource, a non-numeric summed resource, a column both grouped and summed, an ungrouped line resource on an 'info' register, a name both the line and the document carry, and a document column read that 'lockedFields' does not freeze. Accumulation (A) columns only." },
372
+ "lineDateField": { "type": "string", "description": "A line column dating that line's movement instead of 'dateField' — for lines that happen on days of their own under one document date (a limit card issuing through the month and dated at its end). A line that leaves it empty moves on 'dateField'. With 'periodEntity', each line's period must exist and be open, at posting and unposting and at every write to the line, judged by the date the movement is stamped with (the line's or the document's, after 'dateTrunc'). On an information register that records changes only, two lines may not write one key: post one line per key. Install refuses: a register that keeps no date, a binding that posts from the document, a column that is not a stored 'date' or 'timestamp' without time zone, one 'lockedLineFields' does not freeze, one 'groupLinesBy' omits when lines are grouped, and a binding with 'periodDimField'. Accumulation (A) columns only." },
362
373
  "dateField": { "type": "string", "description": "This document's date column, supplying the date the register stamps on each journal row. Required once the register declares a 'journalDate' — one writer that supplied none would make every balance read as of a date quietly short, across all writers. On an accumulation (A) column it may be a one-hop reference path, '<reference column>.<target column>', or an expression over the document ('=...', see 'map')." },
363
374
  "dateTrunc": { "type": "string", "enum": ["day", "month", "year"], "description": "Takes the date 'dateField' supplies back to the start of its day, month or year before the register stamps it — for a document that carries a time while the register's records sit on whole days. The calendar is the value's as stored; no time zone is applied. Needs 'dateField'. Accumulation (A) columns only." },
364
375
  "skipPostingWhenEmpty": { "type": "array", "items": { "type": "string" }, "description": "THE EXCEPTION, not the normal way to model this — read the guide before declaring it. Register dimensions (by the register's names) whose empty value means THIS POSTING records nothing: the line, or the document when the binding posts from the document. One posting is skipped, never the register and never the document. When that is all the document would have recorded it is left unposted to this register, and the flag is per binding, so its other registers post as usual. An empty dimension NOT listed refuses the posting, naming the row and the dimension. It leaves a document fully posted while some of its lines produced no movement, which usually means the document is modelling two kinds of line in one table: since 'lineEntity' is declared per BINDING, prefer giving each register its own line table (goods lines post to stock, service lines do not) so that every line of each table posts. Declare this only when the distinction is not structural but lives in the data — a batch or serial number filled on some rows and not others — so there is no second table to put those rows in. Accumulation (A) columns only. Formerly skipWhenNull: a package using the old name is refused at install, while a tenant's already-stored params keep being read under it." },
@@ -381,6 +392,7 @@
381
392
  "dependencies": {
382
393
  "headerMap": ["lineEntity"],
383
394
  "groupLinesBy": ["lineEntity"],
395
+ "lineDateField": ["lineEntity", "dateField"],
384
396
  "dateTrunc": ["dateField"],
385
397
  "lineEntity": ["lineField", "lockedLineFields"],
386
398
  "lineField": ["lineEntity"],
@@ -24,7 +24,7 @@
24
24
  "account": false,
25
25
  "debitAccount": false, "creditAccount": false,
26
26
  "debitAnalyticFields": false, "creditAnalyticFields": false,
27
- "sideDimensions": false
27
+ "sideDimensions": false, "analyticTotals": false
28
28
  },
29
29
  "description": "A balance register keeps state, so it needs the entity that holds it. It dates its movements on the journal row under 'journalDate'; 'period' belongs to an information register, whose records ARE the dated thing. Its writer is recorded on the journal row, which is what 'recorder' names for an information register that has no journal."
30
30
  }
@@ -41,7 +41,7 @@
41
41
  "account": false,
42
42
  "debitAccount": false, "creditAccount": false,
43
43
  "debitAnalyticFields": false, "creditAnalyticFields": false,
44
- "sideDimensions": false,
44
+ "sideDimensions": false, "analyticTotals": false,
45
45
  "allowNegative": false,
46
46
  "autoCreate": false
47
47
  },
@@ -60,7 +60,7 @@
60
60
  "account": false,
61
61
  "debitAccount": false, "creditAccount": false,
62
62
  "debitAnalyticFields": false, "creditAnalyticFields": false,
63
- "sideDimensions": false,
63
+ "sideDimensions": false, "analyticTotals": false,
64
64
  "totals": false,
65
65
  "allowNegative": false,
66
66
  "autoCreate": false
@@ -241,6 +241,63 @@
241
241
  }
242
242
  }
243
243
  },
244
+ "analyticTotals": {
245
+ "type": "object",
246
+ "description": "An 'accounting' register's per-period cache keyed by the analytics as well: a cached row is keyed by (account, cached dimensions, analytic slots, period), for the accounts the chart flags. It is what makes a balance READ BY AN ANALYTIC cheap - stock by item and warehouse, settlements by counterparty and contract - which 'totals' leaves to the postings because over a whole ledger the analytics are where the rows are. getAccountBalance / getAccountTurnover read whole periods from it and the part-period at the edge from the postings. Beside 'totals', never instead of it, and in an entity of its own. Like 'totals' it caches the turnover within each period, so a backdated posting touches one row; a write to the journal that was not a posting, or a change to an account's flag, marks it stale until it is rebuilt.",
247
+ "required": ["entity", "period", "periodField", "accountField", "accountFlag", "analyticFields", "resources"],
248
+ "additionalProperties": false,
249
+ "properties": {
250
+ "entity": {
251
+ "type": "string",
252
+ "description": "Entity holding the cached rows. Keyed by ONE surrogate column the platform mints (the 'identity' trait), because a slot or a dimension may be empty and a PRIMARY KEY holds no NULL; the platform keeps a unique index over the cache's own key, NULLS NOT DISTINCT, and merges on it. It carries the account column, a column per cached dimension, the jsonb slot columns, the period column and both columns of every resource."
253
+ },
254
+ "period": {
255
+ "type": "string",
256
+ "enum": ["day", "month", "quarter", "year"],
257
+ "description": "How much one cached row covers, as for 'totals'."
258
+ },
259
+ "periodField": {
260
+ "type": "string",
261
+ "pattern": "^[a-z_][a-z0-9_]*$",
262
+ "description": "Column holding the FIRST date of the period a row covers."
263
+ },
264
+ "accountField": {
265
+ "type": "string",
266
+ "pattern": "^[a-z_][a-z0-9_]*$",
267
+ "description": "Column holding the account a cached row belongs to. A compound posting folds into two rows, one per side, each with that side's analytics."
268
+ },
269
+ "accountFlag": {
270
+ "type": "string",
271
+ "pattern": "^[a-z_][a-z0-9_]*$",
272
+ "description": "A bool column of the register's chart: the accounts where it is true are cached, and no other. Changing it on an account marks the cache stale until it is rebuilt (install, or dforge register rebuild-totals --cache analytic)."
273
+ },
274
+ "dimensions": {
275
+ "type": "array",
276
+ "items": { "type": "string", "pattern": "^[a-z_][a-z0-9_]*$" },
277
+ "description": "Which of the register's dimensions the cache is grouped by; omitted, it is all of them. Unlike 'totals' a nullable one may be cached: an empty value is a key value of its own."
278
+ },
279
+ "analyticFields": {
280
+ "type": "array",
281
+ "minItems": 1,
282
+ "items": { "type": "string", "pattern": "^[a-z_][a-z0-9_]*$" },
283
+ "description": "The cache's jsonb columns holding the analytic slots, slot N in the Nth, one per slot a posting side carries (the wider side's count on a compound register). Each holds the analytic's entity and key, which is what a read filters and groups by."
284
+ },
285
+ "resources": {
286
+ "type": "object",
287
+ "description": "Every resource the register declares, each with its 'debitField' and 'creditField' columns, as for an accounting register's 'totals'.",
288
+ "minProperties": 1,
289
+ "additionalProperties": {
290
+ "type": "object",
291
+ "required": ["debitField", "creditField"],
292
+ "additionalProperties": false,
293
+ "properties": {
294
+ "debitField": { "type": "string", "pattern": "^[a-z_][a-z0-9_]*$" },
295
+ "creditField": { "type": "string", "pattern": "^[a-z_][a-z0-9_]*$" }
296
+ }
297
+ }
298
+ }
299
+ }
300
+ },
244
301
  "period": {
245
302
  "type": "string",
246
303
  "pattern": "^[a-z_][a-z0-9_]*$",
@@ -401,9 +401,13 @@
401
401
 
402
402
  "columnDef": {
403
403
  "type": "object",
404
+ "description": "Override of one column the dataset already projects (from the entity, the stored procedure's result columns, or the register). Name only what differs: each key replaces the projected value. A label here is a literal and wins over a translated header.",
404
405
  "properties": {
405
- "label": { "type": "string" },
406
- "visible": { "type": "boolean" },
406
+ "label": { "type": "string", "description": "Column header. Not translated: leave it out to keep the entity's or the procedure's translated header." },
407
+ "fieldTypeCd": { "type": "string", "description": "Control the value renders with, replacing the projected one." },
408
+ "format": { "type": "string", "description": "Display format, replacing the column's display format." },
409
+ "baseDatatypeCd": { "type": "string", "description": "Read only where the dataset projects no columns of its own (a stored procedure with no result columns), when columnsDef is the column list. Ignored as an override: the projected column's datatype stands." },
410
+ "visible": { "type": "boolean", "description": "Accepted, but neither report.run nor the client reads it: it has no effect." },
407
411
  "width": { "type": "integer", "minimum": 1 }
408
412
  },
409
413
  "additionalProperties": false
@@ -84,6 +84,23 @@
84
84
  "$ref": "#/$defs/report"
85
85
  }
86
86
  },
87
+ "storedProcedures": {
88
+ "type": "object",
89
+ "description": "Stored-procedure code (logic/stored_procedures.json) → result-column headers. A header belongs to the procedure, not to a report: every dataset bound to the procedure shows the same translation. A dataset's columnsDef label overrides it with a literal, so leave the label out of columnsDef to keep the column translated. Required, per declared column, by `module pack` / `module validate` for each non-English supported locale; install only warns.",
90
+ "additionalProperties": {
91
+ "type": "object",
92
+ "additionalProperties": false,
93
+ "properties": {
94
+ "columns": {
95
+ "type": "object",
96
+ "description": "Result column code → header.",
97
+ "additionalProperties": {
98
+ "$ref": "#/$defs/labelOnly"
99
+ }
100
+ }
101
+ }
102
+ }
103
+ },
87
104
  "actions": {
88
105
  "type": "object",
89
106
  "description": "Action code (ui/actions.json) → label, tooltip and parameter labels.",
@@ -248,6 +265,10 @@
248
265
  "label": {
249
266
  "$ref": "#/$defs/label"
250
267
  },
268
+ "desc": {
269
+ "type": "string",
270
+ "description": "Translated report description."
271
+ },
251
272
  "datasets": {
252
273
  "type": "object",
253
274
  "description": "Dataset code → caption.",
@@ -249,6 +249,14 @@ var note = params[note] // no quotes around param name in this DSL
249
249
  > `null` key **throws** (a `col = NULL` predicate can never match), and array keys are rejected —
250
250
  > loop and mutate per id instead.
251
251
 
252
+ > **Posting state.** `update()` setting a register posting state (the `A` / `L` column) posts or unposts
253
+ > the document through the register engine, in a trigger action and in a background run alike; a refused
254
+ > posting rolls the caller's write back. A background run holds no transaction, so such a call opens its
255
+ > own: atomic per call, not per run. A scheduled run cannot post with `update()`.
256
+
257
+ > **Keys of any type.** A field saved with `[field] = value` binds the record key by the key column's own
258
+ > type, so a `uuid` or a digits-only `varchar` key works.
259
+
252
260
  > **These writes raise no events.** `insert()`, `update()`, `delete()` and `query()` are the raw
253
261
  > write layer: by design they skip folder row-filters, closed-period checks and registry state, and
254
262
  > they raise no trigger or webhook events either. Only the **acted-on record's own**
@@ -339,7 +347,16 @@ error('Insufficient stock. Available: {available}, requested: {requested}',
339
347
  | Function | Description |
340
348
  |---|---|
341
349
  | `sendEmail(to, subject, htmlBody)` | Send a raw email. `to` is an email address string. |
342
- | `sendEmail(to, subject, htmlBody, templateCode)` | Send a templated email (if email templates are configured). |
350
+ | `sendEmail(to, templateCode, dataObj)` | Send a templated email. The **second** argument picks the mode: one with no spaces that contains `.` or `_` is read as a template code, so give a raw subject spaces. |
351
+ | `sendEmail(to, subjectOrTemplate, dataOrBody, attachments)` | The optional fourth argument attaches files: a `printPdf()` result, or an array of them (`[a, b]`; pass a single file as itself, because a one-name `[a]` reads as a field reference). Only a `printPdf()` result can be attached, and only by the user who generated it or a workspace admin. At most 5 files and 10 MB per email. A file the sender may not attach fails the action. A scheduled job's email with attachments fails and rolls back. |
352
+
353
+ ### PDF and file output
354
+
355
+ | Function | Returns | Description |
356
+ |---|---|---|
357
+ | `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. |
358
+ | `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. |
359
+ | `downloadBase64(base64, fileName, mimeType?)` | URL string | `downloadText()` for bytes; `application/octet-stream` unless `mimeType` says otherwise. |
343
360
 
344
361
  ### External API Calls
345
362
 
@@ -350,7 +367,7 @@ error('Insufficient stock. Available: {available}, requested: {requested}',
350
367
  | `getFileUrl(fileFieldValue)` | String | Generate a temporary signed download URL (relative path) for a file field. Use in email templates or notifications — not for `callApi()`. |
351
368
  | `getSecret('secret_cd')` | String | Retrieve a decrypted secret value (API keys, tokens). Secrets are managed in the admin UI. |
352
369
  | `getFileInfo(fileFieldValue)` | Object \| null | File metadata for a file field. |
353
- | `download(url, fileName?)` | — | Fetch a URL into tenant storage. |
370
+ | `download(url, fileName?)` | — | Set a download URL on the action result; the browser opens it when the action returns. It fetches and stores nothing: the URL must already serve the bytes. To hand over a file the script builds, use `downloadText()` / `downloadBase64()`. |
354
371
  | `callService('name', { args })` | Any | Invoke a registered host service. |
355
372
 
356
373
  ### Files, settings, document extraction
@@ -311,6 +311,7 @@ The user never sees a record unless it passes **all** layers. You don't need to
311
311
 
312
312
  - **`null` filter** (or omitted) means no filtering — show everything the user has access to.
313
313
  - **Single condition** is valid without a group wrapper.
314
+ - **An unknown operator is refused**, not dropped: a request filter (`data.get`, export, reports, a view's query, DSL `select()`) with an unknown operator, an unknown group combinator or an item that is neither condition nor group fails with `INVALID_REQUEST` naming the operator, its column and the valid set. `like` is not an operator: `mask` is SQL `LIKE`, `start` a case-insensitive prefix. A condition on a column the entity does not have is still dropped.
314
315
  - Use **short operators** (`=`, `!=`, `>`) or **enum keys** (`eq`, `notEq`, `gr`) — both work.
315
316
  - For folder `rowFilter` in module packages, prefer the short compact form: `{ "c": "...", "o": "eq", "v": ... }`.
316
317
  - Dates are ISO strings: `"2026-01-01"`.
@@ -320,6 +321,7 @@ The user never sees a record unless it passes **all** layers. You don't need to
320
321
 
321
322
  - Using the JSON filter format inside `query()` in DSL actions — **wrong**. `query()` uses raw SQL.
322
323
  - Wrapping a single condition in a group unnecessarily — **valid but noisy**. A bare condition is fine.
324
+ - Using `"o": "like"` — **wrong**, refused. Use `"o": "mask"` with `%`.
323
325
  - Using `"operator": "equals"` — **wrong**. Use `"o": "="` or `"o": "eq"`.
324
326
  - Using `"column": "status"` — **wrong**. Use `"c": "status"`.
325
327
  - Forgetting that `null` means "no filter" (not "filter by null") — to filter for null values, use `{ "c": "field", "o": "null" }`.
@@ -8,7 +8,7 @@ Lives in: `ui/print_templates.json` + HTML/CSS files under `print_templates/`. S
8
8
 
9
9
  | Path | Engine | Used by |
10
10
  | ----------- | --------------- | -------------------------------------------------------- |
11
- | **Server** | Scriban (Liquid) | `print.render` RPC — final print / save-as-PDF |
11
+ | **Server** | Scriban (Liquid) | `print.render` RPC; `print.renderPdf` (Generate PDF, headless Chromium); `printPdf()` in a DSL action |
12
12
  | **Client** | LiquidJS | Editor preview — fast iteration |
13
13
 
14
14
  Filters are mirrored on both sides so the preview matches the print.
@@ -62,11 +62,29 @@ Every column on the entity is a top-level variable. Display formatting is pre-ap
62
62
  | `{{ _color.<column> }}` | Option color hex for single-value dropdown/radio columns |
63
63
  | `{{ _settings.<cd> }}` | Folder-resolved module setting. Image/file settings come as base64 data URIs (drop into `<img src>`). |
64
64
  | `{{ _today }}` | Typed `DateTimeOffset` (UTC); `{{ _fmt._today }}` is the pre-formatted string |
65
- | `{{ <set_name> }}` | Child set — array. Iterate with `{% for x in <set_name> %}`. Only loaded if the template iterates it. |
65
+ | `{{ <set_name> }}` | Child set — array, in primary-key order. Loaded wherever the template's Liquid code names it (see below) |
66
66
  | `{{ _css }}` | The CSS string attached to the template (rarely needed in template body) |
67
67
 
68
68
  Inside a child-set loop, the same shape applies per row: `line._fmt.<col>`, `line._raw.<col>`, `line._color.<col>`.
69
69
 
70
+ A loop over a document's set can read a set of the line's own entity off each line (`{% for l in lines %}{% for a in l.attributes %}`): detected by the template reading it, loaded for every line in one query under the same rights and row filter, one level deep. The editor preview does not fill it.
71
+
72
+ A printed set, nested or not, needs **Select** on its entity in the folder, as `data.get` does; one the user cannot read prints empty. `print.render` and `print.renderPdf` check Select on the printed entity.
73
+
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
+
76
+ Formula columns (`F`) are read in the same SQL as the record, as `data.get` does, so a formula that navigates references or calls a SQL function prints what the grid shows, and `TODAY()` / `NOW()` work. A formula the SQL translator cannot express prints empty and the response carries a warning naming the column.
77
+
78
+ **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
+
80
+ ```liquid
81
+ {% assign sorted = doc_lines | sort: 'doc_date' %} {# ascending #}
82
+ {% assign sorted = doc_lines | sort: 'doc_date' | reverse %} {# descending #}
83
+ {% assign s = doc_lines | sort: 'doc_num' | sort: 'doc_date' %} {# date, then number: sort is stable, last key first #}
84
+ ```
85
+
86
+ `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
+
70
88
  ## Custom filters
71
89
 
72
90
  | Filter | Example | Output |
@@ -91,6 +109,21 @@ Declare a snippet with `"type": "snippet"`. No `entityCd`. The `template_cd` mus
91
109
 
92
110
  Full mechanics + an EPC SEPA QR worked example: [`docs/ui/print-qr-codes.md`](../../../docs/ui/print-qr-codes.md).
93
111
 
112
+ ### Amount in words
113
+
114
+ 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:
115
+
116
+ ```liquid
117
+ {% assign words_amount = total %}
118
+ {% assign words_currency = currency_cd %}
119
+ {% assign words_lang = 'de' %}
120
+ {% include 'common-words.money' %}
121
+ ```
122
+
123
+ 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`.
124
+
125
+ Snippets run on Scriban (print) and LiquidJS (preview), so write them for both: no `floor` on big numbers, no number taken from `split` / `slice`, plain `{% comment %}` without trim dashes.
126
+
94
127
  ## Module package layout
95
128
 
96
129
  ```
@@ -142,10 +175,13 @@ my_module/
142
175
  - **Using `_fmt` in a predicate.** `{% if _fmt.amount_paid > 0 %}` compares strings. Use the bare reference: `{% if amount_paid > 0 %}`.
143
176
  - **Forgetting `_raw` for codes.** Direct `{{ status }}` is the label (`"Partially Paid"`). For CSS classes / wire-format payloads / predicates against codes, use `{{ _raw.status }}` (`"P"`).
144
177
  - **Manually formatting dates/currency.** Don't reach for `format_currency` when the column is already `fieldTypeCd: "currency"` — `_fmt.<col>` already matches the form. Use the filter only for derived values.
145
- - **Referencing a child set outside a `{% for %}`.** The platform only loads sets that appear in a `for` loop. `{{ lines.size }}` won't trigger the load.
178
+ - **Expecting a set to load only from a `for`.** It loads wherever the template names it (`sort`, `.size`, `.first`). A set read only inside a snippet is not seen: mention it in the host template.
179
+ - **Testing emptiness with `blank` / `empty`.** LiquidJS (preview) knows them, Scriban (print) does not, so the comparison is always false on the server for an unset field. Use `{% unless x %}`, `{% if x and x != '' %}` or `x != nil`.
180
+ - **Dashed comment tags.** The server cuts every `{%- comment -%}` block before parsing, but keep snippets to plain `{% comment %}` so both engines agree.
146
181
  - **Snippet `template_cd` with a dot.** `{% include 'my_module.foo.bar' %}` — the resolver splits on the last dot only, so `my_module.foo` becomes the module name and breaks. No dots in snippet codes.
147
182
  - **Dropping CSS in `<style>` blocks inline.** The renderer wires `<style>` for you; declare CSS in the separate `.css` file (or inline string via the `css` field).
148
- - **Trying to use `TODAY()` / `NOW()` in a formula column expecting it to render in print.** The print-time formula evaluator skips those. Pre-compute via a formula column at insert time, or use `{{ _today | format_date: '...' }}` directly in Liquid.
183
+ - **Printing an unsorted set.** Rows come in primary-key order; use `| sort: 'col'` (and `| reverse`) in the template.
184
+ - **Writing an amount in words by hand.** Include `common-words.money` instead of a custom filter or lookup table.
149
185
  - **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.
150
186
 
151
187
  Module-installed print-template rows can't be deleted from the editor — uninstall the owning module to remove them.
@@ -155,5 +191,6 @@ Module-installed print-template rows can't be deleted from the editor — uninst
155
191
  - Full developer guide: [`docs/ui/print-templates.md`](../../../docs/ui/print-templates.md)
156
192
  - QR codes + snippets guide: [`docs/ui/print-qr-codes.md`](../../../docs/ui/print-qr-codes.md)
157
193
  - JSON Schema: [`docs/schemas/print_templates.schema.json`](../../../docs/schemas/print_templates.schema.json)
194
+ - Amount in words: [`modules/common-words/`](../../../modules/common-words/) (README lists the inputs)
158
195
  - Reference modules: [`modules/fin/`](../../../modules/fin/), [`modules/fin-ch/`](../../../modules/fin-ch/) (Swiss QR-bill), [`modules/crm/`](../../../modules/crm/), [`modules/wms/`](../../../modules/wms/), [`modules/hr/`](../../../modules/hr/)
159
196
  - Source: `server/src/dForge.Api/Services/PrintTemplateService.cs`, `server/src/dForge.Admin/Services/ModuleInstall/PrintTemplateRegistrar.cs`
@@ -183,6 +183,7 @@ A register column is a `bool` state column. `A` writes `balance`, `turnover` and
183
183
  | `lineEntity` + `lineField` | Post from lines: one movement per line. Declared per binding. Needs `lockedLineFields`. |
184
184
  | `headerMap` | On a line posting, register name to a **document** column. A resource may come from the document only on an `info` register. Every column must be in `lockedFields`. |
185
185
  | `groupLinesBy` | One movement per distinct value of these line columns. `map` is read over a group row; numeric resources are summed. |
186
+ | `lineDateField` | On a line posting, a line column dating that line's movement instead of `dateField`; an empty one falls back to `dateField`. With `periodEntity` each line's period must be open, judged by the movement's stamped date (line's or document's, after `dateTrunc`); lines skipped by `skipPostingWhenEmpty` don't count. On a `recordChangesOnly` info register, one line per key (several dates on a key are refused). A `date` or zoneless `timestamp`, in `lockedLineFields` (and `groupLinesBy` when grouped), not with `periodDimField`. |
186
187
  | `skipPostingWhenEmpty` | Dimensions whose emptiness means "this row posts nothing". The exception, see below. |
187
188
  | `lockedFields` / `lockedLineFields` | Columns frozen while posted. `["*"]` freezes the whole row. |
188
189
  | `movementMapping` | Journal attribute column to document or line column. |
@@ -199,7 +200,7 @@ A register column is a `bool` state column. `A` writes `balance`, `turnover` and
199
200
 
200
201
  The per-side object shape was missing from the entity schema until metadata 0.0.39. Validate with metadata 0.0.39 or later.
201
202
 
202
- Paths, expressions, `dateTrunc`, `headerMap`, `groupLinesBy` and `skipPostingWhenEmpty` are `A`-only. An `L` column refuses them.
203
+ Paths, expressions, `dateTrunc`, `headerMap`, `groupLinesBy`, `lineDateField` and `skipPostingWhenEmpty` are `A`-only. An `L` column refuses them.
203
204
 
204
205
  ### `L` column (`acc-registry.gl_corr`)
205
206
 
@@ -262,6 +263,8 @@ execute:
262
263
 
263
264
  `totals` caches the turnover inside each `day`/`month`/`quarter`/`year`, never a closing balance, so a backdated posting touches one row. Declare an `entity` whose PRIMARY KEY is exactly (dimensions, `periodField`), with `inField`/`outField` per resource (`debitField`/`creditField` and an `accountField` on accounting). Posting maintains it; install rebuilds it when its shape changes. Not available on `info`. Details: registers.md section 8.4.
264
265
 
266
+ An `accounting` register may also declare `analyticTotals`, a second cache keyed by the analytic slots as well: (account, cached dimensions, analytic slots, period). It covers the accounts whose chart flag `accountFlag` is true, lives in an entity of its own beside `totals` (a surrogate key, since a slot or dimension may be empty), and holds `analyticFields` (one jsonb column per slot), `accountField`, `periodField`, `period`, optional `dimensions` and `debitField`/`creditField` per resource. `getAccountBalance` / `getAccountTurnover` read it for a flagged account when every filter and grouping is a cached dimension or an analytic of the account, and take the rest from the postings. A write to the journal around posting, or a changed account flag, marks it stale until `dforge register rebuild-totals --cache analytic` (or a module install) rebuilds it. Not available on `balance`, `turnover` or `info`.
267
+
265
268
  ## Common mistakes
266
269
 
267
270
  - **Undeclared or unqualified register.** `params.register` must be `module_cd.register_cd` and exist. Install fails, not the first posting.
@@ -78,7 +78,7 @@ A report has a `label`, a `description`, a `datasets` map, a `layout` object (`{
78
78
  - `query.columns` — column codes; supports dot navigation (`customer.name`).
79
79
  - `query.filter` — canonical filter (`{c,o,v}` or `{g,i:[...]}`); use `@param_code` to reference a parameter.
80
80
  - `query.sort` — `[{ c, d }]` where `d` ∈ `asc | desc`.
81
- - `columnsDef` — optional per-column display overrides (`{ label, visible, width }`).
81
+ - `columnsDef` — optional per-column display overrides (`{ label, fieldTypeCd, format, width }`; each key replaces the projected value). `label` is a literal and wins over a translated header. `visible` is accepted but read by nothing. `baseDatatypeCd` is read only where the dataset projects no columns of its own.
82
82
 
83
83
  ### Stored procedure dataset (developer path)
84
84
 
@@ -110,6 +110,9 @@ Key differences from entity datasets:
110
110
  **override** — `report.run` projects `dForge.sp_column`, and `columnsDef` only restyles it.
111
111
  A function returning an unnamed `SETOF <composite>` or a bare scalar declares no column
112
112
  names, so that one needs an explicit `columns` block in `logic/stored_procedures.json`.
113
+ Leave `label` out of `columnsDef` to keep the column's translated header: result-column headers
114
+ translate under the procedure, `storedProcedures.<sp_cd>.columns.<column_cd>.label`
115
+ (see `references/translations.md`), and every dataset bound to the procedure shows it.
113
116
  - Params work exactly as they do for an entity dataset, and `datasetType` makes no difference: declare them at report level (`parameters`) or on the dataset (`params`). Report level wins on a code collision.
114
117
  - **The parameter code must match the procedure's declared `paramCd` exactly.** `report.run`
115
118
  resolves each argument by that code alone — no prefix is added or stripped — so a report
@@ -83,7 +83,8 @@ Translation files mirror the module's content structure. Each translatable objec
83
83
  | `views` | Data view labels and descriptions (`label`, `desc`) | View codes (keys from `ui/data_views.json`) → `{ label, desc }` |
84
84
  | `menus` | Menu node labels (root + nested items) | Menu root key → `{ label, items: { item_code: { label } } }` matching `ui/menus.json` structure |
85
85
  | `actions` | Action labels, descriptions, param labels **and param dropdown option labels** | Action codes (keys from `ui/actions.json`) → `{ label, desc, params: { param_cd: { label, options: { value: … } } } }` |
86
- | `reports` | Report dataset captions, param labels **and param dropdown option labels** | Report codes → `{ datasets: { ds_cd: { caption } }, params: { param_cd: { label, options: { value: … } } } }` |
86
+ | `reports` | Report `label`, `desc`, dataset captions, param labels **and param dropdown option labels** | Report codes → `{ datasets: { ds_cd: { caption } }, params: { param_cd: { label, options: { value: … } } } }` |
87
+ | `storedProcedures` | Result-column headers of a stored procedure (`logic/stored_procedures.json`), shared by every report bound to it | Procedure codes → `{ columns: { column_cd: { label } } }` |
87
88
  | `entities.<e>.constraints` | Check/unique constraint violation messages (opt-in — warned, not enforced) | Constraint codes → `{ message }` under the owning entity |
88
89
  | `messages` | DSL `info` / `warn` / `error` / `exit` texts (opt-in — warned, not enforced) | Exact English text → translated text |
89
90
  | `entities.<e>.fields.<f>.options` | Dropdown/radio/flags **option labels** (opt-in) | Option `value` → localized label (string) or `{ label, icon, color }` |
@@ -269,6 +270,10 @@ Non-English locales are declared in `supportedLocales` (an array of `ll-CC` tags
269
270
 
270
271
  Every listed locale MUST have a matching `translations/<locale>.json` with a label for each translatable resource, or install fails completeness validation.
271
272
 
273
+ ### Stored-procedure column headers
274
+
275
+ A result column's header belongs to the procedure, not to a report. `module pack` / `module validate` require one `storedProcedures.<sp_cd>.columns.<column_cd>.label` per declared column for each non-English supported locale; install only warns (also when a translation names a column the module lacks). A dataset's `columnsDef` label is a literal and overrides it, so leave the label out to keep the column translated.
276
+
272
277
  ## Rules
273
278
 
274
279
  1. **Always provide `en-US`** as the base language. Other languages fall back to it for missing keys.