@jigx/core-sdk 1.0.0 → 1.2.0-rc
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/README.md +2 -0
- package/dist/action/ja.generate-pdf.d.ts +17 -1
- package/dist/action/ja.generate-pdf.d.ts.map +1 -1
- package/dist/action/ja.generate-pdf.js +4 -1
- package/dist/action/ja.in-background.d.ts +3 -2
- package/dist/action/ja.in-background.d.ts.map +1 -1
- package/dist/action/ja.in-background.js +1 -1
- package/dist/assets/example-extraction-cache.json +3 -3
- package/dist/assets/extracted-core-sdk-examples.yaml +18 -0
- package/dist/assets/extracted-core-sdk-types.yaml +58 -0
- package/dist/assets/type-extraction-cache.json +3 -3
- package/docs/array-fields.md +371 -0
- package/docs/conditional-logic.md +178 -0
- package/docs/convention-naming.md +102 -0
- package/docs/date-field.md +92 -0
- package/docs/dropdown-fields.md +879 -0
- package/docs/field-state.md +131 -0
- package/docs/field-types-overview.md +132 -0
- package/docs/formatting.md +421 -0
- package/docs/icons.md +142 -0
- package/docs/index.md +23 -0
- package/docs/jsonata-expressions.md +200 -0
- package/docs/media-fields.md +107 -0
- package/docs/overview.md +467 -0
- package/docs/pattern-build-deploy.md +91 -0
- package/docs/pattern-datasources.md +459 -0
- package/docs/pattern-forms.md +528 -0
- package/docs/pattern-global-actions.md +92 -0
- package/docs/pattern-javascript-functions.md +452 -0
- package/docs/pattern-navigation.md +304 -0
- package/docs/pattern-pdf-generation.md +391 -0
- package/docs/pattern-rest-acumatica.md +660 -0
- package/docs/pattern-sync-progress.md +96 -0
- package/docs/pattern-sync.md +653 -0
- package/docs/pattern-tabs-form.md +293 -0
- package/docs/recipe-index.md +64 -0
- package/docs/runtime-variables.md +127 -0
- package/docs/sections.md +81 -0
- package/docs/validation-patterns.md +150 -0
- package/package.json +5 -4
- package/CHANGELOG.md +0 -95
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
# Pattern: REST Integration (Acumatica)
|
|
2
|
+
|
|
3
|
+
## Best-practice boundary
|
|
4
|
+
|
|
5
|
+
Use this file to define how Acumatica REST functions are built, not when UI flows should call them.
|
|
6
|
+
|
|
7
|
+
The best-practice architecture is:
|
|
8
|
+
|
|
9
|
+
1. Save all user edits to local tables first.
|
|
10
|
+
2. Mark records with local sync state (`Remote = "new"` / `"dirty"`).
|
|
11
|
+
3. Let a centralized sync workflow select unsynced records and push them to Acumatica.
|
|
12
|
+
|
|
13
|
+
Do not treat inline remote writes from create/edit screens as the default pattern. Some older projects do this, but it is legacy behavior and should not be copied into new apps unless there is a documented exception.
|
|
14
|
+
|
|
15
|
+
DoorPro is one such legacy/project-specific example: it contains direct Acumatica write functions for order submission. Those functions are useful as integration references, but they are not the source of truth for outbound sync architecture.
|
|
16
|
+
|
|
17
|
+
## Acumatica conventions (remember these)
|
|
18
|
+
|
|
19
|
+
- **HTTP method**: PUT for creates/updates (Acumatica-specific — not POST)
|
|
20
|
+
- **`useLocalCall: true`** — always, for all REST functions
|
|
21
|
+
- **`accessToken`** header parameter with `type: 'acuerp'` and `value: 'acuerp'` — Jigx handles OAuth token collection, storage, and injection automatically
|
|
22
|
+
- **URL pattern**: `https://{acumaticaURL}EntityName` — `acumaticaURL` comes from the config table (e.g., `jigx.acumatica.com/entity/Default/23.200.001/`)
|
|
23
|
+
- **Config field names are case-sensitive**: use `acumaticaURL` for REST and `acumaticaOdataURL` for OData everywhere, including config data, datasource access, function params, and action parameters
|
|
24
|
+
- **$expand parameter**: Passed from the action (not hardcoded on the function), location `query`
|
|
25
|
+
- **No msgBody parameter**: Use queries to fetch data at execution time (see Queries section)
|
|
26
|
+
|
|
27
|
+
## Per-line InventoryID / LineType decisions
|
|
28
|
+
|
|
29
|
+
When a parent record submits multiple child lines, do not use a parent-level flag to decide
|
|
30
|
+
whether every child line should be a stock item or a non-stock item. Store the mode on each
|
|
31
|
+
local child row and calculate the Acumatica line fields per row.
|
|
32
|
+
|
|
33
|
+
This matters when the user can mix strict catalog/matrix-backed lines with generic quote lines.
|
|
34
|
+
For example, a door quote may contain one line that should use a validated stock `InventoryID`
|
|
35
|
+
and another line that should use a generic non-stock `InventoryID`.
|
|
36
|
+
|
|
37
|
+
Pattern:
|
|
38
|
+
|
|
39
|
+
- Save a per-line boolean such as `onlyValidDoorConfiguration` on the child row.
|
|
40
|
+
- Default new rows to the strict/validated mode when that is the desired UX, but preserve explicit `false`.
|
|
41
|
+
- In the Acumatica payload map, compute `$strictLine` from the current row, not from the parent quote.
|
|
42
|
+
- For strict rows, send the generated/validated inventory code as `InventoryID`, set `LineType` to `"Inventory Item"`, and usually omit `Description` so Acumatica can use the stock item's description.
|
|
43
|
+
- For generic rows, send the agreed generic non-stock code such as `DOORQUOTE`, set `LineType` to `"Non-Stock Item"`, include a user-facing `Description`, and keep the generated/internal configuration code in the line `note`.
|
|
44
|
+
|
|
45
|
+
## Record sync state (`Remote` field)
|
|
46
|
+
|
|
47
|
+
All records include a `Remote` field to track sync state. Always use strings (never mix boolean/string):
|
|
48
|
+
|
|
49
|
+
| Value | Meaning |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `"new"` | Created locally, never synced to Acumatica |
|
|
52
|
+
| `"dirty"` | Previously synced, has local changes pending |
|
|
53
|
+
| `"remote"` | In sync with Acumatica |
|
|
54
|
+
|
|
55
|
+
New records set `"Remote": "new"` in the save expression. After successful sync, the function's upsert-merge operation sets it to `"remote"`.
|
|
56
|
+
|
|
57
|
+
This state exists specifically to support local-first persistence with deferred outbound sync. The screen/form save action should update the local record and its `Remote` state only. A separate sync action is responsible for selecting records in `"new"` / `"dirty"` state and calling the remote function.
|
|
58
|
+
|
|
59
|
+
For incomplete local records, use a separate local readiness state such as `LocalState = "draft"`.
|
|
60
|
+
Do not overload `Remote` to mean "not ready to sync yet".
|
|
61
|
+
|
|
62
|
+
A datasource selects unsynced records:
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
app.addDatasource
|
|
66
|
+
.sqlite({ datasourceId: 'data-select-customer-to-save', provider: 'dynamic' })
|
|
67
|
+
.entity('default/customers')
|
|
68
|
+
.query(`SELECT id, data FROM [default/customers] WHERE json_extract(data, '$.Remote') = 'new' OR json_extract(data, '$.Remote') = 'dirty' LIMIT 1`)
|
|
69
|
+
.jsonProperties('data')
|
|
70
|
+
.isDocument(true)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For real batch sync, prefer a datasource that selects all syncable rows, not just `LIMIT 1`.
|
|
74
|
+
The sync action then passes that datasource collection into `executeEntities(...)`.
|
|
75
|
+
|
|
76
|
+
## Acumatica create vs update behavior
|
|
77
|
+
|
|
78
|
+
Acumatica determines create vs update based on the JSON body:
|
|
79
|
+
|
|
80
|
+
| Body contains | Behavior |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| No `id` or key field | Creates a new record |
|
|
83
|
+
| `id: <guid>` (top-level, not `.value`) | Updates by internal ID |
|
|
84
|
+
| Key field (e.g., `CustomerID`) without `id` | Looks up by key field, updates if found |
|
|
85
|
+
|
|
86
|
+
For `new` records we strip `id` from the body (via inputTransform) so Acumatica creates. For `dirty` records we include `id` so Acumatica updates.
|
|
87
|
+
|
|
88
|
+
## $expand — getting child objects in the response
|
|
89
|
+
|
|
90
|
+
Acumatica responses only include top-level fields by default. To get nested/child objects, pass a `$expand` query parameter listing the objects to include.
|
|
91
|
+
|
|
92
|
+
For nested expansion (child of child), use `/` notation: `ShippingContact/Address`.
|
|
93
|
+
|
|
94
|
+
Arrays are not allowed as children of children in Acumatica.
|
|
95
|
+
|
|
96
|
+
Example expand value for Customer:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
BillingContact/Address,ShippingContact/Address,PrimaryContact/Address,MainContact/Address,Files
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Pass `$expand` from the action as a `functionParameter`, not hardcoded on the function definition. Different callers may need different expansions.
|
|
103
|
+
|
|
104
|
+
For lookup/reference syncs, the same rule still applies in practice: if a screen reads nested fields like
|
|
105
|
+
`MainContact.Address` or `LocationContact.Address`, the lookup sync must request those expansions explicitly.
|
|
106
|
+
Do not assume the child objects will exist in local data just because the screen references them.
|
|
107
|
+
|
|
108
|
+
Before implementing a new lookup/reference sync, inspect the screen fields and the available schema,
|
|
109
|
+
then propose the likely `$expand` list up front and confirm it if there is any ambiguity.
|
|
110
|
+
|
|
111
|
+
Default working rule:
|
|
112
|
+
|
|
113
|
+
- derive a candidate `$expand` list from the nested fields the UI needs
|
|
114
|
+
- show that list early during planning if the endpoint or child shape is not already settled
|
|
115
|
+
- ask for confirmation when the schemas suggest multiple plausible child objects
|
|
116
|
+
- only then wire the sync
|
|
117
|
+
|
|
118
|
+
This is especially important for reference entities where top-level fields may look sufficient at first,
|
|
119
|
+
but the real UI requirement depends on nested contact/address/status objects.
|
|
120
|
+
|
|
121
|
+
For customer-location contact display, prefer `LocationContact.FullName` and fall back to
|
|
122
|
+
`LocationContact.DisplayName`. `LocationContact.Attention` is also not consistently populated, so
|
|
123
|
+
fall back to `LocationContact.JobTitle` before dropping to customer-level contact fields.
|
|
124
|
+
|
|
125
|
+
If you add a new `$expand` to a lookup sync, remember that unchanged Acumatica rows will not be
|
|
126
|
+
returned by a standard `LastModifiedDateTime` diff sync. In practice that means local cached rows
|
|
127
|
+
can remain structurally stale even though the sync succeeds. The default recipe should therefore be:
|
|
128
|
+
|
|
129
|
+
- keep normal sync incremental
|
|
130
|
+
- add a separate explicit `Force Sync` action for full lookup refresh when needed
|
|
131
|
+
- use that action during testing or rollout until the cached rows have been refreshed locally
|
|
132
|
+
|
|
133
|
+
## Defining a REST function
|
|
134
|
+
|
|
135
|
+
Standard parameters for Acumatica REST functions:
|
|
136
|
+
|
|
137
|
+
| Parameter | Location | Purpose |
|
|
138
|
+
| --- | --- | --- |
|
|
139
|
+
| `acumaticaURL` | path | Base URL from config table |
|
|
140
|
+
| `accessToken` | header | OAuth token (type `acuerp`) |
|
|
141
|
+
| `$expand` | query | Child objects to include in response |
|
|
142
|
+
| `id` | body | Local record ID (used by queries and operations) |
|
|
143
|
+
| `remote` | body | Sync state (used by inputTransform/operations) |
|
|
144
|
+
|
|
145
|
+
The `id` and `remote` body parameters are not sent to the API — the `inputTransform` builds the request body from the query result.
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
const fn = app.addFunction
|
|
149
|
+
.rest({
|
|
150
|
+
functionId: 'rest-put-customer',
|
|
151
|
+
method: 'PUT',
|
|
152
|
+
url: 'https://{acumaticaURL}Customer',
|
|
153
|
+
useLocalCall: true,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
fn.params.path('acumaticaURL', { required: true })
|
|
157
|
+
fn.params.header('accessToken', 'acuerp', { required: true, type: 'acuerp' })
|
|
158
|
+
fn.params.query('$expand', { type: 'string', required: false })
|
|
159
|
+
fn.params.body('id', undefined, { type: 'string', required: true })
|
|
160
|
+
fn.params.body('remote', undefined, { type: 'string', required: true })
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Queries — fetching fresh data at execution time (preferred pattern)
|
|
164
|
+
|
|
165
|
+
When an `executeEntity` call is queued (e.g., user is offline), the function parameters are snapshot at queue time. If the local data changes before the queue processes, the snapshot is stale.
|
|
166
|
+
|
|
167
|
+
**The preferred pattern** is to pass only the record `id` (not the full data) and use a `queries` section on the function to fetch the latest data from the local table when the command queue item is processed.
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
// No msgBody parameter — data comes from queries at execution time
|
|
171
|
+
fn.params.body('id', undefined, { type: 'string', required: true })
|
|
172
|
+
fn.params.body('remote', undefined, { type: 'string', required: true })
|
|
173
|
+
|
|
174
|
+
fn.query('get-customer-to-save', {
|
|
175
|
+
statement: 'SELECT id, data FROM [Customers] WHERE id = @id',
|
|
176
|
+
resultType: 'record',
|
|
177
|
+
jsonColumns: ['data'],
|
|
178
|
+
parameters: { id: '=@ctx.parameters.id' },
|
|
179
|
+
tables: ['Customers'],
|
|
180
|
+
})
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Query results are accessed via `@ctx.queries.<queryName>`. With `jsonColumns: ['data']`, the data column is auto-parsed.
|
|
184
|
+
|
|
185
|
+
The action only passes `id` and `remote` — no data parameter:
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
buttons.add.executeAction({
|
|
189
|
+
action: ACTION.CUSTOMER_SAVE_REMOTE,
|
|
190
|
+
parameters: {
|
|
191
|
+
id: '=@ctx.datasources.data-select-customer-to-save.id',
|
|
192
|
+
remote: '=@ctx.datasources.data-select-customer-to-save.data.Remote',
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Use this pattern unless you specifically need queue-time data snapshots.
|
|
198
|
+
|
|
199
|
+
This is the preferred default for batch sync as well. The action passes identifiers and sync
|
|
200
|
+
metadata, and the function re-queries the latest local row before constructing the Acumatica body.
|
|
201
|
+
|
|
202
|
+
## inputTransform — shaping the request body
|
|
203
|
+
|
|
204
|
+
The inputTransform is a JSONata expression that transforms the query result before sending to the API. It operates on `@ctx.queries` and `@ctx.parameters`, and its result becomes the HTTP request body.
|
|
205
|
+
|
|
206
|
+
For Acumatica:
|
|
207
|
+
- Always strip `Remote` (internal tracking field, not an Acumatica field)
|
|
208
|
+
- Strip `id` + the entity's key field (`CustomerID`, `ContactID`) when `new` — Acumatica assigns these on creation
|
|
209
|
+
- Keep them when `dirty` — Acumatica needs them to identify the record to update
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
// Customer — strip id + CustomerID when new
|
|
213
|
+
fn.inputTransform(
|
|
214
|
+
'=$sift(@ctx.queries.get-customer-to-save.data, function($v, $k) { $k != "Remote" and (@ctx.parameters.remote != "new" or ($k != "id" and $k != "CustomerID")) })',
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
// Contact — strip id + ContactID when new
|
|
218
|
+
fn.inputTransform(
|
|
219
|
+
'=$sift(@ctx.queries.get-contact-to-save.data, function($v, $k) { $k != "Remote" and (@ctx.parameters.remote != "new" or ($k != "id" and $k != "ContactID")) })',
|
|
220
|
+
)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Each entity has its own key field to strip. The pattern is: `$k != "id" and $k != "<EntityKeyField>"`.
|
|
224
|
+
|
|
225
|
+
Also set the key field's form control to `isDisabled: true` on create screens — it's assigned by Acumatica, not the user.
|
|
226
|
+
|
|
227
|
+
## Operations — processing the response
|
|
228
|
+
|
|
229
|
+
Operations run after the HTTP response is received. They update the local database with the response data.
|
|
230
|
+
|
|
231
|
+
### 1. find-replace: Update local ID with Acumatica ID (new records only)
|
|
232
|
+
|
|
233
|
+
When Acumatica creates a record, it assigns a GUID (`id`/`NoteID`). We replace our local UUID with Acumatica's GUID so the record ID matches going forward.
|
|
234
|
+
|
|
235
|
+
```typescript
|
|
236
|
+
fn.operation()
|
|
237
|
+
.findReplace(
|
|
238
|
+
'=@ctx.parameters.id', // find: our local UUID
|
|
239
|
+
'=@ctx.response.body.id', // replace: Acumatica's GUID
|
|
240
|
+
['customers'], // in this table
|
|
241
|
+
false, // includeCommandQueue: false
|
|
242
|
+
)
|
|
243
|
+
.when('=@ctx.parameters.remote = "new"')
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`includeCommandQueue: false` skips the command queue table. Set to `true` when related records in other tables have queued commands that reference this ID.
|
|
247
|
+
|
|
248
|
+
The `when` condition ensures we only do the find-replace for new records — unnecessary for dirty records since the ID already matches.
|
|
249
|
+
|
|
250
|
+
### 2. find-replace: Update local ID in related tables (parent-child)
|
|
251
|
+
|
|
252
|
+
When a parent record (e.g., Customer) is created in Acumatica, child records (e.g., Contacts) reference the parent via a local UUID. After sync, replace that UUID with the Acumatica-assigned key so relationships stay intact.
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
// Replace local customer UUID with Acumatica CustomerID in contacts table
|
|
256
|
+
fn.operation()
|
|
257
|
+
.findReplace(
|
|
258
|
+
'=@ctx.parameters.id', // find: local UUID
|
|
259
|
+
'=@ctx.response.body.CustomerID.value', // replace: Acumatica CustomerID
|
|
260
|
+
['contacts'], // in the contacts table
|
|
261
|
+
false,
|
|
262
|
+
)
|
|
263
|
+
.when('=@ctx.parameters.remote = "new"')
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
This works because find-replace searches for the string value across all data in the specified table. When a contact has `"BusinessAccount": {"value": "local-uuid"}`, the local UUID string gets replaced with the Acumatica CustomerID.
|
|
267
|
+
|
|
268
|
+
**Key insight**: The replace value for the parent table uses `@ctx.response.body.id` (the GUID), but for child tables use the business key (`CustomerID.value`) because that's what child records reference via `BusinessAccount.value`.
|
|
269
|
+
|
|
270
|
+
This parent-first then child-sync ordering is the standard recipe when child rows depend on a
|
|
271
|
+
key returned by Acumatica. Sync the parent first, update child references via operations, then
|
|
272
|
+
execute child sync.
|
|
273
|
+
|
|
274
|
+
### 3. upsert-merge: Merge response into local record
|
|
275
|
+
|
|
276
|
+
Merge the full Acumatica response (which includes calculated fields, expanded child objects, etc.) with `Remote: "remote"` to mark as synced.
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
fn.operation().upsertMerge(
|
|
280
|
+
'customers',
|
|
281
|
+
'=$merge([@ctx.response.body, {"Remote": "remote"}])',
|
|
282
|
+
)
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`upsert-merge` merges the JSON response with the existing local record — it doesn't replace the entire record. This preserves any local-only fields.
|
|
286
|
+
|
|
287
|
+
### 4. success markers for multi-call sends
|
|
288
|
+
|
|
289
|
+
When one user action performs more than one Acumatica REST call, do not mark the
|
|
290
|
+
overall business record as sent after merely enqueueing the calls. Give the send
|
|
291
|
+
attempt a `sendBatchId`, pass it as an output parameter to every REST function,
|
|
292
|
+
and let each function write its own success marker from a success-only
|
|
293
|
+
`operation().upsertMerge(...)`.
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
fn.params.output('opportunityID', { required: true, type: 'string' })
|
|
297
|
+
fn.params.output('sendBatchId', { required: true, type: 'string' })
|
|
298
|
+
|
|
299
|
+
fn.operation().upsertMerge(
|
|
300
|
+
'quoteAcumaticaStatus',
|
|
301
|
+
`={
|
|
302
|
+
"id": @ctx.parameters.opportunityID,
|
|
303
|
+
"opportunityID": @ctx.parameters.opportunityID,
|
|
304
|
+
"sendBatchId": @ctx.parameters.sendBatchId,
|
|
305
|
+
"linesSent": true,
|
|
306
|
+
"linesSentAt": $now()
|
|
307
|
+
}`,
|
|
308
|
+
)
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
The final REST function in the batch should query the status row for the same
|
|
312
|
+
`sendBatchId` and only mark the overall record as sent when all required prior
|
|
313
|
+
markers exist. Store the final state in a Dynamic Data table, not only in local
|
|
314
|
+
state, so other users and devices can see that the quote/order was already sent.
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
fn.query('send-status', {
|
|
318
|
+
statement: 'SELECT id, data FROM [default/quoteAcumaticaStatus] WHERE id = @opportunityID LIMIT 1',
|
|
319
|
+
resultType: 'record',
|
|
320
|
+
jsonColumns: ['data'],
|
|
321
|
+
parameters: { opportunityID: '=@ctx.parameters.opportunityID' },
|
|
322
|
+
tables: ['default/quoteAcumaticaStatus'],
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
fn.operation()
|
|
326
|
+
.upsertMerge(
|
|
327
|
+
'salesInfoAnswers',
|
|
328
|
+
`={
|
|
329
|
+
"id": @ctx.parameters.opportunityID,
|
|
330
|
+
"sentToAcumatica": true,
|
|
331
|
+
"sentToAcumaticaAt": $now(),
|
|
332
|
+
"lastAcumaticaSendBatchId": @ctx.parameters.sendBatchId
|
|
333
|
+
}`,
|
|
334
|
+
)
|
|
335
|
+
.when(
|
|
336
|
+
'=@ctx.queries.send-status.data.linesSent = true and @ctx.queries.send-status.data.sendBatchId = @ctx.parameters.sendBatchId',
|
|
337
|
+
)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
This avoids false positives: failed REST calls run error handlers/queue failure
|
|
341
|
+
logic and never execute success operations, so the overall sent flag is written
|
|
342
|
+
only after every required Acumatica step has succeeded for the same batch.
|
|
343
|
+
|
|
344
|
+
## Calling the function from an action
|
|
345
|
+
|
|
346
|
+
These examples show how a sync action calls the REST function. They do not mean the jig screen should call Acumatica directly as part of the primary save flow.
|
|
347
|
+
|
|
348
|
+
The action passes remote function inputs through `parameters`:
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
workflow.actions
|
|
352
|
+
.executeEntity({
|
|
353
|
+
instanceId: 'save-customer-remote',
|
|
354
|
+
function: 'rest-put-customer',
|
|
355
|
+
parameters: {
|
|
356
|
+
acumaticaURL: '=@ctx.datasources.data-select-config.data.acumaticaURL',
|
|
357
|
+
accessToken: 'acuerp',
|
|
358
|
+
$expand: 'BillingContact/Address,ShippingContact/Address,...',
|
|
359
|
+
id: '=@ctx.action.parameters.id',
|
|
360
|
+
remote: '=@ctx.action.parameters.remote',
|
|
361
|
+
},
|
|
362
|
+
})
|
|
363
|
+
.rest('none', 'functionCall')
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Important: `parameters` are passed only to the function call. `data(...)` is written only by the execute action. They are no longer merged.
|
|
367
|
+
|
|
368
|
+
For batch sync from a header action, the UI should pass a datasource collection of syncable rows:
|
|
369
|
+
|
|
370
|
+
```typescript
|
|
371
|
+
header.actions.executeAction({
|
|
372
|
+
title: 'Sync',
|
|
373
|
+
action: ACTION.SYNC_ALL,
|
|
374
|
+
icon: 'cloud-sync',
|
|
375
|
+
parameters: {
|
|
376
|
+
records: '=@ctx.datasources.syncable-records',
|
|
377
|
+
},
|
|
378
|
+
when: '=$count(@ctx.datasources.syncable-records) > 0',
|
|
379
|
+
})
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Then the global action fans out over that collection:
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
workflow.actions
|
|
386
|
+
.executeEntities({
|
|
387
|
+
instanceId: 'sync-records',
|
|
388
|
+
function: 'rest-put-customer',
|
|
389
|
+
parameters: `=@ctx.action.parameters.records.{
|
|
390
|
+
"acumaticaURL": @ctx.datasources.data-select-config.data.acumaticaURL,
|
|
391
|
+
"accessToken": "acuerp",
|
|
392
|
+
"$expand": "BillingContact/Address,ShippingContact/Address,PrimaryContact/Address,MainContact/Address",
|
|
393
|
+
"id": $.id,
|
|
394
|
+
"remote": $.data.Remote
|
|
395
|
+
}`,
|
|
396
|
+
})
|
|
397
|
+
.rest('none', 'functionCall')
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
### Method behavior
|
|
401
|
+
|
|
402
|
+
| Method | Behavior |
|
|
403
|
+
| --- | --- |
|
|
404
|
+
| `functionCall` | Only calls the REST function. No local entity write. |
|
|
405
|
+
| `save`, `create`, `update` | Calls the function AND saves data to the specified local entity. |
|
|
406
|
+
|
|
407
|
+
Use `functionCall` as the default for Acumatica REST calls unless otherwise specified.
|
|
408
|
+
|
|
409
|
+
## File uploads to Acumatica
|
|
410
|
+
|
|
411
|
+
Acumatica file uploads are usually not sent to the entity endpoint itself. They use a dedicated
|
|
412
|
+
`files/...` route that includes the remote parent record id / NoteID in the URL.
|
|
413
|
+
|
|
414
|
+
Example shape:
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
const fn = app.addFunction.rest({
|
|
418
|
+
functionId: 'rest-put-parent-photo',
|
|
419
|
+
method: 'PUT',
|
|
420
|
+
url: 'https://{acumaticaURL}files/PX.Objects.FS.ServiceOrderEntry/ServiceOrderRecords/{recordId}/{filename}',
|
|
421
|
+
useLocalCall: true,
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
fn.params.path('acumaticaURL', { required: true })
|
|
425
|
+
fn.params.header('accessToken', 'acuerp', { required: true, type: 'acuerp' })
|
|
426
|
+
fn.params.path('recordId', { required: true })
|
|
427
|
+
fn.params.path('filename', { required: true })
|
|
428
|
+
fn.params.header('Content-Type', undefined, { required: true, type: 'string' })
|
|
429
|
+
fn.params.body('file', { required: true, type: 'image' })
|
|
430
|
+
fn.params.output('photoId', { required: true, type: 'string' })
|
|
431
|
+
|
|
432
|
+
fn.conversion('file', 'local-uri', 'buffer')
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Use this pattern when:
|
|
436
|
+
|
|
437
|
+
- files are stored locally as device `fileUrl` / `uri`
|
|
438
|
+
- the upload endpoint expects binary body content
|
|
439
|
+
- the parent record must exist remotely before the upload can succeed
|
|
440
|
+
|
|
441
|
+
Important rules:
|
|
442
|
+
|
|
443
|
+
- upload one file per function call
|
|
444
|
+
- pass only function inputs through `parameters`
|
|
445
|
+
- keep local file storage separate from the remote upload route
|
|
446
|
+
- prefer `local-uri -> buffer` conversion for binary upload
|
|
447
|
+
- if you need local row metadata after the call, pass that metadata through output parameters and
|
|
448
|
+
update local tables with function operations
|
|
449
|
+
|
|
450
|
+
### Getting the correct upload URL
|
|
451
|
+
|
|
452
|
+
Do not guess the `files/...` route from memory.
|
|
453
|
+
|
|
454
|
+
Preferred order:
|
|
455
|
+
|
|
456
|
+
1. inspect the swagger for the entity's file links / upload routes
|
|
457
|
+
2. inspect an actual Acumatica response for the entity's `_links` or file path patterns
|
|
458
|
+
3. if the route is still ambiguous, ask for the full upload URL before implementing
|
|
459
|
+
|
|
460
|
+
This should be confirmed early in planning, the same way `$expand` lists are confirmed early for
|
|
461
|
+
nested lookup data.
|
|
462
|
+
|
|
463
|
+
## UI flow rule: save local, sync centrally
|
|
464
|
+
|
|
465
|
+
For create/edit forms:
|
|
466
|
+
|
|
467
|
+
- Save to the local entity first.
|
|
468
|
+
- Set `Remote` to `"new"` for new records or `"dirty"` for updates.
|
|
469
|
+
- Return control to the user without waiting for Acumatica.
|
|
470
|
+
|
|
471
|
+
Then, outside the form save flow:
|
|
472
|
+
|
|
473
|
+
- A centralized sync action, sync queue, or orchestrator selects unsynced rows.
|
|
474
|
+
- That sync workflow calls the REST function using the record `id` and current `remote` state.
|
|
475
|
+
- On success, the response is merged locally and the row is marked `"remote"`.
|
|
476
|
+
|
|
477
|
+
This keeps the user workflow resilient offline, reduces coupling between form UX and network behavior, and makes retries/failure handling consistent across the app.
|
|
478
|
+
|
|
479
|
+
### Calling from a jig
|
|
480
|
+
|
|
481
|
+
Pass only `id` and `remote` — the function's query fetches the latest data:
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
header.actions.executeAction({
|
|
485
|
+
title: 'Save Single Customer',
|
|
486
|
+
action: ACTION.CUSTOMER_SAVE_REMOTE,
|
|
487
|
+
parameters: {
|
|
488
|
+
id: '=@ctx.datasources.data-select-customer-to-save.id',
|
|
489
|
+
remote: '=@ctx.datasources.data-select-customer-to-save.data.Remote',
|
|
490
|
+
},
|
|
491
|
+
})
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
## Error handlers for REST PUT functions
|
|
495
|
+
|
|
496
|
+
Add standard error handlers to every Acumatica REST function. The handlers cover authentication, permissions, validation, timeouts, throttling, empty responses, and not-found errors.
|
|
497
|
+
|
|
498
|
+
Use a shared helper to keep error handling consistent across functions:
|
|
499
|
+
|
|
500
|
+
```typescript
|
|
501
|
+
// app.ts
|
|
502
|
+
app.script('acumaticaerrors.js', readScriptFile('acumaticaerrors.js'))
|
|
503
|
+
|
|
504
|
+
// functions/acumatica-error-handlers.ts
|
|
505
|
+
export function addAcumaticaPutErrorHandlers(fn: RestFunctionBuilder, entityLabel: string): void {
|
|
506
|
+
fn.error('=@ctx.response.status = 401')
|
|
507
|
+
.notification(true)
|
|
508
|
+
.alert({ icon: 'on-error-sad', title: '401 - Not authenticated', description: '...' })
|
|
509
|
+
|
|
510
|
+
fn.error('=@ctx.response.status = 422')
|
|
511
|
+
.notification(true)
|
|
512
|
+
.details(`=$acumaticaerrors.findNestedErrors(@ctx.response.body,
|
|
513
|
+
"Acumatica rejected the request. Review the values and try again.")`)
|
|
514
|
+
.alert({ icon: 'on-error-sad', title: '422 - Data Validation error',
|
|
515
|
+
description: `=$acumaticaerrors.findNestedErrors(@ctx.response.body,
|
|
516
|
+
"Acumatica rejected the ${entityLabel}. Review the values and try again.")` })
|
|
517
|
+
.operation('=@ctx.response.body')
|
|
518
|
+
.upsertReplace('rest-put-errors')
|
|
519
|
+
|
|
520
|
+
// 503, 504, 429 — retry with delay
|
|
521
|
+
fn.error('=@ctx.response.status = 503')
|
|
522
|
+
.retry({ delay: 5000, maxRetries: 3 })
|
|
523
|
+
.notification(true)
|
|
524
|
+
.alert({ icon: 'on-error-sad', title: '503 - Acumatica Unavailable', description: '...' })
|
|
525
|
+
.operation('=@ctx.response.body')
|
|
526
|
+
.upsertReplace('diff-sync-errors')
|
|
527
|
+
|
|
528
|
+
// ... additional handlers for 403, 404, 429, 504, empty body, non-JSON response
|
|
529
|
+
}
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
Apply to each function:
|
|
533
|
+
|
|
534
|
+
```typescript
|
|
535
|
+
addAcumaticaPutErrorHandlers(fn, 'customer')
|
|
536
|
+
addAcumaticaPutErrorHandlers(fn, 'contact')
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### Standard error handler list
|
|
540
|
+
|
|
541
|
+
| Status | Action | Retry | Error table |
|
|
542
|
+
| ---: | --- | --- | --- |
|
|
543
|
+
| 401 | Alert: not authenticated | no | — |
|
|
544
|
+
| 403 | Alert: no permission | no | `diff-sync-errors` |
|
|
545
|
+
| 404 | Alert: not found | no | — |
|
|
546
|
+
| 422 | Alert: validation error with `$acumaticaerrors.findNestedErrors()` | no | `rest-put-errors` |
|
|
547
|
+
| 429 | Alert: throttled | 3x @ 5s | `diff-sync-errors` |
|
|
548
|
+
| 503 | Alert: unavailable | 3x @ 5s | `diff-sync-errors` |
|
|
549
|
+
| 504 | Alert: timeout | 3x @ 5s | `diff-sync-errors` |
|
|
550
|
+
| 200 + empty body | Alert: no data returned | 3x @ 5s | `diff-sync-errors` |
|
|
551
|
+
| 200 + non-JSON | Alert: unexpected response | no | `diff-sync-errors` |
|
|
552
|
+
|
|
553
|
+
Add `diff-sync-errors` and `rest-put-errors` tables to the database definition.
|
|
554
|
+
|
|
555
|
+
The `entityLabel` parameter customizes error messages per entity (e.g., "Error saving customer" vs "Error saving contact").
|
|
556
|
+
|
|
557
|
+
### Parsing Acumatica 422 validation responses
|
|
558
|
+
|
|
559
|
+
Acumatica `422 Unprocessable Entity` responses often bury useful validation text in
|
|
560
|
+
nested `error` fields, sometimes under `Details`, and those strings can be escaped.
|
|
561
|
+
Do not show the raw `"Function failed with 422 Unprocessable Entity"` message to the
|
|
562
|
+
user. Register a small app script that recursively scans the response body, collects
|
|
563
|
+
every nested `error` value, unescapes it, deduplicates messages, and returns a concise
|
|
564
|
+
user-facing string:
|
|
565
|
+
|
|
566
|
+
```javascript
|
|
567
|
+
// scripts/acumaticaerrors.js
|
|
568
|
+
export function findNestedErrors(body, fallback) {
|
|
569
|
+
// Recursively scan objects/arrays for keys named "error" and return:
|
|
570
|
+
// Acumatica validation failed:
|
|
571
|
+
// - First validation message
|
|
572
|
+
// - Second validation message
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
Use it in the `422` handler description and details so the notification and error
|
|
577
|
+
details contain the Acumatica validation messages rather than the generic HTTP status.
|
|
578
|
+
|
|
579
|
+
### Retrying failed command-queue calls with corrected payloads
|
|
580
|
+
|
|
581
|
+
`action.retry-queue-command` replays the command exactly as it was queued. Use it only when
|
|
582
|
+
the original payload is still correct and the failure was transient or the remote data has been
|
|
583
|
+
fixed.
|
|
584
|
+
|
|
585
|
+
For validation errors where the local app can offer a fallback payload, do not retry the original
|
|
586
|
+
command. Instead:
|
|
587
|
+
|
|
588
|
+
1. Show a warning that explains the validation failure and the two recovery choices.
|
|
589
|
+
2. If the user confirms the remote master data was fixed, call `action.retry-queue-command`.
|
|
590
|
+
3. If the user chooses the fallback, delete the failed queue command with `action.delete-queue-command`.
|
|
591
|
+
4. Execute a new REST function call with the corrected fallback payload.
|
|
592
|
+
|
|
593
|
+
Example: an Acumatica service-order detail call can fail with 422 when an inventory ID does not
|
|
594
|
+
exist. If the user wants to keep the exact inventory ID, retry the queued command after the item
|
|
595
|
+
has been added in Acumatica. If the user chooses generic non-stock quote items, delete the failed
|
|
596
|
+
command and send a new payload using the generic non-stock inventory IDs. Retrying the original
|
|
597
|
+
command would keep sending the missing inventory ID.
|
|
598
|
+
|
|
599
|
+
## Batch sync: executeEntities with parameters map
|
|
600
|
+
|
|
601
|
+
When multiple records need syncing, use `executeEntities` (plural) with a `parameters` map expression. Jigx calls the function once per item in the array.
|
|
602
|
+
|
|
603
|
+
**Important**: Use `executeEntities` (not `executeEntity`) — only `executeEntities` supports the map pattern.
|
|
604
|
+
|
|
605
|
+
```typescript
|
|
606
|
+
// Action accepts an array of records
|
|
607
|
+
action.addParameter('records', { type: 'array', required: true })
|
|
608
|
+
|
|
609
|
+
// executeEntities maps each record to a function call
|
|
610
|
+
workflow.actions
|
|
611
|
+
.executeEntities({
|
|
612
|
+
instanceId: 'save-customer-remote',
|
|
613
|
+
function: 'rest-put-customer',
|
|
614
|
+
parameters: `=@ctx.action.parameters.records.{
|
|
615
|
+
"acumaticaURL": @ctx.datasources.data-select-config.data.acumaticaURL,
|
|
616
|
+
"accessToken": "acuerp",
|
|
617
|
+
"$expand": "BillingContact/Address,...",
|
|
618
|
+
"id": $.id,
|
|
619
|
+
"remote": $.data.Remote
|
|
620
|
+
}`,
|
|
621
|
+
})
|
|
622
|
+
.rest('none', 'functionCall')
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
The JSONata `.{ }` operator maps each item in the array. `$.id` and `$.data.Remote` reference the current item's fields.
|
|
626
|
+
|
|
627
|
+
`parameters` are sent to the function only. If the action also calls `.data(...)`, that payload is what gets saved to the entity.
|
|
628
|
+
|
|
629
|
+
### Calling from the sync action
|
|
630
|
+
|
|
631
|
+
Pass the full datasource array:
|
|
632
|
+
|
|
633
|
+
```typescript
|
|
634
|
+
workflow.actions.executeAction({
|
|
635
|
+
action: 'act-customer-save-remote',
|
|
636
|
+
parameters: {
|
|
637
|
+
records: '=@ctx.datasources.data-select-customer-to-save',
|
|
638
|
+
},
|
|
639
|
+
when: '=$count(@ctx.datasources.data-select-customer-to-save) > 0',
|
|
640
|
+
})
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
The datasource returns all new/dirty records (no `LIMIT 1`, no `isDocument`).
|
|
644
|
+
|
|
645
|
+
### Sequencing for parent-child
|
|
646
|
+
|
|
647
|
+
Push parents before children — children may depend on IDs assigned to parents by the remote system:
|
|
648
|
+
|
|
649
|
+
1. Push customers (find-replace updates CustomerID in contacts table)
|
|
650
|
+
2. Push contacts (now has correct CustomerID in BusinessAccount.value)
|
|
651
|
+
3. Pull customers (diff sync)
|
|
652
|
+
4. Pull contacts (diff sync)
|
|
653
|
+
|
|
654
|
+
**Acumatica-specific**: Acumatica does not accept arrays of objects — each record must be sent individually. The `executeEntities` map pattern handles this automatically.
|
|
655
|
+
|
|
656
|
+
## Config table for integration settings
|
|
657
|
+
|
|
658
|
+
Store the Acumatica base URL and other integration config in a `config` table. See [pattern-datasources.md](pattern-datasources.md#config-table) for the full pattern.
|
|
659
|
+
|
|
660
|
+
Access: `=@ctx.datasources.data-select-config.data.acumaticaURL`
|