@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.
Files changed (41) hide show
  1. package/README.md +2 -0
  2. package/dist/action/ja.generate-pdf.d.ts +17 -1
  3. package/dist/action/ja.generate-pdf.d.ts.map +1 -1
  4. package/dist/action/ja.generate-pdf.js +4 -1
  5. package/dist/action/ja.in-background.d.ts +3 -2
  6. package/dist/action/ja.in-background.d.ts.map +1 -1
  7. package/dist/action/ja.in-background.js +1 -1
  8. package/dist/assets/example-extraction-cache.json +3 -3
  9. package/dist/assets/extracted-core-sdk-examples.yaml +18 -0
  10. package/dist/assets/extracted-core-sdk-types.yaml +58 -0
  11. package/dist/assets/type-extraction-cache.json +3 -3
  12. package/docs/array-fields.md +371 -0
  13. package/docs/conditional-logic.md +178 -0
  14. package/docs/convention-naming.md +102 -0
  15. package/docs/date-field.md +92 -0
  16. package/docs/dropdown-fields.md +879 -0
  17. package/docs/field-state.md +131 -0
  18. package/docs/field-types-overview.md +132 -0
  19. package/docs/formatting.md +421 -0
  20. package/docs/icons.md +142 -0
  21. package/docs/index.md +23 -0
  22. package/docs/jsonata-expressions.md +200 -0
  23. package/docs/media-fields.md +107 -0
  24. package/docs/overview.md +467 -0
  25. package/docs/pattern-build-deploy.md +91 -0
  26. package/docs/pattern-datasources.md +459 -0
  27. package/docs/pattern-forms.md +528 -0
  28. package/docs/pattern-global-actions.md +92 -0
  29. package/docs/pattern-javascript-functions.md +452 -0
  30. package/docs/pattern-navigation.md +304 -0
  31. package/docs/pattern-pdf-generation.md +391 -0
  32. package/docs/pattern-rest-acumatica.md +660 -0
  33. package/docs/pattern-sync-progress.md +96 -0
  34. package/docs/pattern-sync.md +653 -0
  35. package/docs/pattern-tabs-form.md +293 -0
  36. package/docs/recipe-index.md +64 -0
  37. package/docs/runtime-variables.md +127 -0
  38. package/docs/sections.md +81 -0
  39. package/docs/validation-patterns.md +150 -0
  40. package/package.json +5 -4
  41. 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`