@firedrill-tools/netsuite 0.1.2

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 (92) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +214 -0
  3. package/firedrill/agent.target.json +16 -0
  4. package/firedrill/baseline.scenario.json +6905 -0
  5. package/firedrill/concurrency-limited.scenario.json +11 -0
  6. package/firedrill/conformance.suite.json +21 -0
  7. package/firedrill/ns-analytics.drill.json +73 -0
  8. package/firedrill/ns-concurrency.drill.json +53 -0
  9. package/firedrill/ns-default-identity.drill.json +43 -0
  10. package/firedrill/ns-denied.drill.json +38 -0
  11. package/firedrill/ns-insufficient-permission.drill.json +53 -0
  12. package/firedrill/ns-invalid-login.drill.json +53 -0
  13. package/firedrill/ns-order-to-cash.drill.json +93 -0
  14. package/firedrill/ns-record-locked.drill.json +53 -0
  15. package/firedrill/ns-records.drill.json +68 -0
  16. package/firedrill/ns-role-ar-clerk.drill.json +58 -0
  17. package/firedrill/ns-role-sales-rep.drill.json +58 -0
  18. package/firedrill/ns-subsidiary-scope.drill.json +58 -0
  19. package/firedrill/ns-tight-limits.drill.json +58 -0
  20. package/firedrill/ns-transform-lost.drill.json +63 -0
  21. package/firedrill/ns-write-outage.drill.json +63 -0
  22. package/firedrill/record-locked.scenario.json +11 -0
  23. package/firedrill/tight-limits.scenario.json +17 -0
  24. package/firedrill/tools/netsuite/app/assets/ATTRIBUTION.md +37 -0
  25. package/firedrill/tools/netsuite/app/assets/fonts/LICENSE.txt +92 -0
  26. package/firedrill/tools/netsuite/app/assets/oracle-netsuite.svg +1 -0
  27. package/firedrill/tools/netsuite/app/assets/oracle.svg +1 -0
  28. package/firedrill/tools/netsuite/app/site/app.js +182 -0
  29. package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin-ext.woff2 +0 -0
  30. package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin.woff2 +0 -0
  31. package/firedrill/tools/netsuite/app/site/assets/oracle-netsuite.svg +1 -0
  32. package/firedrill/tools/netsuite/app/site/assets/oracle.svg +1 -0
  33. package/firedrill/tools/netsuite/app/site/icons.js +59 -0
  34. package/firedrill/tools/netsuite/app/site/index.html +68 -0
  35. package/firedrill/tools/netsuite/app/site/menus.js +111 -0
  36. package/firedrill/tools/netsuite/app/site/overlay.js +121 -0
  37. package/firedrill/tools/netsuite/app/site/store.js +95 -0
  38. package/firedrill/tools/netsuite/app/site/styles/base.css +131 -0
  39. package/firedrill/tools/netsuite/app/site/styles/chrome.css +183 -0
  40. package/firedrill/tools/netsuite/app/site/styles/pages.css +134 -0
  41. package/firedrill/tools/netsuite/app/site/styles/tables.css +92 -0
  42. package/firedrill/tools/netsuite/app/site/ui.js +119 -0
  43. package/firedrill/tools/netsuite/app/site/views/common.js +134 -0
  44. package/firedrill/tools/netsuite/app/site/views/customer-form.js +152 -0
  45. package/firedrill/tools/netsuite/app/site/views/customer.js +147 -0
  46. package/firedrill/tools/netsuite/app/site/views/customers.js +111 -0
  47. package/firedrill/tools/netsuite/app/site/views/home-chrome.js +75 -0
  48. package/firedrill/tools/netsuite/app/site/views/home-data.js +153 -0
  49. package/firedrill/tools/netsuite/app/site/views/home.js +162 -0
  50. package/firedrill/tools/netsuite/app/site/views/invoice-new.js +131 -0
  51. package/firedrill/tools/netsuite/app/site/views/invoice.js +232 -0
  52. package/firedrill/tools/netsuite/app/site/views/invoices.js +99 -0
  53. package/firedrill/tools/netsuite/app/site/views/items.js +95 -0
  54. package/firedrill/tools/netsuite/app/site/views/order-new.js +154 -0
  55. package/firedrill/tools/netsuite/app/site/views/order.js +214 -0
  56. package/firedrill/tools/netsuite/app/site/views/orders.js +93 -0
  57. package/firedrill/tools/netsuite/app/site/views/payments.js +65 -0
  58. package/firedrill/tools/netsuite/app/site/views/pickers.js +117 -0
  59. package/firedrill/tools/netsuite/app/site/views/subsidiaries.js +40 -0
  60. package/firedrill/tools/netsuite/app/site/views/suiteql.js +81 -0
  61. package/firedrill/tools/netsuite/behavior.mjs +13 -0
  62. package/firedrill/tools/netsuite/lib/body.mjs +185 -0
  63. package/firedrill/tools/netsuite/lib/collections.mjs +202 -0
  64. package/firedrill/tools/netsuite/lib/errors.mjs +91 -0
  65. package/firedrill/tools/netsuite/lib/handlers-meta.mjs +100 -0
  66. package/firedrill/tools/netsuite/lib/handlers-read.mjs +141 -0
  67. package/firedrill/tools/netsuite/lib/handlers-session.mjs +52 -0
  68. package/firedrill/tools/netsuite/lib/handlers-write.mjs +6 -0
  69. package/firedrill/tools/netsuite/lib/metadata.mjs +148 -0
  70. package/firedrill/tools/netsuite/lib/primitives.mjs +105 -0
  71. package/firedrill/tools/netsuite/lib/project-tran.mjs +153 -0
  72. package/firedrill/tools/netsuite/lib/project.mjs +178 -0
  73. package/firedrill/tools/netsuite/lib/q.mjs +280 -0
  74. package/firedrill/tools/netsuite/lib/session.mjs +188 -0
  75. package/firedrill/tools/netsuite/lib/suiteql-parse.mjs +382 -0
  76. package/firedrill/tools/netsuite/lib/suiteql-run.mjs +270 -0
  77. package/firedrill/tools/netsuite/lib/suiteql-select.mjs +118 -0
  78. package/firedrill/tools/netsuite/lib/suiteql-tables.mjs +162 -0
  79. package/firedrill/tools/netsuite/lib/tran.mjs +133 -0
  80. package/firedrill/tools/netsuite/lib/wire-decode.mjs +107 -0
  81. package/firedrill/tools/netsuite/lib/wire.mjs +228 -0
  82. package/firedrill/tools/netsuite/lib/write-customer.mjs +181 -0
  83. package/firedrill/tools/netsuite/lib/write-invoice.mjs +319 -0
  84. package/firedrill/tools/netsuite/lib/write-order.mjs +269 -0
  85. package/firedrill/tools/netsuite/netsuite.tool.json +4888 -0
  86. package/firedrill/transform-response-lost.scenario.json +11 -0
  87. package/firedrill/world.json +7711 -0
  88. package/firedrill/write-outage.scenario.json +11 -0
  89. package/firedrill.json +5 -0
  90. package/package.json +52 -0
  91. package/starter.json +6904 -0
  92. package/test/conformance.mjs +701 -0
