@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.
- package/LICENSE +201 -0
- package/README.md +214 -0
- package/firedrill/agent.target.json +16 -0
- package/firedrill/baseline.scenario.json +6905 -0
- package/firedrill/concurrency-limited.scenario.json +11 -0
- package/firedrill/conformance.suite.json +21 -0
- package/firedrill/ns-analytics.drill.json +73 -0
- package/firedrill/ns-concurrency.drill.json +53 -0
- package/firedrill/ns-default-identity.drill.json +43 -0
- package/firedrill/ns-denied.drill.json +38 -0
- package/firedrill/ns-insufficient-permission.drill.json +53 -0
- package/firedrill/ns-invalid-login.drill.json +53 -0
- package/firedrill/ns-order-to-cash.drill.json +93 -0
- package/firedrill/ns-record-locked.drill.json +53 -0
- package/firedrill/ns-records.drill.json +68 -0
- package/firedrill/ns-role-ar-clerk.drill.json +58 -0
- package/firedrill/ns-role-sales-rep.drill.json +58 -0
- package/firedrill/ns-subsidiary-scope.drill.json +58 -0
- package/firedrill/ns-tight-limits.drill.json +58 -0
- package/firedrill/ns-transform-lost.drill.json +63 -0
- package/firedrill/ns-write-outage.drill.json +63 -0
- package/firedrill/record-locked.scenario.json +11 -0
- package/firedrill/tight-limits.scenario.json +17 -0
- package/firedrill/tools/netsuite/app/assets/ATTRIBUTION.md +37 -0
- package/firedrill/tools/netsuite/app/assets/fonts/LICENSE.txt +92 -0
- package/firedrill/tools/netsuite/app/assets/oracle-netsuite.svg +1 -0
- package/firedrill/tools/netsuite/app/assets/oracle.svg +1 -0
- package/firedrill/tools/netsuite/app/site/app.js +182 -0
- package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin-ext.woff2 +0 -0
- package/firedrill/tools/netsuite/app/site/assets/fonts/open-sans-latin.woff2 +0 -0
- package/firedrill/tools/netsuite/app/site/assets/oracle-netsuite.svg +1 -0
- package/firedrill/tools/netsuite/app/site/assets/oracle.svg +1 -0
- package/firedrill/tools/netsuite/app/site/icons.js +59 -0
- package/firedrill/tools/netsuite/app/site/index.html +68 -0
- package/firedrill/tools/netsuite/app/site/menus.js +111 -0
- package/firedrill/tools/netsuite/app/site/overlay.js +121 -0
- package/firedrill/tools/netsuite/app/site/store.js +95 -0
- package/firedrill/tools/netsuite/app/site/styles/base.css +131 -0
- package/firedrill/tools/netsuite/app/site/styles/chrome.css +183 -0
- package/firedrill/tools/netsuite/app/site/styles/pages.css +134 -0
- package/firedrill/tools/netsuite/app/site/styles/tables.css +92 -0
- package/firedrill/tools/netsuite/app/site/ui.js +119 -0
- package/firedrill/tools/netsuite/app/site/views/common.js +134 -0
- package/firedrill/tools/netsuite/app/site/views/customer-form.js +152 -0
- package/firedrill/tools/netsuite/app/site/views/customer.js +147 -0
- package/firedrill/tools/netsuite/app/site/views/customers.js +111 -0
- package/firedrill/tools/netsuite/app/site/views/home-chrome.js +75 -0
- package/firedrill/tools/netsuite/app/site/views/home-data.js +153 -0
- package/firedrill/tools/netsuite/app/site/views/home.js +162 -0
- package/firedrill/tools/netsuite/app/site/views/invoice-new.js +131 -0
- package/firedrill/tools/netsuite/app/site/views/invoice.js +232 -0
- package/firedrill/tools/netsuite/app/site/views/invoices.js +99 -0
- package/firedrill/tools/netsuite/app/site/views/items.js +95 -0
- package/firedrill/tools/netsuite/app/site/views/order-new.js +154 -0
- package/firedrill/tools/netsuite/app/site/views/order.js +214 -0
- package/firedrill/tools/netsuite/app/site/views/orders.js +93 -0
- package/firedrill/tools/netsuite/app/site/views/payments.js +65 -0
- package/firedrill/tools/netsuite/app/site/views/pickers.js +117 -0
- package/firedrill/tools/netsuite/app/site/views/subsidiaries.js +40 -0
- package/firedrill/tools/netsuite/app/site/views/suiteql.js +81 -0
- package/firedrill/tools/netsuite/behavior.mjs +13 -0
- package/firedrill/tools/netsuite/lib/body.mjs +185 -0
- package/firedrill/tools/netsuite/lib/collections.mjs +202 -0
- package/firedrill/tools/netsuite/lib/errors.mjs +91 -0
- package/firedrill/tools/netsuite/lib/handlers-meta.mjs +100 -0
- package/firedrill/tools/netsuite/lib/handlers-read.mjs +141 -0
- package/firedrill/tools/netsuite/lib/handlers-session.mjs +52 -0
- package/firedrill/tools/netsuite/lib/handlers-write.mjs +6 -0
- package/firedrill/tools/netsuite/lib/metadata.mjs +148 -0
- package/firedrill/tools/netsuite/lib/primitives.mjs +105 -0
- package/firedrill/tools/netsuite/lib/project-tran.mjs +153 -0
- package/firedrill/tools/netsuite/lib/project.mjs +178 -0
- package/firedrill/tools/netsuite/lib/q.mjs +280 -0
- package/firedrill/tools/netsuite/lib/session.mjs +188 -0
- package/firedrill/tools/netsuite/lib/suiteql-parse.mjs +382 -0
- package/firedrill/tools/netsuite/lib/suiteql-run.mjs +270 -0
- package/firedrill/tools/netsuite/lib/suiteql-select.mjs +118 -0
- package/firedrill/tools/netsuite/lib/suiteql-tables.mjs +162 -0
- package/firedrill/tools/netsuite/lib/tran.mjs +133 -0
- package/firedrill/tools/netsuite/lib/wire-decode.mjs +107 -0
- package/firedrill/tools/netsuite/lib/wire.mjs +228 -0
- package/firedrill/tools/netsuite/lib/write-customer.mjs +181 -0
- package/firedrill/tools/netsuite/lib/write-invoice.mjs +319 -0
- package/firedrill/tools/netsuite/lib/write-order.mjs +269 -0
- package/firedrill/tools/netsuite/netsuite.tool.json +4888 -0
- package/firedrill/transform-response-lost.scenario.json +11 -0
- package/firedrill/world.json +7711 -0
- package/firedrill/write-outage.scenario.json +11 -0
- package/firedrill.json +5 -0
- package/package.json +52 -0
- package/starter.json +6904 -0
- package/test/conformance.mjs +701 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Transaction projections: sales order, invoice, customer payment, item and subsidiary records.
|
|
2
|
+
import { round2 } from "./primitives.mjs";
|
|
3
|
+
import {
|
|
4
|
+
currencyRef, invStatus, itemRefName, payStatus, projectLine, ref, selfLinks, soStatus,
|
|
5
|
+
subsidiaryRefName, termsRef, customerRefName,
|
|
6
|
+
} from "./project.mjs";
|
|
7
|
+
|
|
8
|
+
const REC = "/services/rest/record/v1";
|
|
9
|
+
|
|
10
|
+
function sublist(resource, recordId, lines, items, expand) {
|
|
11
|
+
const links = [{ rel: "self", href: `${REC}/${resource}/${recordId}/item` }];
|
|
12
|
+
if (expand !== true) return { links };
|
|
13
|
+
return {
|
|
14
|
+
links,
|
|
15
|
+
items: lines.map((line) => projectLine(resource, recordId, line, items)),
|
|
16
|
+
totalResults: lines.length,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function projectSalesOrder(row, context) {
|
|
21
|
+
const record = {
|
|
22
|
+
links: selfLinks("salesOrder", row.id),
|
|
23
|
+
id: row.id,
|
|
24
|
+
tranId: row.tranId,
|
|
25
|
+
tranDate: row.tranDate,
|
|
26
|
+
entity: ref("customer", row.entityId, customerRefName(context.customers.get(row.entityId))),
|
|
27
|
+
subsidiary: ref("subsidiary", row.subsidiaryId, subsidiaryRefName(context.subsidiaries.get(row.subsidiaryId))),
|
|
28
|
+
status: soStatus(row.status),
|
|
29
|
+
currency: currencyRef(row.currencyId),
|
|
30
|
+
subtotal: row.subtotal,
|
|
31
|
+
taxTotal: row.taxTotal,
|
|
32
|
+
total: row.total,
|
|
33
|
+
createdDate: row.createdDate,
|
|
34
|
+
lastModifiedDate: row.lastModifiedDate,
|
|
35
|
+
item: sublist("salesOrder", row.id, row.lines, context.items, context.expand),
|
|
36
|
+
};
|
|
37
|
+
if (row.memo !== null) record.memo = row.memo;
|
|
38
|
+
if (row.otherRefNum !== null) record.otherRefNum = row.otherRefNum;
|
|
39
|
+
const terms = termsRef(row.termsId);
|
|
40
|
+
if (terms !== null) record.terms = terms;
|
|
41
|
+
return record;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function projectInvoice(row, context) {
|
|
45
|
+
const record = {
|
|
46
|
+
links: selfLinks("invoice", row.id),
|
|
47
|
+
id: row.id,
|
|
48
|
+
tranId: row.tranId,
|
|
49
|
+
tranDate: row.tranDate,
|
|
50
|
+
dueDate: row.dueDate,
|
|
51
|
+
entity: ref("customer", row.entityId, customerRefName(context.customers.get(row.entityId))),
|
|
52
|
+
subsidiary: ref("subsidiary", row.subsidiaryId, subsidiaryRefName(context.subsidiaries.get(row.subsidiaryId))),
|
|
53
|
+
status: invStatus(row.status),
|
|
54
|
+
currency: currencyRef(row.currencyId),
|
|
55
|
+
subtotal: row.subtotal,
|
|
56
|
+
taxTotal: row.taxTotal,
|
|
57
|
+
total: row.total,
|
|
58
|
+
amountPaid: row.amountPaid,
|
|
59
|
+
amountRemaining: round2(row.total - row.amountPaid),
|
|
60
|
+
createdDate: row.createdDate,
|
|
61
|
+
lastModifiedDate: row.lastModifiedDate,
|
|
62
|
+
item: sublist("invoice", row.id, row.lines, context.items, context.expand),
|
|
63
|
+
};
|
|
64
|
+
if (row.memo !== null) record.memo = row.memo;
|
|
65
|
+
if (row.otherRefNum !== null) record.otherRefNum = row.otherRefNum;
|
|
66
|
+
if (row.createdFromId !== null) {
|
|
67
|
+
const order = context.orders.get(row.createdFromId);
|
|
68
|
+
record.createdFrom = ref("salesOrder", row.createdFromId, order === undefined ? row.createdFromId : order.tranId);
|
|
69
|
+
}
|
|
70
|
+
const terms = termsRef(row.termsId);
|
|
71
|
+
if (terms !== null) record.terms = terms;
|
|
72
|
+
return record;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function projectPayment(row, context) {
|
|
76
|
+
const record = {
|
|
77
|
+
links: selfLinks("customerPayment", row.id),
|
|
78
|
+
id: row.id,
|
|
79
|
+
tranId: row.tranId,
|
|
80
|
+
tranDate: row.tranDate,
|
|
81
|
+
customer: ref("customer", row.entityId, customerRefName(context.customers.get(row.entityId))),
|
|
82
|
+
subsidiary: ref("subsidiary", row.subsidiaryId, subsidiaryRefName(context.subsidiaries.get(row.subsidiaryId))),
|
|
83
|
+
payment: row.payment,
|
|
84
|
+
unapplied: row.unapplied,
|
|
85
|
+
account: { refName: row.accountName },
|
|
86
|
+
currency: currencyRef(row.currencyId),
|
|
87
|
+
status: payStatus(row.status),
|
|
88
|
+
createdDate: row.createdDate,
|
|
89
|
+
lastModifiedDate: row.lastModifiedDate,
|
|
90
|
+
};
|
|
91
|
+
if (row.memo !== null) record.memo = row.memo;
|
|
92
|
+
const links = [{ rel: "self", href: `${REC}/customerPayment/${row.id}/apply` }];
|
|
93
|
+
record.apply = context.expand === true
|
|
94
|
+
? {
|
|
95
|
+
links,
|
|
96
|
+
items: row.applied.map((entry) => {
|
|
97
|
+
const invoice = context.invoices.get(entry.invoiceId);
|
|
98
|
+
return {
|
|
99
|
+
doc: entry.invoiceId,
|
|
100
|
+
refName: invoice === undefined ? entry.invoiceId : invoice.tranId,
|
|
101
|
+
applyDate: row.tranDate,
|
|
102
|
+
amount: entry.amount,
|
|
103
|
+
apply: true,
|
|
104
|
+
};
|
|
105
|
+
}),
|
|
106
|
+
totalResults: row.applied.length,
|
|
107
|
+
}
|
|
108
|
+
: { links };
|
|
109
|
+
return record;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function projectItem(row, subsidiaries) {
|
|
113
|
+
const record = {
|
|
114
|
+
links: selfLinks("inventoryItem", row.id),
|
|
115
|
+
id: row.id,
|
|
116
|
+
itemId: row.itemId,
|
|
117
|
+
basePrice: row.basePrice,
|
|
118
|
+
quantityOnHand: row.quantityOnHand,
|
|
119
|
+
incomeAccount: { refName: row.incomeAccountName },
|
|
120
|
+
taxSchedule: { refName: row.taxScheduleName },
|
|
121
|
+
isInactive: row.isInactive,
|
|
122
|
+
lastModifiedDate: row.lastModifiedDate,
|
|
123
|
+
subsidiary: {
|
|
124
|
+
links: [{ rel: "self", href: `${REC}/inventoryItem/${row.id}/subsidiary` }],
|
|
125
|
+
items: row.subsidiaryIds.map((id) => ref("subsidiary", id, subsidiaryRefName(subsidiaries.get(id)))),
|
|
126
|
+
totalResults: row.subsidiaryIds.length,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
if (row.displayName !== null) record.displayName = row.displayName;
|
|
130
|
+
if (row.description !== null) record.description = row.description;
|
|
131
|
+
if (row.cost !== null) record.cost = row.cost;
|
|
132
|
+
return record;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function projectSubsidiary(row, subsidiaries) {
|
|
136
|
+
const record = {
|
|
137
|
+
links: selfLinks("subsidiary", row.id),
|
|
138
|
+
id: row.id,
|
|
139
|
+
name: row.name,
|
|
140
|
+
country: { id: row.country, refName: row.country },
|
|
141
|
+
currency: { refName: row.currencyName },
|
|
142
|
+
isElimination: row.isElimination,
|
|
143
|
+
isInactive: row.isInactive,
|
|
144
|
+
fiscalCalendar: { refName: row.fiscalCalendarName },
|
|
145
|
+
lastModifiedDate: row.lastModifiedDate,
|
|
146
|
+
};
|
|
147
|
+
if (row.parentId !== null) {
|
|
148
|
+
record.parent = ref("subsidiary", row.parentId, subsidiaryRefName(subsidiaries.get(row.parentId)));
|
|
149
|
+
}
|
|
150
|
+
return record;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export const ITEM_REF_NAME = itemRefName;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Provider-shaped projections: state rows → the JSON NetSuite's REST record service returns.
|
|
2
|
+
import { dateToDays, round2 } from "./primitives.mjs";
|
|
3
|
+
|
|
4
|
+
const REC = "/services/rest/record/v1";
|
|
5
|
+
|
|
6
|
+
export const RESOURCE = {
|
|
7
|
+
customer: "customer",
|
|
8
|
+
salesOrder: "salesOrder",
|
|
9
|
+
invoice: "invoice",
|
|
10
|
+
customerPayment: "customerPayment",
|
|
11
|
+
inventoryItem: "inventoryItem",
|
|
12
|
+
subsidiary: "subsidiary",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const selfLinks = (resource, id) => [{ rel: "self", href: `${REC}/${resource}/${id}` }];
|
|
16
|
+
|
|
17
|
+
const SO_STATUS = new Map([
|
|
18
|
+
["pendingApproval", ["A", "Pending Approval"]],
|
|
19
|
+
["pendingFulfillment", ["B", "Pending Fulfillment"]],
|
|
20
|
+
["partiallyFulfilled", ["D", "Partially Fulfilled"]],
|
|
21
|
+
["pendingBillingPartFulfilled", ["E", "Pending Billing/Partially Fulfilled"]],
|
|
22
|
+
["pendingBilling", ["F", "Pending Billing"]],
|
|
23
|
+
["billed", ["G", "Billed"]],
|
|
24
|
+
["closed", ["H", "Closed"]],
|
|
25
|
+
]);
|
|
26
|
+
const INV_STATUS = new Map([
|
|
27
|
+
["open", ["A", "Open"]],
|
|
28
|
+
["paidInFull", ["B", "Paid In Full"]],
|
|
29
|
+
["pendingApproval", ["C", "Pending Approval"]],
|
|
30
|
+
["voided", ["V", "Voided"]],
|
|
31
|
+
]);
|
|
32
|
+
const PAY_STATUS = new Map([
|
|
33
|
+
["deposited", ["A", "Deposited"]],
|
|
34
|
+
["notDeposited", ["B", "Not Deposited"]],
|
|
35
|
+
]);
|
|
36
|
+
const CURRENCY = new Map([
|
|
37
|
+
["1", "USD"],
|
|
38
|
+
["3", "CAD"],
|
|
39
|
+
]);
|
|
40
|
+
const TERMS = new Map([
|
|
41
|
+
["1", "Net 15"],
|
|
42
|
+
["2", "Net 30"],
|
|
43
|
+
["3", "Due on receipt"],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
const enumRef = (table, key) => {
|
|
47
|
+
const entry = table.get(key);
|
|
48
|
+
return entry === undefined ? { id: key, refName: key } : { id: entry[0], refName: entry[1] };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const soStatus = (key) => enumRef(SO_STATUS, key);
|
|
52
|
+
export const invStatus = (key) => enumRef(INV_STATUS, key);
|
|
53
|
+
export const payStatus = (key) => enumRef(PAY_STATUS, key);
|
|
54
|
+
|
|
55
|
+
export function currencyRef(id) {
|
|
56
|
+
const name = CURRENCY.get(id);
|
|
57
|
+
return name === undefined ? null : { id, refName: name };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function termsRef(id) {
|
|
61
|
+
if (id === null || id === undefined) return null;
|
|
62
|
+
const name = TERMS.get(id);
|
|
63
|
+
return name === undefined ? null : { id, refName: name };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Index rows by internal id for refName resolution without a rescan. */
|
|
67
|
+
export function indexById(rows) {
|
|
68
|
+
const index = new Map();
|
|
69
|
+
for (const row of rows) index.set(row.id, row);
|
|
70
|
+
return index;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const customerRefName = (row) => (row === undefined || row === null ? null : row.entityId);
|
|
74
|
+
export const itemRefName = (row) => (row === undefined || row === null ? null : row.itemId);
|
|
75
|
+
export const subsidiaryRefName = (row) => (row === undefined || row === null ? null : row.name);
|
|
76
|
+
|
|
77
|
+
export function ref(resource, id, refName) {
|
|
78
|
+
if (id === null || id === undefined) return null;
|
|
79
|
+
return { links: selfLinks(resource, id), id, refName: refName ?? id };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Customer balances, computed from transactions on every read — never stored. */
|
|
83
|
+
export function customerTotals(customerId, invoices, orders, today) {
|
|
84
|
+
let balance = 0;
|
|
85
|
+
let overdue = 0;
|
|
86
|
+
const todayDays = dateToDays(today) ?? 0;
|
|
87
|
+
for (const invoice of invoices) {
|
|
88
|
+
if (invoice.entityId !== customerId) continue;
|
|
89
|
+
if (invoice.status === "voided") continue;
|
|
90
|
+
const remaining = round2(invoice.total - invoice.amountPaid);
|
|
91
|
+
if (remaining <= 0) continue;
|
|
92
|
+
balance += remaining;
|
|
93
|
+
const due = dateToDays(invoice.dueDate);
|
|
94
|
+
if (due !== null && due < todayDays) overdue += remaining;
|
|
95
|
+
}
|
|
96
|
+
let unbilled = 0;
|
|
97
|
+
for (const order of orders) {
|
|
98
|
+
if (order.entityId !== customerId) continue;
|
|
99
|
+
if (order.status === "closed" || order.status === "billed") continue;
|
|
100
|
+
const open = round2(order.total - order.billedTotal);
|
|
101
|
+
if (open > 0) unbilled += open;
|
|
102
|
+
}
|
|
103
|
+
return { balance: round2(balance), overdueBalance: round2(overdue), unbilledOrders: round2(unbilled) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function projectCustomer(row, context) {
|
|
107
|
+
const totals = customerTotals(row.id, context.invoiceRows, context.orderRows, context.today);
|
|
108
|
+
const record = {
|
|
109
|
+
links: selfLinks("customer", row.id),
|
|
110
|
+
id: row.id,
|
|
111
|
+
entityId: row.entityId,
|
|
112
|
+
isPerson: row.isPerson,
|
|
113
|
+
isInactive: row.isInactive,
|
|
114
|
+
subsidiary: ref("subsidiary", row.subsidiaryId, subsidiaryRefName(context.subsidiaries.get(row.subsidiaryId))),
|
|
115
|
+
currency: currencyRef(row.currencyId),
|
|
116
|
+
balance: totals.balance,
|
|
117
|
+
unbilledOrders: totals.unbilledOrders,
|
|
118
|
+
overdueBalance: totals.overdueBalance,
|
|
119
|
+
dateCreated: row.dateCreated,
|
|
120
|
+
lastModifiedDate: row.lastModifiedDate,
|
|
121
|
+
};
|
|
122
|
+
for (const [key, value] of [
|
|
123
|
+
["companyName", row.companyName],
|
|
124
|
+
["firstName", row.firstName],
|
|
125
|
+
["middleName", row.middleName],
|
|
126
|
+
["lastName", row.lastName],
|
|
127
|
+
["email", row.email],
|
|
128
|
+
["phone", row.phone],
|
|
129
|
+
["altPhone", row.altPhone],
|
|
130
|
+
["comments", row.comments],
|
|
131
|
+
["defaultAddress", row.defaultAddress],
|
|
132
|
+
]) {
|
|
133
|
+
if (value !== null && value !== undefined) record[key] = value;
|
|
134
|
+
}
|
|
135
|
+
if (row.creditLimit !== null && row.creditLimit !== undefined) record.creditLimit = row.creditLimit;
|
|
136
|
+
const terms = termsRef(row.termsId);
|
|
137
|
+
if (terms !== null) record.terms = terms;
|
|
138
|
+
if (context.expand === true) {
|
|
139
|
+
record.addressBook = {
|
|
140
|
+
links: [{ rel: "self", href: `${REC}/customer/${row.id}/addressBook` }],
|
|
141
|
+
items: row.addressBook.map((entry) => ({
|
|
142
|
+
links: [{ rel: "self", href: `${REC}/customer/${row.id}/addressBook/${entry.addrId}` }],
|
|
143
|
+
addressBookAddress: {
|
|
144
|
+
addressee: entry.addressee,
|
|
145
|
+
addr1: entry.addr1,
|
|
146
|
+
...(entry.addr2 === null ? {} : { addr2: entry.addr2 }),
|
|
147
|
+
city: entry.city,
|
|
148
|
+
state: entry.state,
|
|
149
|
+
zip: entry.zip,
|
|
150
|
+
country: { id: entry.country, refName: entry.country },
|
|
151
|
+
},
|
|
152
|
+
defaultBilling: entry.defaultBilling,
|
|
153
|
+
defaultShipping: entry.defaultShipping,
|
|
154
|
+
label: entry.label,
|
|
155
|
+
})),
|
|
156
|
+
totalResults: row.addressBook.length,
|
|
157
|
+
};
|
|
158
|
+
} else {
|
|
159
|
+
record.addressBook = { links: [{ rel: "self", href: `${REC}/customer/${row.id}/addressBook` }] };
|
|
160
|
+
}
|
|
161
|
+
return record;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function projectLine(resource, recordId, line, items) {
|
|
165
|
+
return {
|
|
166
|
+
links: [{ rel: "self", href: `${REC}/${resource}/${recordId}/item/${line.line}` }],
|
|
167
|
+
line: line.line,
|
|
168
|
+
item: ref("inventoryItem", line.itemId, itemRefName(items.get(line.itemId))),
|
|
169
|
+
quantity: line.quantity,
|
|
170
|
+
rate: line.rate,
|
|
171
|
+
amount: line.amount,
|
|
172
|
+
...(line.description === null ? {} : { description: line.description }),
|
|
173
|
+
taxRate: line.taxRate,
|
|
174
|
+
taxAmount: line.taxAmount,
|
|
175
|
+
...(line.orderLine === null ? {} : { orderLine: line.orderLine }),
|
|
176
|
+
quantityBilled: line.quantityBilled,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// The `q` record-filter grammar. Hand-written tokenizer and precedence parser: no regular expression is ever
|
|
2
|
+
// built from caller text, and every string match is a single linear scan.
|
|
3
|
+
import { quote } from "./errors.mjs";
|
|
4
|
+
import { parseDate } from "./primitives.mjs";
|
|
5
|
+
|
|
6
|
+
export const MAX_Q = 2000;
|
|
7
|
+
const MAX_COMPARISONS = 30;
|
|
8
|
+
const MAX_LIST = 20;
|
|
9
|
+
const MAX_DEPTH = 10;
|
|
10
|
+
|
|
11
|
+
const WORD_STOP = new Set([" ", "\t", "\n", "\r", "(", ")", "[", "]", ",", '"']);
|
|
12
|
+
|
|
13
|
+
class QError extends Error {}
|
|
14
|
+
const bad = (message) => {
|
|
15
|
+
throw new QError(message);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function tokenize(text) {
|
|
19
|
+
const tokens = [];
|
|
20
|
+
let index = 0;
|
|
21
|
+
while (index < text.length) {
|
|
22
|
+
const character = text[index];
|
|
23
|
+
if (character === " " || character === "\t" || character === "\n" || character === "\r") {
|
|
24
|
+
index += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (character === "\uFFFD") bad(`unreadable character at position ${index}`);
|
|
28
|
+
if (character === "(" || character === ")" || character === "[" || character === "]" || character === ",") {
|
|
29
|
+
tokens.push({ kind: character, at: index });
|
|
30
|
+
index += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (character === '"') {
|
|
34
|
+
let value = "";
|
|
35
|
+
let cursor = index + 1;
|
|
36
|
+
let closed = false;
|
|
37
|
+
while (cursor < text.length) {
|
|
38
|
+
const current = text[cursor];
|
|
39
|
+
if (current === "\\") {
|
|
40
|
+
const next = text[cursor + 1];
|
|
41
|
+
if (next !== '"' && next !== "\\") bad(`unsupported escape at position ${cursor}`);
|
|
42
|
+
value += next;
|
|
43
|
+
cursor += 2;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (current === '"') {
|
|
47
|
+
closed = true;
|
|
48
|
+
cursor += 1;
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
if (current === "\uFFFD") bad(`unreadable character at position ${cursor}`);
|
|
52
|
+
value += current;
|
|
53
|
+
cursor += 1;
|
|
54
|
+
}
|
|
55
|
+
if (!closed) bad(`unterminated string starting at position ${index}`);
|
|
56
|
+
tokens.push({ kind: "value", value, quoted: true, at: index });
|
|
57
|
+
index = cursor;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
let cursor = index;
|
|
61
|
+
let word = "";
|
|
62
|
+
while (cursor < text.length && !WORD_STOP.has(text[cursor])) {
|
|
63
|
+
if (text[cursor] === "\uFFFD") bad(`unreadable character at position ${cursor}`);
|
|
64
|
+
word += text[cursor];
|
|
65
|
+
cursor += 1;
|
|
66
|
+
}
|
|
67
|
+
if (word.length === 0) bad(`unexpected character at position ${index}`);
|
|
68
|
+
tokens.push({ kind: "word", value: word, at: index });
|
|
69
|
+
index = cursor;
|
|
70
|
+
}
|
|
71
|
+
return tokens;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const NONE_OPS = new Set(["EMPTY", "EMPTY_NOT"]);
|
|
75
|
+
const OPS = {
|
|
76
|
+
boolean: new Set(["IS", "IS_NOT"]),
|
|
77
|
+
numeric: new Set([
|
|
78
|
+
"ANY_OF", "ANY_OF_NOT", "BETWEEN", "BETWEEN_NOT", "EQUAL", "EQUAL_NOT",
|
|
79
|
+
"GREATER", "GREATER_NOT", "GREATER_OR_EQUAL", "GREATER_OR_EQUAL_NOT",
|
|
80
|
+
"LESS", "LESS_NOT", "LESS_OR_EQUAL", "LESS_OR_EQUAL_NOT", "WITHIN", "WITHIN_NOT",
|
|
81
|
+
]),
|
|
82
|
+
string: new Set([
|
|
83
|
+
"CONTAIN", "CONTAIN_NOT", "IS", "IS_NOT", "START_WITH", "START_WITH_NOT", "ENDWITH", "ENDWITH_NOT",
|
|
84
|
+
]),
|
|
85
|
+
date: new Set([
|
|
86
|
+
"AFTER", "AFTER_NOT", "BEFORE", "BEFORE_NOT", "ON", "ON_NOT",
|
|
87
|
+
"ON_OR_AFTER", "ON_OR_AFTER_NOT", "ON_OR_BEFORE", "ON_OR_BEFORE_NOT",
|
|
88
|
+
]),
|
|
89
|
+
};
|
|
90
|
+
const TWO_VALUE = new Set(["BETWEEN", "BETWEEN_NOT", "WITHIN", "WITHIN_NOT"]);
|
|
91
|
+
const LIST_OPS = new Set(["ANY_OF", "ANY_OF_NOT"]);
|
|
92
|
+
|
|
93
|
+
/** Parse one `q` expression against a field table; throws QError with a caller-safe message. */
|
|
94
|
+
export function parseQ(text, fields) {
|
|
95
|
+
if (text.length > MAX_Q) bad(`the q parameter is limited to ${MAX_Q} characters`);
|
|
96
|
+
const tokens = tokenize(text);
|
|
97
|
+
let position = 0;
|
|
98
|
+
let comparisons = 0;
|
|
99
|
+
const peek = () => (position < tokens.length ? tokens[position] : null);
|
|
100
|
+
const take = () => {
|
|
101
|
+
const token = peek();
|
|
102
|
+
if (token === null) bad("unexpected end of the q expression");
|
|
103
|
+
position += 1;
|
|
104
|
+
return token;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function value(token) {
|
|
108
|
+
if (token.kind !== "word" && token.kind !== "value") bad(`expected a value near position ${token.at}`);
|
|
109
|
+
return token.value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function comparison() {
|
|
113
|
+
comparisons += 1;
|
|
114
|
+
if (comparisons > MAX_COMPARISONS) bad(`the q expression is limited to ${MAX_COMPARISONS} comparisons`);
|
|
115
|
+
const fieldToken = take();
|
|
116
|
+
if (fieldToken.kind !== "word") bad(`expected a field name near position ${fieldToken.at}`);
|
|
117
|
+
const name = fieldToken.value;
|
|
118
|
+
if (!Object.hasOwn(fields, name)) bad(`unknown field ${quote(name)} at position ${fieldToken.at}`);
|
|
119
|
+
const type = fields[name].type;
|
|
120
|
+
const operatorToken = take();
|
|
121
|
+
if (operatorToken.kind !== "word") bad(`expected an operator near position ${operatorToken.at}`);
|
|
122
|
+
const operator = operatorToken.value.toUpperCase();
|
|
123
|
+
if (NONE_OPS.has(operator)) return { name, type, operator, values: [] };
|
|
124
|
+
const allowed = OPS[type];
|
|
125
|
+
if (allowed === undefined || !allowed.has(operator)) {
|
|
126
|
+
bad(`operator ${quote(operator)} cannot be used with the ${type} field ${quote(name)}`);
|
|
127
|
+
}
|
|
128
|
+
const values = [];
|
|
129
|
+
if (LIST_OPS.has(operator) && peek() !== null && peek().kind === "[") {
|
|
130
|
+
take();
|
|
131
|
+
for (;;) {
|
|
132
|
+
const next = peek();
|
|
133
|
+
if (next === null) bad("unterminated value list in the q expression");
|
|
134
|
+
if (next.kind === "]") {
|
|
135
|
+
take();
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
if (values.length > 0) {
|
|
139
|
+
const comma = take();
|
|
140
|
+
if (comma.kind !== ",") bad(`expected , in a value list at position ${comma.at}`);
|
|
141
|
+
}
|
|
142
|
+
values.push(value(take()));
|
|
143
|
+
if (values.length > MAX_LIST) bad(`a value list is limited to ${MAX_LIST} values`);
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
values.push(value(take()));
|
|
147
|
+
if (TWO_VALUE.has(operator)) {
|
|
148
|
+
const comma = peek();
|
|
149
|
+
if (comma !== null && comma.kind === ",") take();
|
|
150
|
+
values.push(value(take()));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (values.length === 0) bad(`operator ${quote(operator)} needs at least one value`);
|
|
154
|
+
return { name, type, operator, values };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function expression(depth) {
|
|
158
|
+
if (depth > MAX_DEPTH) bad(`the q expression nests deeper than ${MAX_DEPTH} levels`);
|
|
159
|
+
let node = term(depth);
|
|
160
|
+
for (;;) {
|
|
161
|
+
const next = peek();
|
|
162
|
+
if (next === null || next.kind !== "word" || next.value.toUpperCase() !== "OR") break;
|
|
163
|
+
take();
|
|
164
|
+
node = { kind: "or", left: node, right: term(depth) };
|
|
165
|
+
}
|
|
166
|
+
return node;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function term(depth) {
|
|
170
|
+
let node = factor(depth);
|
|
171
|
+
for (;;) {
|
|
172
|
+
const next = peek();
|
|
173
|
+
if (next === null || next.kind !== "word" || next.value.toUpperCase() !== "AND") break;
|
|
174
|
+
take();
|
|
175
|
+
node = { kind: "and", left: node, right: factor(depth) };
|
|
176
|
+
}
|
|
177
|
+
return node;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function factor(depth) {
|
|
181
|
+
const next = peek();
|
|
182
|
+
if (next === null) bad("unexpected end of the q expression");
|
|
183
|
+
if (next.kind === "(") {
|
|
184
|
+
take();
|
|
185
|
+
const inner = expression(depth + 1);
|
|
186
|
+
const close = take();
|
|
187
|
+
if (close.kind !== ")") bad(`expected ) at position ${close.at}`);
|
|
188
|
+
return inner;
|
|
189
|
+
}
|
|
190
|
+
return { kind: "comparison", ...comparison() };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const root = expression(1);
|
|
194
|
+
if (position !== tokens.length) bad(`unexpected token at position ${tokens[position].at}`);
|
|
195
|
+
return root;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export { QError };
|
|
199
|
+
|
|
200
|
+
const lower = (value) => (typeof value === "string" ? value.toLowerCase() : "");
|
|
201
|
+
|
|
202
|
+
function compareString(operator, actual, expected) {
|
|
203
|
+
const left = lower(actual);
|
|
204
|
+
const right = lower(expected);
|
|
205
|
+
switch (operator) {
|
|
206
|
+
case "IS": return left === right;
|
|
207
|
+
case "CONTAIN": return left.indexOf(right) >= 0;
|
|
208
|
+
case "START_WITH": return left.startsWith(right);
|
|
209
|
+
case "ENDWITH": return left.endsWith(right);
|
|
210
|
+
default: return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function numberOf(text) {
|
|
215
|
+
const parsed = Number(text);
|
|
216
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function compareNumber(operator, actual, values) {
|
|
220
|
+
if (actual === null || actual === undefined) return false;
|
|
221
|
+
const first = numberOf(values[0]);
|
|
222
|
+
if (first === null) bad(`value ${quote(values[0])} is not a number`);
|
|
223
|
+
switch (operator) {
|
|
224
|
+
case "EQUAL": return actual === first;
|
|
225
|
+
case "GREATER": return actual > first;
|
|
226
|
+
case "GREATER_OR_EQUAL": return actual >= first;
|
|
227
|
+
case "LESS": return actual < first;
|
|
228
|
+
case "LESS_OR_EQUAL": return actual <= first;
|
|
229
|
+
case "ANY_OF": return values.some((entry) => {
|
|
230
|
+
const parsed = numberOf(entry);
|
|
231
|
+
if (parsed === null) bad(`value ${quote(entry)} is not a number`);
|
|
232
|
+
return actual === parsed;
|
|
233
|
+
});
|
|
234
|
+
case "BETWEEN":
|
|
235
|
+
case "WITHIN": {
|
|
236
|
+
const second = numberOf(values[1] ?? "");
|
|
237
|
+
if (second === null) bad(`operator ${operator} needs two numeric values`);
|
|
238
|
+
return actual >= first && actual <= second;
|
|
239
|
+
}
|
|
240
|
+
default: return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function compareDate(operator, actual, values) {
|
|
245
|
+
if (typeof actual !== "string" || actual.length === 0) return false;
|
|
246
|
+
const first = parseDateValue(values[0]);
|
|
247
|
+
switch (operator) {
|
|
248
|
+
case "ON": return actual === first;
|
|
249
|
+
case "AFTER": return actual > first;
|
|
250
|
+
case "ON_OR_AFTER": return actual >= first;
|
|
251
|
+
case "BEFORE": return actual < first;
|
|
252
|
+
case "ON_OR_BEFORE": return actual <= first;
|
|
253
|
+
default: return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function parseDateValue(text) {
|
|
258
|
+
const parsed = parseDate(text);
|
|
259
|
+
if (parsed === null) bad(`value ${quote(text)} is not a date`);
|
|
260
|
+
return parsed;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const isEmpty = (value) => value === null || value === undefined || value === "";
|
|
264
|
+
|
|
265
|
+
/** Evaluate a parsed q tree against one projected filter row. */
|
|
266
|
+
export function evaluate(node, row) {
|
|
267
|
+
if (node.kind === "and") return evaluate(node.left, row) && evaluate(node.right, row);
|
|
268
|
+
if (node.kind === "or") return evaluate(node.left, row) || evaluate(node.right, row);
|
|
269
|
+
const actual = Object.hasOwn(row, node.name) ? row[node.name] : null;
|
|
270
|
+
const negated = node.operator.endsWith("_NOT");
|
|
271
|
+
const operator = negated ? node.operator.slice(0, -4) : node.operator;
|
|
272
|
+
let result;
|
|
273
|
+
if (operator === "EMPTY") result = isEmpty(actual);
|
|
274
|
+
else if (node.type === "boolean") result = (lower(node.values[0]) === "true") === (actual === true);
|
|
275
|
+
else if (node.type === "string") result = compareString(operator, actual, node.values[0]);
|
|
276
|
+
else if (node.type === "numeric") result = compareNumber(operator, actual, node.values);
|
|
277
|
+
else if (node.type === "date") result = compareDate(operator, actual, node.values);
|
|
278
|
+
else result = false;
|
|
279
|
+
return negated ? !result : result;
|
|
280
|
+
}
|