@datarails-ai/dr-cli 0.0.1-locked → 1.6.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +608 -0
  2. package/dist/chunk-DWMCBGQH.js +242 -0
  3. package/dist/cli.js +64468 -0
  4. package/dist/kb-6UWI7Q32.js +21 -0
  5. package/dist/scopes-map.json +4412 -0
  6. package/dist/skills/connector-sandbox/SKILL.md +95 -0
  7. package/dist/skills/datarails-connect-workato/SKILL.md +140 -0
  8. package/dist/skills/datarails-connectors-onboard-connector/SKILL.md +813 -0
  9. package/dist/skills/dr-cli/SKILL.md +659 -0
  10. package/dist/skills/dr-cli/reference/admin.md +796 -0
  11. package/dist/skills/dr-cli/reference/budget-builder.md +151 -0
  12. package/dist/skills/dr-cli/reference/calc-vals.md +73 -0
  13. package/dist/skills/dr-cli/reference/calcmodels.md +76 -0
  14. package/dist/skills/dr-cli/reference/cash.md +61 -0
  15. package/dist/skills/dr-cli/reference/dashboards-widgets.md +85 -0
  16. package/dist/skills/dr-cli/reference/datamappers.md +150 -0
  17. package/dist/skills/dr-cli/reference/desk.md +472 -0
  18. package/dist/skills/dr-cli/reference/filebox-documents.md +373 -0
  19. package/dist/skills/dr-cli/reference/forecast.md +51 -0
  20. package/dist/skills/dr-cli/reference/integrations.md +57 -0
  21. package/dist/skills/dr-cli/reference/lookups.md +168 -0
  22. package/dist/skills/dr-cli/reference/metrics.md +512 -0
  23. package/dist/skills/dr-cli/reference/multi-product.md +180 -0
  24. package/dist/skills/dr-cli/reference/reconciliation-mec.md +76 -0
  25. package/dist/skills/dr-cli/reference/rh-config.md +84 -0
  26. package/dist/skills/dr-cli/reference/safety.md +143 -0
  27. package/dist/skills/dr-cli/reference/spend-control.md +206 -0
  28. package/dist/skills/dr-cli/reference/templates.md +163 -0
  29. package/dist/skills/dr-cli/reference/workflows.md +137 -0
  30. package/dist/skills/dr-error-enrichment/SKILL.md +80 -0
  31. package/dist/skills/dr-error-enrichment/reference/core.md +257 -0
  32. package/dist/skills/dr-error-enrichment/reference/groundcover.md +84 -0
  33. package/dist/skills/dr-error-enrichment/reference/jira-bugs.md +219 -0
  34. package/dist/skills/dr-error-enrichment/reference/safety-backup.md +122 -0
  35. package/dist/skills/dr-error-enrichment-author/SKILL.md +219 -0
  36. package/dist/skills/dr-error-enrichment-improve/SKILL.md +218 -0
  37. package/dist/skills/dr-error-enrichment-maintain/SKILL.md +237 -0
  38. package/dist/skills/dr-error-enrichment-reconcile/SKILL.md +167 -0
  39. package/dist/skills/dr-filebox-api-setup/SKILL.md +170 -0
  40. package/dist/skills/dr-switch-user/SKILL.md +395 -0
  41. package/dist/skills/dr-validate/SKILL.md +392 -0
  42. package/dist/skills/dr-validate/report-template.html +950 -0
  43. package/dist/skills/fx-conversion-matrix/SKILL.md +763 -0
  44. package/dist/skills/fx-conversion-matrix/scripts/_args.mjs +52 -0
  45. package/dist/skills/fx-conversion-matrix/scripts/_xlsx.mjs +241 -0
  46. package/dist/skills/fx-conversion-matrix/scripts/build-matrix.mjs +366 -0
  47. package/dist/skills/fx-conversion-matrix/scripts/gen-mappers.mjs +171 -0
  48. package/dist/skills/fx-conversion-matrix/scripts/rates-to-xlsx.mjs +172 -0
  49. package/dist/skills/fx-onboard/SKILL.md +325 -0
  50. package/dist/skills/mec-table-validation/SKILL.md +383 -0
  51. package/dist/skills/plugins.json +24 -0
  52. package/dist/skills/validate-workato-migration/SKILL.md +167 -0
  53. package/dist/skills/validate-workato-migration/reference/gotchas.md +16 -0
  54. package/dist/skills/validate-workato-migration/reference/staging-orgs.md +26 -0
  55. package/dist/skills/validate-workato-migration/scripts/compare_files.py +255 -0
  56. package/dist/zendeskKb-JPSHVOPH.js +243 -0
  57. package/package.json +56 -9
  58. package/scripts/postinstall.mjs +91 -0
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Argument handling shared by this skill's scripts.
3
+ *
4
+ * Same reasoning as `_xlsx.mjs`: one copy, because a fix to the parser has to
5
+ * reach every script rather than the one someone happened to edit. These scripts
6
+ * can only import from this directory — the skill folder is copied verbatim into
7
+ * the CLI binary and runs under bare `node`, so nothing in `src/` or
8
+ * `node_modules/` is reachable from here.
9
+ */
10
+
11
+ /**
12
+ * Parse `--flag value` / `--flag` pairs.
13
+ *
14
+ * A flag with no value (or followed by another flag) yields `true`, so every
15
+ * caller must check the type before using a value — {@link str} does that.
16
+ */
17
+ export function parseArgs(argv) {
18
+ const out = {};
19
+ for (let i = 0; i < argv.length; i += 1) {
20
+ const a = argv[i];
21
+ if (!a.startsWith('--')) continue;
22
+ const key = a.slice(2);
23
+ const next = argv[i + 1];
24
+ if (next === undefined || next.startsWith('--')) {
25
+ out[key] = true;
26
+ } else {
27
+ out[key] = next;
28
+ i += 1;
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
34
+ /** A parsed flag's value, or undefined when absent or given without one. */
35
+ export function str(args, key) {
36
+ const v = args[key];
37
+ return typeof v === 'string' && v !== '' ? v : undefined;
38
+ }
39
+
40
+ /** Fail with a one-line reason, the way every script here reports bad input. */
41
+ export function die(msg) {
42
+ console.error(`error: ${msg}`);
43
+ process.exit(1);
44
+ }
45
+
46
+ /** A comma-separated currency list, upper-cased and emptied of blanks. */
47
+ export function splitCodes(s) {
48
+ return String(s ?? '')
49
+ .split(',')
50
+ .map((c) => c.trim().toUpperCase())
51
+ .filter((c) => c !== '');
52
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Minimal .xlsx writer, shared by this skill's scripts.
3
+ *
4
+ * Dependency-free on purpose: exceljs and openpyxl are both routinely missing
5
+ * on Windows dev boxes, and a global npm install inside a sandboxed app lands
6
+ * somewhere the operator's own shell cannot see. The output is a stored-entry
7
+ * ZIP (no compression, so no zlib dance) wrapping the smallest OOXML package
8
+ * Excel and the Datarails backend will both accept -- verified by round-tripping
9
+ * a generated file through `dr documents add-functions` and a real filebox
10
+ * calculate.
11
+ *
12
+ * Kept as one module rather than copied per script: a fix to the ZIP or the
13
+ * package layout has to reach every producer, and two copies is how that stops
14
+ * being true. Nothing outside this directory is importable -- the skill folder is
15
+ * copied verbatim into the CLI binary and the scripts run under bare `node` --
16
+ * so the CLI's own `jszip` dependency is genuinely out of reach, not merely
17
+ * unused.
18
+ */
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // ZIP
22
+ // ---------------------------------------------------------------------------
23
+
24
+ const CRC_TABLE = (() => {
25
+ const t = new Int32Array(256);
26
+ for (let n = 0; n < 256; n += 1) {
27
+ let c = n;
28
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
29
+ t[n] = c;
30
+ }
31
+ return t;
32
+ })();
33
+
34
+ function crc32(buf) {
35
+ let c = 0 ^ -1;
36
+ for (let i = 0; i < buf.length; i += 1) c = (c >>> 8) ^ CRC_TABLE[(c ^ buf[i]) & 0xff];
37
+ return (c ^ -1) >>> 0;
38
+ }
39
+
40
+ /**
41
+ * Pack entries into a ZIP with stored (uncompressed) members.
42
+ *
43
+ * Timestamps are pinned to 1980-01-01 so the same inputs give the same bytes --
44
+ * which is what lets a regression test diff a generated workbook against a
45
+ * known-good one.
46
+ *
47
+ * @param {{name: string, data: Buffer}[]} entries
48
+ * @returns {Buffer}
49
+ */
50
+ function zip(entries) {
51
+ const locals = [];
52
+ const central = [];
53
+ let offset = 0;
54
+
55
+ for (const { name, data } of entries) {
56
+ const nameBuf = Buffer.from(name, 'utf8');
57
+ const crc = crc32(data);
58
+
59
+ const local = Buffer.alloc(30 + nameBuf.length);
60
+ local.writeUInt32LE(0x04034b50, 0);
61
+ local.writeUInt16LE(20, 4); // version needed
62
+ local.writeUInt16LE(0, 6); // flags
63
+ local.writeUInt16LE(0, 8); // method: stored
64
+ local.writeUInt16LE(0, 10); // mod time
65
+ local.writeUInt16LE(0x21, 12); // mod date (1980-01-01, deterministic)
66
+ local.writeUInt32LE(crc, 14);
67
+ local.writeUInt32LE(data.length, 18);
68
+ local.writeUInt32LE(data.length, 22);
69
+ local.writeUInt16LE(nameBuf.length, 26);
70
+ local.writeUInt16LE(0, 28);
71
+ nameBuf.copy(local, 30);
72
+ locals.push(local, data);
73
+
74
+ const cd = Buffer.alloc(46 + nameBuf.length);
75
+ cd.writeUInt32LE(0x02014b50, 0);
76
+ cd.writeUInt16LE(20, 4); // version made by
77
+ cd.writeUInt16LE(20, 6); // version needed
78
+ cd.writeUInt16LE(0, 8);
79
+ cd.writeUInt16LE(0, 10);
80
+ cd.writeUInt16LE(0, 12);
81
+ cd.writeUInt16LE(0x21, 14);
82
+ cd.writeUInt32LE(crc, 16);
83
+ cd.writeUInt32LE(data.length, 20);
84
+ cd.writeUInt32LE(data.length, 24);
85
+ cd.writeUInt16LE(nameBuf.length, 28);
86
+ cd.writeUInt16LE(0, 30); // extra len
87
+ cd.writeUInt16LE(0, 32); // comment len
88
+ cd.writeUInt16LE(0, 34); // disk number
89
+ cd.writeUInt16LE(0, 36); // internal attrs
90
+ cd.writeUInt32LE(0, 38); // external attrs
91
+ cd.writeUInt32LE(offset, 42);
92
+ nameBuf.copy(cd, 46);
93
+ central.push(cd);
94
+
95
+ offset += local.length + data.length;
96
+ }
97
+
98
+ const cdBuf = Buffer.concat(central);
99
+ const eocd = Buffer.alloc(22);
100
+ eocd.writeUInt32LE(0x06054b50, 0);
101
+ eocd.writeUInt16LE(0, 4);
102
+ eocd.writeUInt16LE(0, 6);
103
+ eocd.writeUInt16LE(entries.length, 8);
104
+ eocd.writeUInt16LE(entries.length, 10);
105
+ eocd.writeUInt32LE(cdBuf.length, 12);
106
+ eocd.writeUInt32LE(offset, 16);
107
+ eocd.writeUInt16LE(0, 20);
108
+
109
+ return Buffer.concat([...locals, cdBuf, eocd]);
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Cells and sheets
114
+ // ---------------------------------------------------------------------------
115
+
116
+ const xmlEscape = (s) =>
117
+ String(s)
118
+ .replace(/&/g, '&amp;')
119
+ .replace(/</g, '&lt;')
120
+ .replace(/>/g, '&gt;')
121
+ .replace(/"/g, '&quot;');
122
+
123
+ /** 1-based column index -> spreadsheet letters (1 -> A, 27 -> AA). */
124
+ export function colName(n) {
125
+ let s = '';
126
+ let i = n;
127
+ while (i > 0) {
128
+ const rem = (i - 1) % 26;
129
+ s = String.fromCharCode(65 + rem) + s;
130
+ i = Math.floor((i - 1) / 26);
131
+ }
132
+ return s;
133
+ }
134
+
135
+ /** A cell holding literal text. Inline, so no sharedStrings part is needed. */
136
+ export const textCell = (ref, value) =>
137
+ `<c r="${ref}" t="inlineStr"><is><t xml:space="preserve">${xmlEscape(value)}</t></is></c>`;
138
+
139
+ /**
140
+ * A cell holding a formula and NO cached value. Datarails recalculates
141
+ * server-side, and omitting `<v>` keeps a stale number from being mistaken for
142
+ * a real one before the first calculate.
143
+ */
144
+ export const formulaCell = (ref, f) => `<c r="${ref}"><f>${xmlEscape(f)}</f></c>`;
145
+
146
+ /** A cell holding a literal number. */
147
+ export const numberCell = (ref, value) => `<c r="${ref}"><v>${value}</v></c>`;
148
+
149
+ /**
150
+ * Serialise one worksheet.
151
+ *
152
+ * @param {Map<number, string[]>} rows row number -> cell XML, in column order
153
+ */
154
+ export function sheetXml(rows) {
155
+ const body = [...rows.entries()]
156
+ .sort((a, b) => a[0] - b[0])
157
+ .map(([r, cells]) => `<row r="${r}">${cells.join('')}</row>`)
158
+ .join('');
159
+ return (
160
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
161
+ `<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">` +
162
+ `<sheetData>${body}</sheetData></worksheet>`
163
+ );
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Package
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /**
171
+ * Assemble a complete .xlsx from named sheets.
172
+ *
173
+ * @param {{name: string, xml: string}[]} sheets in tab order
174
+ * @param {{fullCalcOnLoad?: boolean}} [opts] `fullCalcOnLoad` forces a full
175
+ * recalculation when the file is opened, so no cached zero survives a round
176
+ * trip. Set it for workbooks carrying formulas; pointless for literal data.
177
+ * @returns {Buffer}
178
+ */
179
+ export function buildWorkbook(sheets, opts = {}) {
180
+ const entries = sheets.map((s, i) => ({
181
+ ...s,
182
+ file: `sheet${i + 1}.xml`,
183
+ rid: `rId${i + 1}`,
184
+ }));
185
+
186
+ const contentTypes =
187
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
188
+ `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
189
+ `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
190
+ `<Default Extension="xml" ContentType="application/xml"/>` +
191
+ `<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>` +
192
+ entries
193
+ .map(
194
+ (s) =>
195
+ `<Override PartName="/xl/worksheets/${s.file}" ` +
196
+ `ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`,
197
+ )
198
+ .join('') +
199
+ `</Types>`;
200
+
201
+ const rootRels =
202
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
203
+ `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
204
+ `<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>` +
205
+ `</Relationships>`;
206
+
207
+ const workbook =
208
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
209
+ `<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ` +
210
+ `xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>` +
211
+ entries
212
+ .map((s, i) => `<sheet name="${xmlEscape(s.name)}" sheetId="${i + 1}" r:id="${s.rid}"/>`)
213
+ .join('') +
214
+ `</sheets>` +
215
+ (opts.fullCalcOnLoad === true ? `<calcPr calcId="0" fullCalcOnLoad="1"/>` : '') +
216
+ `</workbook>`;
217
+
218
+ const workbookRels =
219
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
220
+ `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
221
+ entries
222
+ .map(
223
+ (s) =>
224
+ `<Relationship Id="${s.rid}" ` +
225
+ `Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" ` +
226
+ `Target="worksheets/${s.file}"/>`,
227
+ )
228
+ .join('') +
229
+ `</Relationships>`;
230
+
231
+ return zip([
232
+ { name: '[Content_Types].xml', data: Buffer.from(contentTypes, 'utf8') },
233
+ { name: '_rels/.rels', data: Buffer.from(rootRels, 'utf8') },
234
+ { name: 'xl/workbook.xml', data: Buffer.from(workbook, 'utf8') },
235
+ { name: 'xl/_rels/workbook.xml.rels', data: Buffer.from(workbookRels, 'utf8') },
236
+ ...entries.map((s) => ({
237
+ name: `xl/worksheets/${s.file}`,
238
+ data: Buffer.from(s.xml, 'utf8'),
239
+ })),
240
+ ]);
241
+ }
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build the FX cross-currency conversion workbook.
4
+ *
5
+ * One sheet per target currency, named `To <CCY>`. Handles both source shapes
6
+ * and any number of rate types.
7
+ *
8
+ * SHAPES
9
+ *
10
+ * --shape single-base (default)
11
+ * The source quotes everything against one currency. A cross-rate is then
12
+ * a plain ratio -- rate(source->target) = rate(target)/rate(source) --
13
+ * so there is ONE DR.GET per row per rate type and no lookup per pair.
14
+ * It degenerates correctly when the base is itself a row (its rate is 1).
15
+ *
16
+ * --shape pairs
17
+ * The source already holds directed pairs (a from-currency AND a
18
+ * to-currency dimension). Nothing is derived: each cell is a direct
19
+ * lookup of the supplied pair. A pair the customer did not supply
20
+ * returns 0, silently -- verify coverage BEFORE building (see the skill's
21
+ * Step 0), because this layout cannot tell a missing pair from a zero.
22
+ *
23
+ * RATE TYPES
24
+ *
25
+ * --rate-types "AVG=FX_Avg_Rate,EOP=FX_EOP_Rate"
26
+ * name=function pairs. Each type gets its own column per target currency,
27
+ * because a Datarails function binds exactly one value field. The legacy
28
+ * `--function <NAME>` form is one unnamed type and is still accepted.
29
+ *
30
+ * UDM names the two source rates `Currency AVG rate` (period average, for
31
+ * P&L) and `Currency EOP rate` (end of period, for balance sheet), so
32
+ * `--rate-types "AVG=...,EOP=..." --column-pattern "Currency {type} rate"`
33
+ * reproduces the standard pair. Note EOP, not EOM: every tenant and the
34
+ * MEC playbook spell it end-of-PERIOD.
35
+ *
36
+ * LAYOUTS
37
+ *
38
+ * --layout matrix (wiring A) headers on row 4, read by per-currency
39
+ * datamappers picking cells by coordinate.
40
+ * --layout lut (wiring B) headers on row 1, key columns FIRST: a
41
+ * filebox-sourced LUT resolves keys POSITIONALLY from the header row and
42
+ * silently ignores the payload's keys/fields arrays. The period becomes a
43
+ * real column rather than being implied by the filebox date tag, and the
44
+ * DR.GET helpers move to a `_rates` sheet so each mapped sheet is exactly
45
+ * key | key | value...
46
+ *
47
+ * Alongside the .xlsx this writes `<out>.layout.json` describing every mapped
48
+ * cell. `gen-mappers.mjs --layout-file` consumes it, so the two scripts cannot
49
+ * disagree about a coordinate.
50
+ *
51
+ * Usage:
52
+ * node build-matrix.mjs --out matrix.xlsx --base USD \
53
+ * --rate-types "Avg=FX_Avg_Rate,EOP=FX_EOP_Rate" \
54
+ * --currencies USD,EUR,GBP,ILS [--targets EUR,GBP,ILS] \
55
+ * [--shape single-base|pairs] [--layout matrix|lut] \
56
+ * [--period-field "Reporting Month"] [--currency-field "Posting Currency"] \
57
+ * [--to-currency-field "Reporting Currency"] (pairs only) \
58
+ * [--column-pattern "{type} {ccy} Rate"] [--scenario Actuals]
59
+ *
60
+ * No dependencies -- see `_xlsx.mjs` for why and how.
61
+ */
62
+ import { writeFile } from 'node:fs/promises';
63
+
64
+ import { die, parseArgs, splitCodes, str } from './_args.mjs';
65
+ import {
66
+ buildWorkbook,
67
+ colName,
68
+ formulaCell as formula,
69
+ sheetXml,
70
+ textCell as text,
71
+ } from './_xlsx.mjs';
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Arguments
75
+ // ---------------------------------------------------------------------------
76
+
77
+ const args = parseArgs(process.argv.slice(2));
78
+ const out = args.out ?? 'matrix.xlsx';
79
+ const base = (args.base ?? 'USD').toUpperCase();
80
+ const periodField = str(args, 'period-field') ?? 'Reporting Month';
81
+ const currencyField = str(args, 'currency-field') ?? 'Posting Currency';
82
+ const toCurrencyField = str(args, 'to-currency-field') ?? 'Reporting Currency';
83
+ const scenario = str(args, 'scenario') ?? 'Actuals';
84
+ const layout = args.layout ?? 'matrix';
85
+ const shape = args.shape ?? 'single-base';
86
+
87
+ if (layout !== 'matrix' && layout !== 'lut')
88
+ die(`--layout must be "matrix" or "lut", got "${layout}"`);
89
+ if (shape !== 'single-base' && shape !== 'pairs') {
90
+ die(`--shape must be "single-base" or "pairs", got "${shape}"`);
91
+ }
92
+
93
+ /**
94
+ * Rate types, as {name, fn}. `name` is '' for the legacy single-type form,
95
+ * which keeps its original column naming so existing files round-trip.
96
+ */
97
+ let rateTypes;
98
+ if (str(args, 'rate-types') !== undefined) {
99
+ rateTypes = str(args, 'rate-types')
100
+ .split(',')
101
+ .map((spec) => spec.trim())
102
+ .filter((spec) => spec !== '')
103
+ .map((spec) => {
104
+ const eq = spec.indexOf('=');
105
+ if (eq <= 0 || eq === spec.length - 1) {
106
+ die(`--rate-types entry "${spec}" must be NAME=FUNCTION (e.g. Avg=FX_Avg_Rate)`);
107
+ }
108
+ return { name: spec.slice(0, eq).trim(), fn: spec.slice(eq + 1).trim() };
109
+ });
110
+ } else if (str(args, 'function') !== undefined) {
111
+ rateTypes = [{ name: '', fn: str(args, 'function') }];
112
+ } else {
113
+ die('pass --rate-types "Avg=FX_Avg_Rate[,EOP=FX_EOP_Rate]" (or the legacy --function <NAME>)');
114
+ }
115
+
116
+ const seenNames = new Set();
117
+ for (const t of rateTypes) {
118
+ if (seenNames.has(t.name)) die(`duplicate rate type name "${t.name}"`);
119
+ seenNames.add(t.name);
120
+ if (/\s/.test(t.fn)) die(`function name "${t.fn}" contains whitespace; dr functions reject that`);
121
+ }
122
+
123
+ /** Every currency that gets a ROW (the conversion sources). */
124
+ const currencies = splitCodes(args.currencies);
125
+ /** Every currency that gets a TAB (the conversion targets). Defaults to all. */
126
+ const targets = args.targets === undefined ? currencies : splitCodes(args.targets);
127
+
128
+ if (currencies.length === 0) die('--currencies USD,EUR,GBP is required');
129
+ for (const t of targets) {
130
+ if (!currencies.includes(t)) {
131
+ die(`target ${t} is not in --currencies, so it has no row to convert from`);
132
+ }
133
+ }
134
+ if (shape === 'single-base' && !currencies.includes(base)) {
135
+ die(`base ${base} must appear in --currencies (its own rate is the 1.0 anchor)`);
136
+ }
137
+
138
+ /**
139
+ * Column name for one rate type on one target.
140
+ *
141
+ * The currency goes in the name only WHEN NEEDED -- a table with one reporting
142
+ * currency does not need it, and leaving it out keeps the plain UDM name that MEC
143
+ * and reconciliation expect. Defaults climb that ladder automatically:
144
+ *
145
+ * 1 target `Currency AVG rate` plain UDM
146
+ * 2+ targets `EUR Currency AVG rate` UDM stem, target prefixed
147
+ * explicit pairing `Currency USD to EUR AVG rate` via --column-pattern
148
+ *
149
+ * All three forms exist in the reference org, so none of this is invented. The
150
+ * legacy single-unnamed-type form keeps `Rate to <CCY>` unchanged, because files
151
+ * already built that way must keep round-tripping.
152
+ *
153
+ * Tokens: {type} the rate type, {ccy} the target, {base} the base currency
154
+ * (single-base only -- in pairs mode the source varies per row, so there is no
155
+ * one base to name).
156
+ */
157
+ function defaultColumnPattern() {
158
+ // The legacy single-unnamed-type form is pinned: files already built that way
159
+ // must keep round-tripping byte for byte.
160
+ if (rateTypes.length === 1 && rateTypes[0].name === '') return 'Rate to {ccy}';
161
+ return targets.length > 1 ? '{ccy} Currency {type} rate' : 'Currency {type} rate';
162
+ }
163
+ const columnPattern = str(args, 'column-pattern') ?? defaultColumnPattern();
164
+
165
+ if (columnPattern.includes('{base}') && shape !== 'single-base') {
166
+ die(
167
+ '--column-pattern {base} needs --shape single-base; in pairs mode the source currency varies per row',
168
+ );
169
+ }
170
+
171
+ const columnName = (type, ccy) =>
172
+ columnPattern
173
+ .replaceAll('{type}', type.name)
174
+ .replaceAll('{ccy}', ccy)
175
+ .replaceAll('{base}', base)
176
+ // An unnamed rate type leaves a double space behind; collapse it.
177
+ .replace(/\s+/g, ' ')
178
+ .trim();
179
+
180
+ /**
181
+ * The lookup for one currency under one rate type.
182
+ *
183
+ * The period pair MUST come first, immediately after the function name. Any
184
+ * other order returns 0 with no error, no #NAME?, and nothing else to signal
185
+ * it. DR.MONTH() makes the match period-based rather than exact-timestamp,
186
+ * which is what a Date-typed period field needs.
187
+ */
188
+ function rateFormula(type, code, toCode) {
189
+ const filters =
190
+ toCode === undefined
191
+ ? `"[${currencyField}]","${code}"`
192
+ : `"[${currencyField}]","${code}","[${toCurrencyField}]","${toCode}"`;
193
+ return (
194
+ `_xll.DR.GET(${type.fn},"[${periodField}]",_xll.DR.MONTH(DR_TAG_DATE),` +
195
+ `${filters},"[Scenario]","${scenario}")`
196
+ );
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Sheets
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /** @type {{name: string, xml: string}[]} */
204
+ const sheets = [];
205
+ /** Layout sidecar: what gen-mappers.mjs needs, so it never guesses a cell. */
206
+ const layoutDoc = {
207
+ layout,
208
+ shape,
209
+ base: shape === 'single-base' ? base : null,
210
+ header_row: layout === 'lut' ? 1 : 4,
211
+ currency_field: currencyField,
212
+ period_field: periodField,
213
+ rate_types: rateTypes.map((t) => t.name),
214
+ sheets: [],
215
+ };
216
+
217
+ const headerRow = layout === 'lut' ? 1 : 4;
218
+ const firstDataRow = headerRow + 1;
219
+
220
+ /**
221
+ * Per-type helper columns holding the base-quoted rate. Only single-base needs
222
+ * them -- pairs mode reads its answer directly.
223
+ */
224
+ const needsHelpers = shape === 'single-base';
225
+
226
+ /**
227
+ * Row of `code` in whichever block holds the helper rates. The `_rates` sheet
228
+ * and a matrix tab both start their data at `firstDataRow`, so one expression
229
+ * covers both layouts.
230
+ */
231
+ const helperRow = (code) => currencies.indexOf(code) + firstDataRow;
232
+
233
+ /**
234
+ * Where each rate type's columns sit, resolved ONCE so the header row and the
235
+ * data rows cannot disagree about a coordinate.
236
+ *
237
+ * - `helper` is the base-quoted rate. In `matrix` it interleaves with the
238
+ * output columns (B,D,F...), in `lut` it lives on the `_rates` sheet
239
+ * (B,C,D...), and in pairs mode there is none -- the lookup IS the answer.
240
+ * - `out` is the mapped cross-rate column, the one gen-mappers reads.
241
+ */
242
+ const columnPlan = rateTypes.map((type, ti) => ({
243
+ type,
244
+ helper: needsHelpers ? (layout === 'lut' ? colName(2 + ti) : colName(2 + 2 * ti)) : null,
245
+ out: layout === 'lut' ? colName(3 + ti) : needsHelpers ? colName(3 + 2 * ti) : colName(2 + ti),
246
+ }));
247
+
248
+ if (layout === 'lut' && needsHelpers) {
249
+ // Helpers live off the mapped sheets so each `To <CCY>` tab is exactly
250
+ // key | key | value... and positional key resolution cannot pick up a rate
251
+ // column by mistake.
252
+ const rows = new Map();
253
+ const head = [text(`A${headerRow}`, 'Currency')];
254
+ for (const { type, helper } of columnPlan) {
255
+ head.push(text(`${helper}${headerRow}`, `${base}-based ${type.name || 'rate'}`.trim()));
256
+ }
257
+ rows.set(headerRow, head);
258
+ currencies.forEach((code, i) => {
259
+ const r = firstDataRow + i;
260
+ const cells = [text(`A${r}`, code)];
261
+ for (const { type, helper } of columnPlan) {
262
+ cells.push(formula(`${helper}${r}`, rateFormula(type, code)));
263
+ }
264
+ rows.set(r, cells);
265
+ });
266
+ sheets.push({ name: '_rates', xml: sheetXml(rows) });
267
+ }
268
+
269
+ for (const target of targets) {
270
+ const rows = new Map();
271
+ // Every mapped column, in the order the header row lists them.
272
+ const plan = columnPlan.map((p) => ({ ...p, name: columnName(p.type, target) }));
273
+ const mappedColumns = plan.map((p) => ({ name: p.name, coordinate: `${p.out}${headerRow}` }));
274
+ // The anchor is the TARGET currency's own row, and it is the same for every
275
+ // source row on this tab.
276
+ const targetRow = helperRow(target);
277
+
278
+ if (layout === 'matrix') {
279
+ rows.set(1, [text('A1', `FX Conversion Matrix — convert to ${target}`)]);
280
+ // TEXT() wraps DR_TAG_DATE directly -- NOT DR.MONTH(), which returns a
281
+ // range-like pseudo-value and yields #NAME? when wrapped.
282
+ rows.set(2, [text('A2', 'Month:'), formula('B2', 'TEXT(DR_TAG_DATE,"mmmm yyyy")')]);
283
+
284
+ const head = [text(`A${headerRow}`, 'Currency')];
285
+ for (const { type, helper, out, name } of plan) {
286
+ if (helper !== null) {
287
+ head.push(text(`${helper}${headerRow}`, `${base}-based ${type.name || 'rate'}`.trim()));
288
+ }
289
+ head.push(text(`${out}${headerRow}`, name));
290
+ }
291
+ rows.set(headerRow, head);
292
+
293
+ currencies.forEach((code, i) => {
294
+ const r = firstDataRow + i;
295
+ const cells = [text(`A${r}`, code)];
296
+ for (const { type, helper, out } of plan) {
297
+ if (helper !== null) {
298
+ cells.push(formula(`${helper}${r}`, rateFormula(type, code)));
299
+ // rate(target)/rate(source), anchored on the target's own row.
300
+ cells.push(formula(`${out}${r}`, `$${helper}$${targetRow}/${helper}${r}`));
301
+ } else {
302
+ // Pairs: nothing is derived, the supplied pair IS the answer.
303
+ cells.push(formula(`${out}${r}`, rateFormula(type, code, target)));
304
+ }
305
+ }
306
+ rows.set(r, cells);
307
+ });
308
+ } else {
309
+ // Keys leftmost: the LUT engine takes the first key_len columns as keys.
310
+ const head = [text(`A${headerRow}`, 'Currency'), text(`B${headerRow}`, periodField)];
311
+ for (const { out, name } of plan) head.push(text(`${out}${headerRow}`, name));
312
+ rows.set(headerRow, head);
313
+
314
+ currencies.forEach((code, i) => {
315
+ const r = firstDataRow + i;
316
+ const cells = [
317
+ text(`A${r}`, code),
318
+ // The period must be a real, matchable column here -- a filebox-sourced
319
+ // LUT keys on cell values, not on the version's date tag.
320
+ formula(`B${r}`, 'TEXT(DR_TAG_DATE,"yyyy-mm")'),
321
+ ];
322
+ for (const { type, helper, out } of plan) {
323
+ cells.push(
324
+ helper !== null
325
+ ? formula(`${out}${r}`, `_rates!$${helper}$${targetRow}/_rates!$${helper}$${r}`)
326
+ : formula(`${out}${r}`, rateFormula(type, code, target)),
327
+ );
328
+ }
329
+ rows.set(r, cells);
330
+ });
331
+ }
332
+
333
+ sheets.push({ name: `To ${target}`, xml: sheetXml(rows) });
334
+ layoutDoc.sheets.push({
335
+ sheet: `To ${target}`,
336
+ target,
337
+ currency_coordinate: `A${headerRow}`,
338
+ columns: mappedColumns,
339
+ });
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Package
344
+ // ---------------------------------------------------------------------------
345
+
346
+ // fullCalcOnLoad: every rate cell is a formula, so a cached value from a
347
+ // previous period must never be what the reader sees.
348
+ await writeFile(out, buildWorkbook(sheets, { fullCalcOnLoad: true }));
349
+
350
+ const layoutFile = `${out}.layout.json`;
351
+ await writeFile(layoutFile, `${JSON.stringify(layoutDoc, null, 2)}\n`, 'utf8');
352
+
353
+ console.log(
354
+ `wrote ${out} — shape=${shape}, layout=${layout}` +
355
+ (shape === 'single-base' ? `, base=${base}` : '') +
356
+ `, ${rateTypes.length} rate type(s): ${rateTypes.map((t) => t.name || '(unnamed)').join(', ')}` +
357
+ `, ${targets.length} tab(s): ${targets.map((t) => `To ${t}`).join(', ')}`,
358
+ );
359
+ console.log(`wrote ${layoutFile} — pass it to gen-mappers.mjs --layout-file`);
360
+ if (shape === 'pairs') {
361
+ console.log(
362
+ 'note: pairs mode derives nothing. A pair the source does not carry reads 0 with\n' +
363
+ ' no error -- confirm coverage before trusting this file.',
364
+ );
365
+ }
366
+ console.log('next: dr documents add-functions (Step 4) BEFORE uploading, or every cell reads 0');