@@ -0,0 +1,270 @@
1
+ // SuiteQL evaluation: resolve the parsed plan against the analytics tables and render every scalar as a string.
2
+ import { quote } from "./errors.mjs";
3
+ import { parseSql, SqlError, sqlBad } from "./suiteql-parse.mjs";
4
+ import { buildTables, COLUMNS, displayIndex, DF_SOURCE, TABLES } from "./suiteql-tables.mjs";
5
+
6
+ const canonicalTable = (name) => {
7
+ const canonical = TABLES.get(name.toLowerCase());
8
+ if (canonical === undefined) sqlBad(`unknown table ${quote(name)}`);
9
+ return canonical;
10
+ };
11
+
12
+ class Scope {
13
+ constructor() {
14
+ this.sources = [];
15
+ }
16
+
17
+ add(canonical, alias) {
18
+ const key = (alias ?? canonical).toLowerCase();
19
+ if (this.sources.some((source) => source.key === key)) sqlBad(`duplicate table alias ${quote(alias ?? canonical)}`);
20
+ this.sources.push({ key, canonical, columns: new Set(COLUMNS[canonical]) });
21
+ }
22
+
23
+ /** Resolve a column reference to the flat row key `<alias>.<column>`. */
24
+ resolve(reference) {
25
+ const column = reference.name.toLowerCase();
26
+ if (reference.qualifier !== null) {
27
+ const source = this.sources.find((entry) => entry.key === reference.qualifier.toLowerCase());
28
+ if (source === undefined) sqlBad(`unknown table qualifier ${quote(reference.qualifier)}`);
29
+ if (!source.columns.has(column)) sqlBad(`unknown column ${quote(`${reference.qualifier}.${reference.name}`)}`);
30
+ return { key: `${source.key}.${column}`, column, canonical: source.canonical };
31
+ }
32
+ const matches = this.sources.filter((entry) => entry.columns.has(column));
33
+ if (matches.length === 0) sqlBad(`unknown column ${quote(reference.name)}`);
34
+ if (matches.length > 1) sqlBad(`column ${quote(reference.name)} is ambiguous; qualify it with its table`);
35
+ return { key: `${matches[0].key}.${column}`, column, canonical: matches[0].canonical };
36
+ }
37
+
38
+ star(qualifier) {
39
+ if (qualifier === null) {
40
+ if (this.sources.length > 1) sqlBad("SELECT * needs a table qualifier when the statement joins tables");
41
+ return this.sources.flatMap((source) => [...source.columns].map((column) => ({ key: `${source.key}.${column}`, name: column })));
42
+ }
43
+ const source = this.sources.find((entry) => entry.key === qualifier.toLowerCase());
44
+ if (source === undefined) sqlBad(`unknown table qualifier ${quote(qualifier)}`);
45
+ return [...source.columns].map((column) => ({ key: `${source.key}.${column}`, name: column }));
46
+ }
47
+ }
48
+
49
+ const flatten = (source, key, row) => {
50
+ const flat = {};
51
+ for (const column of COLUMNS[source]) flat[`${key}.${column}`] = row[column] ?? null;
52
+ return flat;
53
+ };
54
+
55
+ /** Both sides of a join must be plain column equalities so the right table can be indexed once. */
56
+ function joinKeys(node, scope, rightKey, keys = []) {
57
+ if (node.kind === "and") {
58
+ joinKeys(node.left, scope, rightKey, keys);
59
+ joinKeys(node.right, scope, rightKey, keys);
60
+ return keys;
61
+ }
62
+ if (node.kind !== "compare" || node.operator !== "=") {
63
+ sqlBad("a JOIN condition must be one or more column equalities");
64
+ }
65
+ if (node.left.kind !== "column" || node.right.kind !== "column") {
66
+ sqlBad("a JOIN condition must compare two columns");
67
+ }
68
+ const left = scope.resolve(node.left);
69
+ const right = scope.resolve(node.right);
70
+ const leftIsRightTable = left.key.startsWith(`${rightKey}.`);
71
+ const rightIsRightTable = right.key.startsWith(`${rightKey}.`);
72
+ if (leftIsRightTable === rightIsRightTable) sqlBad("a JOIN condition must relate the joined table to an earlier one");
73
+ keys.push(leftIsRightTable ? { outer: right.key, inner: left.key } : { outer: left.key, inner: right.key });
74
+ return keys;
75
+ }
76
+
77
+ const text = (value) => (value === null || value === undefined ? "" : String(value));
78
+
79
+ /** One aggregate over a group bucket's already-evaluated argument values. */
80
+ export function aggregate(name, values, distinct) {
81
+ const present = values.filter((entry) => entry !== null && entry !== undefined);
82
+ if (name === "COUNT") {
83
+ return distinct === true ? new Set(present.map((entry) => String(entry))).size : present.length;
84
+ }
85
+ if (present.length === 0) return null;
86
+ const numbers = present.map((entry) => {
87
+ const parsed = typeof entry === "number" ? entry : Number(entry);
88
+ if (!Number.isFinite(parsed)) sqlBad(`${name} needs numeric values`);
89
+ return parsed;
90
+ });
91
+ const total = numbers.reduce((sum, entry) => sum + entry, 0);
92
+ if (name === "SUM") return Math.round(total * 1e6) / 1e6;
93
+ if (name === "AVG") return Math.round((total / numbers.length) * 1e6) / 1e6;
94
+ if (name === "MIN") return Math.min(...numbers);
95
+ return Math.max(...numbers);
96
+ }
97
+
98
+ function likeMatch(value, pattern) {
99
+ const subject = text(value).toLowerCase();
100
+ const source = text(pattern).toLowerCase();
101
+ let index = 0;
102
+ let anchored = true;
103
+ let cursor = 0;
104
+ while (index < source.length) {
105
+ if (source[index] === "%") {
106
+ anchored = false;
107
+ index += 1;
108
+ continue;
109
+ }
110
+ let segment = "";
111
+ let singles = 0;
112
+ while (index < source.length && source[index] !== "%") {
113
+ if (source[index] === "_") singles += 1;
114
+ else segment += source[index];
115
+ index += 1;
116
+ }
117
+ if (singles > 0) {
118
+ // `_` is matched positionally: consume one character per underscore around the literal segment.
119
+ if (anchored) {
120
+ if (cursor + singles + segment.length > subject.length) return false;
121
+ if (!subject.startsWith(segment, cursor + singles)) return false;
122
+ cursor += singles + segment.length;
123
+ } else {
124
+ const found = subject.indexOf(segment, cursor + singles);
125
+ if (found < 0) return false;
126
+ cursor = found + segment.length;
127
+ }
128
+ } else if (anchored) {
129
+ if (!subject.startsWith(segment, cursor)) return false;
130
+ cursor += segment.length;
131
+ } else {
132
+ const found = subject.indexOf(segment, cursor);
133
+ if (found < 0) return false;
134
+ cursor = found + segment.length;
135
+ }
136
+ anchored = true;
137
+ }
138
+ return anchored ? cursor === subject.length : true;
139
+ }
140
+
141
+ function compareValues(left, right) {
142
+ if (left === null || right === null) return null;
143
+ const leftNumber = typeof left === "number" ? left : Number(left);
144
+ const rightNumber = typeof right === "number" ? right : Number(right);
145
+ if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber) && text(left).trim() !== "" && text(right).trim() !== "") {
146
+ return leftNumber < rightNumber ? -1 : leftNumber > rightNumber ? 1 : 0;
147
+ }
148
+ const leftText = text(left);
149
+ const rightText = text(right);
150
+ return leftText < rightText ? -1 : leftText > rightText ? 1 : 0;
151
+ }
152
+
153
+ export function runSql(session, statement, params) {
154
+ const plan = parseSql(statement);
155
+ const scope = new Scope();
156
+ const tables = buildTables(session);
157
+ const fromCanonical = canonicalTable(plan.from.name);
158
+ scope.add(fromCanonical, plan.from.alias);
159
+ const fromKey = scope.sources[0].key;
160
+ let rows = tables.rows(fromCanonical).map((row) => flatten(fromCanonical, fromKey, row));
161
+
162
+ for (const join of plan.joins) {
163
+ const canonical = canonicalTable(join.table.name);
164
+ scope.add(canonical, join.table.alias);
165
+ const key = scope.sources[scope.sources.length - 1].key;
166
+ const keys = joinKeys(join.on, scope, key, []);
167
+ const index = new Map();
168
+ const rightRows = tables.rows(canonical).map((row) => flatten(canonical, key, row));
169
+ for (const row of rightRows) {
170
+ const composite = keys.map((entry) => text(row[entry.inner])).join("\u0000");
171
+ const bucket = index.get(composite);
172
+ if (bucket === undefined) index.set(composite, [row]);
173
+ else bucket.push(row);
174
+ }
175
+ const joined = [];
176
+ const empty = Object.fromEntries(COLUMNS[canonical].map((column) => [`${key}.${column}`, null]));
177
+ for (const row of rows) {
178
+ const composite = keys.map((entry) => text(row[entry.outer])).join("\u0000");
179
+ const matches = index.get(composite);
180
+ if (matches === undefined) {
181
+ if (join.kind === "left") joined.push({ ...row, ...empty });
182
+ continue;
183
+ }
184
+ for (const match of matches) joined.push({ ...row, ...match });
185
+ }
186
+ rows = joined;
187
+ }
188
+
189
+ const display = displayIndex(session);
190
+ if (plan.paramCount > params.length) {
191
+ sqlBad(`the statement uses ${plan.paramCount} ? parameters but ${params.length} were supplied`);
192
+ }
193
+
194
+ function value(node, row) {
195
+ if (node.kind === "literal") return node.value;
196
+ if (node.kind === "param") return params[node.index] ?? null;
197
+ if (node.kind === "column") return row[scope.resolve(node).key] ?? null;
198
+ if (node.kind === "df") {
199
+ const resolved = scope.resolve(node.column);
200
+ const source = DF_SOURCE[resolved.column];
201
+ if (source === undefined) sqlBad(`BUILTIN.DF is not supported for column ${quote(node.column.name)}`);
202
+ const raw = row[resolved.key] ?? null;
203
+ if (source === "status") return raw;
204
+ if (raw === null) return null;
205
+ return display.get(source)?.get(String(raw)) ?? null;
206
+ }
207
+ if (node.kind === "call") {
208
+ const args = node.args.map((argument) => value(argument, row));
209
+ if (node.name === "UPPER") return args[0] === null ? null : text(args[0]).toUpperCase();
210
+ if (node.name === "LOWER") return args[0] === null ? null : text(args[0]).toLowerCase();
211
+ return args[0] === null ? (args[1] ?? null) : args[0];
212
+ }
213
+ if (node.kind === "aggregate") {
214
+ const bucket = Object.hasOwn(row, "__bucket__") ? row.__bucket__ : null;
215
+ if (!Array.isArray(bucket)) sqlBad("an aggregate is only supported in the select list, HAVING and ORDER BY");
216
+ if (node.args[0].kind === "star") return bucket.length;
217
+ return aggregate(node.name, bucket.map((entry) => value(node.args[0], entry)), node.distinct);
218
+ }
219
+ if (node.kind === "star") sqlBad("* cannot be used here");
220
+ sqlBad("unsupported expression");
221
+ return null;
222
+ }
223
+
224
+ function test(node, row) {
225
+ if (node.kind === "and") return test(node.left, row) && test(node.right, row);
226
+ if (node.kind === "or") return test(node.left, row) || test(node.right, row);
227
+ if (node.kind === "not") return !test(node.operand, row);
228
+ if (node.kind === "isNull") {
229
+ const actual = value(node.operand, row);
230
+ return node.negated ? actual !== null : actual === null;
231
+ }
232
+ if (node.kind === "like") {
233
+ const result = likeMatch(value(node.left, row), value(node.right, row));
234
+ return node.negated ? !result : result;
235
+ }
236
+ if (node.kind === "between") {
237
+ const actual = value(node.left, row);
238
+ const low = compareValues(actual, value(node.low, row));
239
+ const high = compareValues(actual, value(node.high, row));
240
+ const result = low !== null && high !== null && low >= 0 && high <= 0;
241
+ return node.negated ? !result : result;
242
+ }
243
+ if (node.kind === "in") {
244
+ const actual = value(node.left, row);
245
+ const result = node.values.some((entry) => compareValues(actual, value(entry, row)) === 0);
246
+ return node.negated ? !result : result;
247
+ }
248
+ const order = compareValues(value(node.left, row), value(node.right, row));
249
+ if (order === null) return false;
250
+ switch (node.operator) {
251
+ case "=": return order === 0;
252
+ case "<>": return order !== 0;
253
+ case "<": return order < 0;
254
+ case "<=": return order <= 0;
255
+ case ">": return order > 0;
256
+ default: return order >= 0;
257
+ }
258
+ }
259
+
260
+ if (plan.where !== null) {
261
+ const filtered = [];
262
+ for (const row of rows) {
263
+ if (test(plan.where, row)) filtered.push(row);
264
+ }
265
+ rows = filtered;
266
+ }
267
+ return { plan, scope, rows, value, test, display };
268
+ }
269
+
270
+ export { SqlError };
@@ -0,0 +1,118 @@
1
+ // SuiteQL projection: select list, DISTINCT, GROUP BY / HAVING, ORDER BY and FETCH FIRST, rendered as NetSuite does.
2
+ import { runSql } from "./suiteql-run.mjs";
3
+ import { sqlBad } from "./suiteql-parse.mjs";
4
+
5
+ const hasAggregate = (node) => {
6
+ if (node === null || node === undefined) return false;
7
+ if (node.kind === "aggregate") return true;
8
+ if (node.kind === "call") return node.args.some(hasAggregate);
9
+ return false;
10
+ };
11
+
12
+ /** NetSuite renders every scalar in a SuiteQL result as a JSON string; null stays null. */
13
+ const render = (value) => {
14
+ if (value === null || value === undefined) return null;
15
+ if (typeof value === "boolean") return value ? "T" : "F";
16
+ return String(value);
17
+ };
18
+
19
+ function columnName(item, index, scope) {
20
+ if (item.alias !== null && item.alias !== undefined) return item.alias.toLowerCase();
21
+ if (item.expression.kind === "column") return scope.resolve(item.expression).column;
22
+ if (item.expression.kind === "df") return `df_${scope.resolve(item.expression.column).column}`;
23
+ return `expr${index + 1}`;
24
+ }
25
+
26
+ /** Execute a SuiteQL statement and return plain result rows (all scalars already rendered). */
27
+ export function executeSql(session, statement, params) {
28
+ const { plan, scope, rows, value, test } = runSql(session, statement, params);
29
+
30
+ const expanded = [];
31
+ for (const [index, item] of plan.select.entries()) {
32
+ if (item.expression.kind === "star") {
33
+ for (const column of scope.star(item.expression.qualifier)) {
34
+ expanded.push({ name: column.name, read: (row) => row[column.key] ?? null });
35
+ }
36
+ continue;
37
+ }
38
+ const name = columnName(item, index, scope);
39
+ expanded.push({ name, expression: item.expression, read: (row) => value(item.expression, row) });
40
+ }
41
+
42
+ const grouped = plan.groupBy.length > 0 || plan.select.some((item) => hasAggregate(item.expression));
43
+ let resultRows;
44
+ if (!grouped) {
45
+ if (plan.having !== null) sqlBad("HAVING needs a grouped statement");
46
+ resultRows = rows.map((row) => {
47
+ const output = {};
48
+ for (const column of expanded) output[column.name] = column.read(row);
49
+ return { output, source: row };
50
+ });
51
+ } else {
52
+ const keys = plan.groupBy.map((reference) => scope.resolve(reference).key);
53
+ const buckets = new Map();
54
+ for (const row of rows) {
55
+ const composite = keys.map((key) => (row[key] === null ? "\u0001" : String(row[key]))).join("\u0000");
56
+ const bucket = buckets.get(composite);
57
+ if (bucket === undefined) buckets.set(composite, [row]);
58
+ else bucket.push(row);
59
+ }
60
+ if (keys.length === 0) buckets.set("", rows);
61
+ resultRows = [];
62
+ for (const bucket of buckets.values()) {
63
+ const first = bucket[0] ?? {};
64
+ const output = {};
65
+ const carrier = { ...first, __bucket__: bucket };
66
+ for (const column of expanded) {
67
+ output[column.name] = column.expression !== undefined && hasAggregate(column.expression)
68
+ ? value(column.expression, carrier)
69
+ : column.read(first);
70
+ }
71
+ const groupRow = { output, source: first, bucket, carrier };
72
+ if (plan.having === null || test(plan.having, carrier)) resultRows.push(groupRow);
73
+ }
74
+ }
75
+
76
+ if (plan.distinct === true) {
77
+ const seen = new Set();
78
+ resultRows = resultRows.filter((entry) => {
79
+ const key = JSON.stringify(entry.output);
80
+ if (seen.has(key)) return false;
81
+ seen.add(key);
82
+ return true;
83
+ });
84
+ }
85
+
86
+ for (const order of [...plan.orderBy].reverse()) {
87
+ // ORDER BY may name a select alias, as Oracle allows; otherwise it is an expression over the source row.
88
+ const alias = order.expression.kind === "column" && order.expression.qualifier === null
89
+ ? order.expression.name.toLowerCase()
90
+ : null;
91
+ const read = (entry) => {
92
+ if (alias !== null && Object.hasOwn(entry.output, alias)) return entry.output[alias];
93
+ return order.expression.kind === "column"
94
+ ? entry.source[scope.resolve(order.expression).key] ?? null
95
+ : value(order.expression, entry.carrier ?? entry.source);
96
+ };
97
+ resultRows = [...resultRows].sort((left, right) => {
98
+ const a = read(left);
99
+ const b = read(right);
100
+ if (a === b) return 0;
101
+ if (a === null) return 1;
102
+ if (b === null) return -1;
103
+ const numeric = Number(a) - Number(b);
104
+ const compared = Number.isFinite(numeric) && String(a).trim() !== "" && String(b).trim() !== ""
105
+ ? numeric
106
+ : String(a) < String(b) ? -1 : 1;
107
+ return order.direction === "desc" ? -compared : compared;
108
+ });
109
+ }
110
+
111
+ if (plan.fetch !== null) resultRows = resultRows.slice(0, plan.fetch);
112
+
113
+ return resultRows.map((entry) => {
114
+ const output = {};
115
+ for (const [key, raw] of Object.entries(entry.output)) output[key] = render(raw);
116
+ return output;
117
+ });
118
+ }
@@ -0,0 +1,162 @@
1
+ // The analytics tables the SuiteQL subset exposes, built from state rows and filtered by the session's subsidiary scope.
2
+ import { invStatus, payStatus, soStatus } from "./project.mjs";
3
+
4
+ export const TABLES = new Map([
5
+ ["customer", "customer"],
6
+ ["transaction", "transaction"],
7
+ ["transactionline", "transactionLine"],
8
+ ["item", "item"],
9
+ ["employee", "employee"],
10
+ ["subsidiary", "subsidiary"],
11
+ ]);
12
+
13
+ export const COLUMNS = {
14
+ customer: [
15
+ "id", "entityid", "companyname", "email", "phone", "isperson", "isinactive",
16
+ "subsidiary", "datecreated", "lastmodifieddate", "firstname", "lastname", "creditlimit",
17
+ ],
18
+ transaction: [
19
+ "id", "type", "entity", "trandate", "tranid", "status", "foreigntotal",
20
+ "memo", "subsidiary", "duedate", "createdfrom",
21
+ ],
22
+ transactionLine: [
23
+ "id", "transaction", "linesequencenumber", "item", "quantity", "rate", "netamount", "subsidiary",
24
+ ],
25
+ item: ["id", "itemid", "displayname", "description", "baseprice", "cost", "quantityonhand", "itemtype", "isinactive"],
26
+ employee: ["id", "entityid", "firstname", "lastname", "email", "title", "subsidiary", "supervisor", "isinactive"],
27
+ subsidiary: ["id", "name", "parent", "country", "currency", "isinactive"],
28
+ };
29
+
30
+ /** Columns whose BUILTIN.DF renders another record's display name. */
31
+ export const DF_SOURCE = {
32
+ entity: "customer",
33
+ customer: "customer",
34
+ item: "item",
35
+ subsidiary: "subsidiary",
36
+ supervisor: "employee",
37
+ parent: "subsidiary",
38
+ transaction: "transaction",
39
+ createdfrom: "transaction",
40
+ status: "status",
41
+ };
42
+
43
+ const inScope = (session, subsidiaryId) => session.inScope(subsidiaryId);
44
+
45
+ export function buildTables(session) {
46
+ const cache = new Map();
47
+ return {
48
+ rows(name) {
49
+ if (cache.has(name)) return cache.get(name);
50
+ const built = build(session, name);
51
+ cache.set(name, built);
52
+ return built;
53
+ },
54
+ };
55
+ }
56
+
57
+ function build(session, name) {
58
+ if (name === "customer") {
59
+ return session.rows("customers").filter((row) => inScope(session, row.subsidiaryId)).map((row) => ({
60
+ id: row.id,
61
+ entityid: row.entityId,
62
+ companyname: row.companyName,
63
+ email: row.email,
64
+ phone: row.phone,
65
+ isperson: row.isPerson ? "T" : "F",
66
+ isinactive: row.isInactive ? "T" : "F",
67
+ subsidiary: row.subsidiaryId,
68
+ datecreated: row.dateCreated,
69
+ lastmodifieddate: row.lastModifiedDate,
70
+ firstname: row.firstName,
71
+ lastname: row.lastName,
72
+ creditlimit: row.creditLimit,
73
+ }));
74
+ }
75
+ if (name === "transaction") {
76
+ const rows = [];
77
+ for (const order of session.rows("sales-orders")) {
78
+ if (!inScope(session, order.subsidiaryId)) continue;
79
+ rows.push({
80
+ id: order.id, type: "SalesOrd", entity: order.entityId, trandate: order.tranDate,
81
+ tranid: order.tranId, status: soStatus(order.status).refName, foreigntotal: order.total,
82
+ memo: order.memo, subsidiary: order.subsidiaryId, duedate: null, createdfrom: null,
83
+ });
84
+ }
85
+ for (const invoice of session.rows("invoices")) {
86
+ if (!inScope(session, invoice.subsidiaryId)) continue;
87
+ rows.push({
88
+ id: invoice.id, type: "CustInvc", entity: invoice.entityId, trandate: invoice.tranDate,
89
+ tranid: invoice.tranId, status: invStatus(invoice.status).refName, foreigntotal: invoice.total,
90
+ memo: invoice.memo, subsidiary: invoice.subsidiaryId, duedate: invoice.dueDate,
91
+ createdfrom: invoice.createdFromId,
92
+ });
93
+ }
94
+ for (const payment of session.rows("customer-payments")) {
95
+ if (!inScope(session, payment.subsidiaryId)) continue;
96
+ rows.push({
97
+ id: payment.id, type: "CustPymt", entity: payment.entityId, trandate: payment.tranDate,
98
+ tranid: payment.tranId, status: payStatus(payment.status).refName, foreigntotal: payment.payment,
99
+ memo: payment.memo, subsidiary: payment.subsidiaryId, duedate: null, createdfrom: null,
100
+ });
101
+ }
102
+ rows.sort((left, right) => Number(left.id) - Number(right.id));
103
+ return rows;
104
+ }
105
+ if (name === "transactionLine") {
106
+ const rows = [];
107
+ const push = (record) => {
108
+ if (!inScope(session, record.subsidiaryId)) return;
109
+ for (const line of record.lines) {
110
+ rows.push({
111
+ id: `${record.id}.${line.line}`,
112
+ transaction: record.id,
113
+ linesequencenumber: line.line,
114
+ item: line.itemId,
115
+ quantity: line.quantity,
116
+ rate: line.rate,
117
+ netamount: line.amount,
118
+ subsidiary: record.subsidiaryId,
119
+ });
120
+ }
121
+ };
122
+ for (const order of session.rows("sales-orders")) push(order);
123
+ for (const invoice of session.rows("invoices")) push(invoice);
124
+ return rows;
125
+ }
126
+ if (name === "item") {
127
+ return session.rows("items")
128
+ .filter((row) => row.subsidiaryIds.some((id) => inScope(session, id)))
129
+ .map((row) => ({
130
+ id: row.id, itemid: row.itemId, displayname: row.displayName, description: row.description,
131
+ baseprice: row.basePrice, cost: row.cost, quantityonhand: row.quantityOnHand,
132
+ itemtype: row.itemType === "inventoryItem" ? "InvtPart" : "Service",
133
+ isinactive: row.isInactive ? "T" : "F",
134
+ }));
135
+ }
136
+ if (name === "employee") {
137
+ return session.rows("employees").filter((row) => inScope(session, row.subsidiaryId)).map((row) => ({
138
+ id: row.id, entityid: row.entityId, firstname: row.firstName, lastname: row.lastName,
139
+ email: row.email, title: row.title, subsidiary: row.subsidiaryId, supervisor: row.supervisorId,
140
+ isinactive: row.isInactive ? "T" : "F",
141
+ }));
142
+ }
143
+ return session.rows("subsidiaries").filter((row) => inScope(session, row.id)).map((row) => ({
144
+ id: row.id, name: row.name, parent: row.parentId, country: row.country,
145
+ currency: row.currencyName, isinactive: row.isInactive ? "T" : "F",
146
+ }));
147
+ }
148
+
149
+ /** Display names for BUILTIN.DF, resolved once per request. */
150
+ export function displayIndex(session) {
151
+ const index = new Map();
152
+ index.set("customer", new Map(session.rows("customers").map((row) => [row.id, row.entityId])));
153
+ index.set("item", new Map(session.rows("items").map((row) => [row.id, row.itemId])));
154
+ index.set("subsidiary", new Map(session.rows("subsidiaries").map((row) => [row.id, row.name])));
155
+ index.set("employee", new Map(session.rows("employees").map((row) => [row.id, row.entityId])));
156
+ const transactions = new Map();
157
+ for (const order of session.rows("sales-orders")) transactions.set(order.id, order.tranId);
158
+ for (const invoice of session.rows("invoices")) transactions.set(invoice.id, invoice.tranId);
159
+ for (const payment of session.rows("customer-payments")) transactions.set(payment.id, payment.tranId);
160
+ index.set("transaction", transactions);
161
+ return index;
162
+ }
@@ -0,0 +1,133 @@
1
+ // Shared transaction rules: reference resolution, line building, totals, derived status and document numbers.
2
+ import { quote } from "./errors.mjs";
3
+ import { round2, round4 } from "./primitives.mjs";
4
+ import { numberField, referenceField, stringField } from "./body.mjs";
5
+
6
+ const TAX_RATES = new Map([["1", 0.086], ["2", 0.05]]);
7
+ export const taxRateFor = (subsidiaryId) => TAX_RATES.get(subsidiaryId) ?? 0;
8
+
9
+ export function resolveCustomer(session, entityId, path = "entity") {
10
+ const customer = session.get("customers", entityId);
11
+ if (customer === null || !session.inScope(customer.subsidiaryId)) {
12
+ session.fail("INVALID_KEY_OR_REF", `Invalid customer reference key ${quote(String(entityId))}.`, { errorPath: path });
13
+ }
14
+ if (customer.isInactive === true) {
15
+ session.fail("USER_ERROR", `The customer ${quote(customer.entityId)} is inactive and cannot be used on a transaction.`, {
16
+ errorPath: path,
17
+ });
18
+ }
19
+ return customer;
20
+ }
21
+
22
+ export function resolveItem(session, itemId, subsidiaryId, path) {
23
+ const item = session.get("items", itemId);
24
+ if (item === null) {
25
+ session.fail("INVALID_KEY_OR_REF", `Invalid item reference key ${quote(String(itemId))}.`, { errorPath: path });
26
+ }
27
+ if (item.isInactive === true) {
28
+ session.fail("USER_ERROR", `The item ${quote(item.itemId)} is inactive and cannot be used on a transaction.`, { errorPath: path });
29
+ }
30
+ if (!item.subsidiaryIds.includes(subsidiaryId)) {
31
+ session.fail("INVALID_KEY_OR_REF", `The item ${quote(item.itemId)} is not available in the subsidiary of this transaction.`, {
32
+ errorPath: path,
33
+ });
34
+ }
35
+ return item;
36
+ }
37
+
38
+ /** Build the item sublist from a wire body; `quantityBilled` is preserved from an existing line when given. */
39
+ export function buildLine(session, entry, subsidiaryId, lineNumber, prefix, existing = null) {
40
+ const itemId = referenceField(session, entry, "item", prefix);
41
+ if (itemId === undefined && existing === null) {
42
+ session.fail("INVALID_KEY_OR_REF", `The line ${quote(`${prefix}item`)} must carry an item reference.`, { errorPath: `${prefix}item` });
43
+ }
44
+ const resolvedItemId = itemId ?? existing.itemId;
45
+ const item = resolveItem(session, resolvedItemId, subsidiaryId, `${prefix}item`);
46
+ const quantity = numberField(session, entry, "quantity", { min: 0, max: 1000000, prefix });
47
+ const rate = numberField(session, entry, "rate", { min: 0, max: 100000000, prefix });
48
+ const description = stringField(session, entry, "description", 999, prefix);
49
+ const taxRate = numberField(session, entry, "taxRate", { min: 0, max: 0.25, prefix });
50
+ const finalQuantity = round4(quantity ?? existing?.quantity ?? 1);
51
+ const finalRate = round4(rate ?? existing?.rate ?? item.basePrice);
52
+ const finalTaxRate = taxRate ?? existing?.taxRate ?? taxRateFor(subsidiaryId);
53
+ const amount = round2(finalQuantity * finalRate);
54
+ const billed = existing === null ? 0 : existing.quantityBilled;
55
+ if (finalQuantity < billed) {
56
+ session.fail("USER_ERROR", `The quantity on line ${lineNumber} cannot be lower than the quantity already billed (${billed}).`, {
57
+ errorPath: `${prefix}quantity`,
58
+ });
59
+ }
60
+ return {
61
+ line: lineNumber,
62
+ itemId: resolvedItemId,
63
+ quantity: finalQuantity,
64
+ rate: finalRate,
65
+ amount,
66
+ description: description === undefined ? existing?.description ?? null : description,
67
+ taxRate: finalTaxRate,
68
+ taxAmount: round2(amount * finalTaxRate),
69
+ orderLine: existing === null ? null : existing.orderLine,
70
+ quantityBilled: billed,
71
+ };
72
+ }
73
+
74
+ export function totals(lines) {
75
+ const subtotal = round2(lines.reduce((sum, line) => sum + line.amount, 0));
76
+ const taxTotal = round2(lines.reduce((sum, line) => sum + line.taxAmount, 0));
77
+ return { subtotal, taxTotal, total: round2(subtotal + taxTotal) };
78
+ }
79
+
80
+ /** Billed value of an order, tax included, from each line's quantityBilled. */
81
+ export function billedTotal(lines) {
82
+ return round2(lines.reduce((sum, line) => sum + round2(line.quantityBilled * line.rate) * (1 + line.taxRate), 0));
83
+ }
84
+
85
+ /** Sales-order status derived from billed quantities; a closed order stays closed. */
86
+ export function orderStatus(order) {
87
+ if (order.status === "closed") return "closed";
88
+ const billedLines = order.lines.filter((line) => line.quantityBilled > 0);
89
+ if (billedLines.length === 0) return order.status === "pendingApproval" ? "pendingApproval" : "pendingFulfillment";
90
+ const fullyBilled = order.lines.every((line) => line.quantityBilled >= line.quantity);
91
+ if (fullyBilled) return "billed";
92
+ return "pendingBillingPartFulfilled";
93
+ }
94
+
95
+ export function invoiceStatus(invoice) {
96
+ if (invoice.status === "voided" || invoice.status === "pendingApproval") return invoice.status;
97
+ return round2(invoice.total - invoice.amountPaid) <= 0 && invoice.total > 0 ? "paidInFull" : "open";
98
+ }
99
+
100
+ const PREFIX_PATTERN = /^([A-Z]+)([0-9]+)$/;
101
+
102
+ /** Next document number for a transaction namespace, continuing the seeded sequence. */
103
+ export function nextTranId(session, namespace, prefix) {
104
+ let highest = 1000;
105
+ for (const row of session.rows(namespace)) {
106
+ const parts = PREFIX_PATTERN.exec(row.tranId);
107
+ if (parts === null || parts[1] !== prefix) continue;
108
+ const value = Number(parts[2]);
109
+ if (Number.isSafeInteger(value) && value > highest) highest = value;
110
+ }
111
+ return `${prefix}${highest + 1}`;
112
+ }
113
+
114
+ export function checkDuplicateTranId(session, namespaces, tranId, ownId) {
115
+ for (const namespace of namespaces) {
116
+ for (const row of session.rows(namespace)) {
117
+ if (row.id !== ownId && row.tranId === tranId) {
118
+ session.fail("USER_ERROR", `The document number ${quote(tranId)} is already in use by another transaction.`, {
119
+ errorPath: "tranId",
120
+ });
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ export function lineCounter(session, transactionId) {
127
+ const row = session.get("line-counters", transactionId);
128
+ return row === null || !Number.isInteger(row.nextLine) ? 1 : row.nextLine;
129
+ }
130
+
131
+ export function setLineCounter(session, transactionId, nextLine) {
132
+ session.put("line-counters", transactionId, { nextLine });
133
+ }