@sparkerp/plugin-sdk 0.1.0
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 +21 -0
- package/README.md +61 -0
- package/bundle/blocks.json +12229 -0
- package/bundle/catalog.json +32475 -0
- package/bundle/docs/applications/hcm/employee-profile.md +144 -0
- package/bundle/docs/concepts/artifact-types.md +80 -0
- package/bundle/docs/concepts/glossary.md +124 -0
- package/bundle/docs/concepts/overview.md +88 -0
- package/bundle/docs/concepts/page-data-flow.md +84 -0
- package/bundle/docs/concepts/plugin-lifecycle.md +81 -0
- package/bundle/docs/concepts/sdk-modes.md +71 -0
- package/bundle/docs/docs.json +109 -0
- package/bundle/docs/guides/add-a-cadence-job.md +134 -0
- package/bundle/docs/guides/add-a-compliance-job.md +139 -0
- package/bundle/docs/guides/add-a-create-edit-form.md +167 -0
- package/bundle/docs/guides/add-a-custom-block.md +225 -0
- package/bundle/docs/guides/add-a-data-provider.md +216 -0
- package/bundle/docs/guides/add-a-kpi.md +154 -0
- package/bundle/docs/guides/add-a-reminder-job.md +181 -0
- package/bundle/docs/guides/add-an-aggregation-job.md +153 -0
- package/bundle/docs/guides/add-an-approval-workflow.md +222 -0
- package/bundle/docs/guides/add-an-entity.md +170 -0
- package/bundle/docs/guides/add-business-rules.md +147 -0
- package/bundle/docs/guides/add-i18n.md +121 -0
- package/bundle/docs/guides/add-menus.md +149 -0
- package/bundle/docs/guides/build-a-code-plugin.md +141 -0
- package/bundle/docs/guides/build-a-page.md +213 -0
- package/bundle/docs/guides/build-a-polyglot-service-plugin.md +201 -0
- package/bundle/docs/guides/build-a-tenant-extension-service.md +149 -0
- package/bundle/docs/guides/create-a-plugin.md +172 -0
- package/bundle/docs/guides/expose-a-plugin-operation-as-an-ai-tool.md +114 -0
- package/bundle/docs/guides/index.md +53 -0
- package/bundle/docs/guides/publish-and-upgrade.md +152 -0
- package/bundle/docs/guides/responsive-plugin.md +117 -0
- package/bundle/docs/guides/seed-data-on-install.md +127 -0
- package/bundle/docs/guides/set-up-the-sdk.md +120 -0
- package/bundle/docs/guides/use-the-mcp-server.md +104 -0
- package/bundle/docs/guides/validate-and-test.md +119 -0
- package/bundle/docs/guides/wire-a-pages-data.md +153 -0
- package/bundle/docs/quickstart/index.md +118 -0
- package/bundle/docs/recipes/condition-based-compliance-flag.md +70 -0
- package/bundle/docs/recipes/cross-plugin-offboarding-fanout.md +68 -0
- package/bundle/docs/recipes/cross-row-aggregation.md +67 -0
- package/bundle/docs/recipes/entity-with-approval-workflow.md +72 -0
- package/bundle/docs/recipes/index.md +40 -0
- package/bundle/docs/recipes/install-lifecycle-data-seeding.md +76 -0
- package/bundle/docs/recipes/kpi-dashboard-page.md +92 -0
- package/bundle/docs/recipes/master-detail-page.md +84 -0
- package/bundle/docs/recipes/n-days-before-reminder.md +72 -0
- package/bundle/docs/recipes/per-row-document-generator.md +65 -0
- package/bundle/docs/recipes/related-record-lookup-field.md +74 -0
- package/bundle/docs/recipes/resolved-reference-grid-column.md +63 -0
- package/bundle/docs/recipes/scheduled-record-generation.md +68 -0
- package/bundle/docs/reference/action.md +18 -0
- package/bundle/docs/reference/block-definition.md +35 -0
- package/bundle/docs/reference/block-instance.md +33 -0
- package/bundle/docs/reference/composite-pack-manifest.md +33 -0
- package/bundle/docs/reference/connector-definition.md +36 -0
- package/bundle/docs/reference/data-service-definition.md +22 -0
- package/bundle/docs/reference/data-view-definition.md +26 -0
- package/bundle/docs/reference/entity-aggregation-config.md +38 -0
- package/bundle/docs/reference/entity-cadence-config.md +33 -0
- package/bundle/docs/reference/entity-compliance-config.md +37 -0
- package/bundle/docs/reference/entity-cross-plugin-action-config.md +37 -0
- package/bundle/docs/reference/entity-definition.md +18 -0
- package/bundle/docs/reference/entity-document-generator-config.md +35 -0
- package/bundle/docs/reference/entity-rule-definition.md +29 -0
- package/bundle/docs/reference/entity-status-date-sweep-config.md +27 -0
- package/bundle/docs/reference/form.md +35 -0
- package/bundle/docs/reference/index.md +37 -0
- package/bundle/docs/reference/menu.md +26 -0
- package/bundle/docs/reference/mobile-nav.md +22 -0
- package/bundle/docs/reference/page-template.md +29 -0
- package/bundle/docs/reference/page.md +44 -0
- package/bundle/docs/reference/plugin-manifest.md +54 -0
- package/bundle/docs/reference/plugin-seed-data.md +21 -0
- package/bundle/docs/reference/print-template-definition.md +22 -0
- package/bundle/docs/reference/provider-definition.md +22 -0
- package/bundle/docs/reference/report-definition.md +22 -0
- package/bundle/docs/reference/rule.md +30 -0
- package/bundle/docs/reference/workflow-definition.md +28 -0
- package/bundle/docs/troubleshooting/grid-shows-zero-rows.md +82 -0
- package/bundle/docs/troubleshooting/job-never-runs.md +89 -0
- package/bundle/docs/troubleshooting/known-gotchas.md +193 -0
- package/bundle/docs/troubleshooting/publishing-errors.md +119 -0
- package/bundle/docs/tutorial/01-create-the-plugin.md +157 -0
- package/bundle/docs/tutorial/02-category-entity-and-seed.md +94 -0
- package/bundle/docs/tutorial/03-equipment-entity.md +121 -0
- package/bundle/docs/tutorial/04-equipment-register-page.md +153 -0
- package/bundle/docs/tutorial/05-checkout-entity-and-page.md +116 -0
- package/bundle/docs/tutorial/06-checkout-approval-workflow.md +139 -0
- package/bundle/docs/tutorial/07-return-due-reminder-job.md +129 -0
- package/bundle/docs/tutorial/08-menus-i18n-publish.md +123 -0
- package/bundle/docs/tutorial/index.md +72 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-aggregation-configs.json +18 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cadence-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-compliance-configs.json +74 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cross-plugin-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-document-configs.json +9 -0
- package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-sweep-configs.json +260 -0
- package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/entities/hcm_salary_planning_entry.json +41 -0
- package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/page/salary-planning.json +1 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/page/promotions.json +10944 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.movement.json +44 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.promotion-acknowledge.json +19 -0
- package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.resignation.json +29 -0
- package/bundle/examples/example-patterns.mjs +94 -0
- package/bundle/manifest.json +14 -0
- package/bundle/schemas/action.schema.json +102 -0
- package/bundle/schemas/block-definition.schema.json +222 -0
- package/bundle/schemas/block-instance.schema.json +139 -0
- package/bundle/schemas/composite-pack-manifest.schema.json +160 -0
- package/bundle/schemas/connector-definition.schema.json +29 -0
- package/bundle/schemas/data-service-definition.schema.json +64 -0
- package/bundle/schemas/data-view-definition.schema.json +148 -0
- package/bundle/schemas/entity-aggregation-config.schema.json +32 -0
- package/bundle/schemas/entity-cadence-config.schema.json +27 -0
- package/bundle/schemas/entity-compliance-config.schema.json +31 -0
- package/bundle/schemas/entity-cross-plugin-action-config.schema.json +31 -0
- package/bundle/schemas/entity-definition.schema.json +126 -0
- package/bundle/schemas/entity-document-generator-config.schema.json +29 -0
- package/bundle/schemas/entity-rule-definition.schema.json +75 -0
- package/bundle/schemas/entity-status-date-sweep-config.schema.json +21 -0
- package/bundle/schemas/form.schema.json +281 -0
- package/bundle/schemas/menu.schema.json +106 -0
- package/bundle/schemas/mobile-nav.schema.json +53 -0
- package/bundle/schemas/page-template.schema.json +46 -0
- package/bundle/schemas/page.schema.json +292 -0
- package/bundle/schemas/plugin-manifest.schema.json +139 -0
- package/bundle/schemas/plugin-seed-data.schema.json +24 -0
- package/bundle/schemas/print-template-definition.schema.json +90 -0
- package/bundle/schemas/provider-definition.schema.json +27 -0
- package/bundle/schemas/report-definition.schema.json +96 -0
- package/bundle/schemas/rule.schema.json +82 -0
- package/bundle/schemas/workflow-definition.schema.json +42 -0
- package/bundle/validators/block-engine.mjs +23222 -0
- package/bundle/validators/page-engine.mjs +21645 -0
- package/erp-cli/authoring-root.mjs +344 -0
- package/erp-cli/erp.mjs +4065 -0
- package/erp-cli/example-patterns.mjs +94 -0
- package/erp-mcp-server/index.mjs +818 -0
- package/package.json +27 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Add a scheduled reminder job
|
|
3
|
+
audience: tenant
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Add a scheduled reminder job
|
|
7
|
+
|
|
8
|
+
## What you're doing
|
|
9
|
+
|
|
10
|
+
"Flip this record's status once a date passes" — or "N days before it passes".
|
|
11
|
+
Overdue checkouts, expiring warranties, upcoming audits. **You write no Java and
|
|
12
|
+
no job class.** The platform ships one generic job,
|
|
13
|
+
`engine-entity.status-date-sweep`, that runs daily and applies every
|
|
14
|
+
`entity_status_date_sweep_config` row. Adding a reminder = adding one config row,
|
|
15
|
+
shipped with your plugin as seed data.
|
|
16
|
+
|
|
17
|
+
## How the generic job works
|
|
18
|
+
|
|
19
|
+
Once a day (cron `0 5 0 * * *`, per tenant) the job:
|
|
20
|
+
|
|
21
|
+
1. reads every active `entity_status_date_sweep_config` row;
|
|
22
|
+
2. for each, lists all rows of that config's `entity_name`;
|
|
23
|
+
3. for every row whose `status_field` is one of `when_status_in` **and** whose
|
|
24
|
+
`date_field` satisfies `compare_op` against *now* (optionally shifted by
|
|
25
|
+
`day_offset` days), it sets `set_field` (defaults to `status_field`) to
|
|
26
|
+
`set_status_to`.
|
|
27
|
+
|
|
28
|
+
Because a swept row no longer matches `when_status_in`, re-running the job is a
|
|
29
|
+
harmless no-op — it's naturally idempotent.
|
|
30
|
+
|
|
31
|
+
## The complete example
|
|
32
|
+
|
|
33
|
+
`spk-assembly/metadata/seed-data/office-equipment-sweep-configs.json` —
|
|
34
|
+
[real file](../tutorial/example-plugin/spk-assembly/metadata/seed-data/office-equipment-sweep-configs.json):
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"entity": "entity_status_date_sweep_config",
|
|
39
|
+
"keyFields": ["entity_name", "date_field", "set_status_to", "day_offset"],
|
|
40
|
+
"source": "office-equipment",
|
|
41
|
+
"rows": [
|
|
42
|
+
{
|
|
43
|
+
"entity_name": "oeq_checkout",
|
|
44
|
+
"status_field": "status",
|
|
45
|
+
"when_status_in": "CHECKED_OUT",
|
|
46
|
+
"date_field": "due_date",
|
|
47
|
+
"compare_op": "lte",
|
|
48
|
+
"day_offset": 3,
|
|
49
|
+
"set_status_to": "DUE_SOON",
|
|
50
|
+
"active": true,
|
|
51
|
+
"seeded_by": "office-equipment"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"entity_name": "oeq_checkout",
|
|
55
|
+
"status_field": "status",
|
|
56
|
+
"when_status_in": "CHECKED_OUT,DUE_SOON",
|
|
57
|
+
"date_field": "due_date",
|
|
58
|
+
"compare_op": "lt",
|
|
59
|
+
"day_offset": 0,
|
|
60
|
+
"set_status_to": "OVERDUE",
|
|
61
|
+
"active": true,
|
|
62
|
+
"seeded_by": "office-equipment"
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
You also need the shared config entity to exist. Ship it (idempotent-additive):
|
|
69
|
+
`spk-assembly/metadata/entities/entity_status_date_sweep_config.json` —
|
|
70
|
+
[real file](../tutorial/example-plugin/spk-assembly/metadata/entities/entity_status_date_sweep_config.json).
|
|
71
|
+
|
|
72
|
+
## Line by line
|
|
73
|
+
|
|
74
|
+
### The seed-data wrapper
|
|
75
|
+
|
|
76
|
+
- **`entity`** — `entity_status_date_sweep_config`, the shared platform config
|
|
77
|
+
table.
|
|
78
|
+
- **`keyFields`** — the natural key. On re-install, a row matching **all** of
|
|
79
|
+
these is updated (only if a value changed) rather than duplicated. Include
|
|
80
|
+
`day_offset` so the 3-day and 0-day rows for the same date column are treated
|
|
81
|
+
as distinct.
|
|
82
|
+
- **`source`** — your plugin id; stamped into `seeded_by`.
|
|
83
|
+
- **`rows`** — the config rows.
|
|
84
|
+
|
|
85
|
+
### Each config row
|
|
86
|
+
|
|
87
|
+
- **`entity_name`** — the entity to sweep.
|
|
88
|
+
- **`status_field`** — which column holds the lifecycle status (default
|
|
89
|
+
`status`).
|
|
90
|
+
- **`when_status_in`** — comma-separated statuses a row must currently be in to
|
|
91
|
+
be eligible.
|
|
92
|
+
- **`date_field`** — the date/timestamp column to compare against now.
|
|
93
|
+
- **`compare_op`** — `lte | lt | gte | gt`.
|
|
94
|
+
- **`day_offset`** — `0` = compare against now exactly. `3` = compare against
|
|
95
|
+
`now + 3 days`, i.e. "fires when `due_date` is within 3 days" — this is the
|
|
96
|
+
"N days before" reminder. Negative shifts earlier.
|
|
97
|
+
- **`set_field`** — the column to write. **Omit it** to write `status_field`
|
|
98
|
+
itself.
|
|
99
|
+
- **`set_status_to`** — the new value.
|
|
100
|
+
|
|
101
|
+
### The two rows together
|
|
102
|
+
|
|
103
|
+
- Row 1: a `CHECKED_OUT` checkout whose `due_date` is within 3 days → `DUE_SOON`.
|
|
104
|
+
- Row 2: a `CHECKED_OUT` **or** `DUE_SOON` checkout whose `due_date` has passed →
|
|
105
|
+
`OVERDUE`.
|
|
106
|
+
|
|
107
|
+
This two-row shape (a "soon" warning then an "it happened" flip) is the exact
|
|
108
|
+
pattern the platform's own `leave_policy` sweep uses.
|
|
109
|
+
|
|
110
|
+
### Flip a status, not a boolean
|
|
111
|
+
|
|
112
|
+
Write to an **enum/text status column**, as above. Writing `set_status_to:
|
|
113
|
+
"true"` to a **boolean** `set_field` currently fails inside the generic job with
|
|
114
|
+
a SQL type error — the job passes the value as a string. Model reminders as
|
|
115
|
+
status values (`DUE_SOON`, `OVERDUE`) rather than boolean flags until that is
|
|
116
|
+
fixed. (This was found while building this tutorial.)
|
|
117
|
+
|
|
118
|
+
## The job registers itself
|
|
119
|
+
|
|
120
|
+
The platform ships an `AFTER_CREATE` rule on `entity_status_date_sweep_config`
|
|
121
|
+
that calls `ensureEntityStatusDateSweepJobRegistered` — so the **first** config
|
|
122
|
+
row your plugin seeds auto-registers the job for your tenant. You don't ship that
|
|
123
|
+
rule.
|
|
124
|
+
|
|
125
|
+
## Ground yourself first
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
erp schema pull entity-status-date-sweep-config
|
|
129
|
+
erp schema pull plugin-seed-data
|
|
130
|
+
erp examples patterns --kind jobs
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## How to verify it worked
|
|
134
|
+
|
|
135
|
+
After publishing, the job is registered and enabled:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
erp api get "/api/v1/jobs/engine-entity.status-date-sweep"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{ "jobCode": "engine-entity.status-date-sweep", "status": "ENABLED",
|
|
143
|
+
"cronExpression": "0 5 0 * * *", "concurrencyPolicy": "PER_TENANT" }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Run it on demand and check a record flips. **This exact sequence was run against
|
|
147
|
+
the tutorial module:**
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
# a checkout that is CHECKED_OUT with a due_date in the past
|
|
151
|
+
erp api post "/api/v1/entities/oeq_checkout/records" \
|
|
152
|
+
--body '{"checkout_number":"CO-100","equipment_id":1,"employee_id":1,"checkout_date":"2026-08-01","due_date":"2026-09-04","status":"CHECKED_OUT"}'
|
|
153
|
+
|
|
154
|
+
erp api post "/api/v1/jobs/engine-entity.status-date-sweep/execute" --body "{}"
|
|
155
|
+
# → { "executionId": 534 }
|
|
156
|
+
|
|
157
|
+
erp api get "/api/v1/jobs/engine-entity.status-date-sweep/executions?size=1"
|
|
158
|
+
# → resultJson: {"swept": 4, "failed": 0, "rowsScanned": 102, "configsScanned": 30}
|
|
159
|
+
|
|
160
|
+
erp api get "/api/v1/entities/oeq_checkout/records/2"
|
|
161
|
+
# → { "checkout_number": "CO-100", "status": "OVERDUE" }
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
`CHECKED_OUT` → `OVERDUE`. Verified.
|
|
165
|
+
|
|
166
|
+
## Common mistakes
|
|
167
|
+
|
|
168
|
+
| Symptom | Cause | Fix |
|
|
169
|
+
| --- | --- | --- |
|
|
170
|
+
| job execution reports `"failed": N` | `set_field` is a boolean column | flip a status value instead |
|
|
171
|
+
| nothing sweeps | `when_status_in` casing doesn't match the enum | match exactly |
|
|
172
|
+
| "N days before" never fires | forgot `day_offset` (defaults to 0) | set `day_offset` to the window |
|
|
173
|
+
| re-install duplicates the config | `keyFields` don't uniquely identify the row | include enough fields (add `day_offset`, `set_field`) |
|
|
174
|
+
| job not registered | plugin seeded no config row, or the config entity wasn't shipped | ship `entity_status_date_sweep_config.json` and at least one seed row |
|
|
175
|
+
|
|
176
|
+
## What to read next
|
|
177
|
+
|
|
178
|
+
- [Add a cross-row aggregation job](./add-an-aggregation-job.md)
|
|
179
|
+
- [Add a cadence-generator job](./add-a-cadence-job.md)
|
|
180
|
+
- [Add a compliance-sweep job](./add-a-compliance-job.md)
|
|
181
|
+
- Recipe: [N-days-before reminder job](../recipes/n-days-before-reminder.md)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Add a cross-row aggregation job
|
|
3
|
+
audience: tenant
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Add a cross-row aggregation job
|
|
7
|
+
|
|
8
|
+
## What you're doing
|
|
9
|
+
|
|
10
|
+
"Count / sum / average many rows and store the result somewhere." A nightly
|
|
11
|
+
inventory reconciliation, a per-asset maintenance-cost rollup, seats-used per
|
|
12
|
+
licence. **No Java.** One `entity_aggregation_config` row, run by the generic
|
|
13
|
+
`engine-entity.aggregation-sweep` job, shipped as seed data.
|
|
14
|
+
|
|
15
|
+
This is the batch cousin of a [KPI Data Service](./add-a-kpi.md): a KPI is
|
|
16
|
+
computed on demand for a screen; an aggregation job writes a number back into a
|
|
17
|
+
table on a schedule so other rows/pages/rules can use it.
|
|
18
|
+
|
|
19
|
+
## Two target shapes
|
|
20
|
+
|
|
21
|
+
| You want | Set |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| a **summary entity** with one row per bucket (e.g. count of equipment per status) | `target_key_field` = a text key column, `target_key_prefix` to namespace it |
|
|
24
|
+
| a value written **onto the source's own parent row** (e.g. `synced_cost` onto a maintenance record) | `target_key_field: "id"` — the bucket key *is* the parent's primary key |
|
|
25
|
+
|
|
26
|
+
## The complete example — count per status into a summary entity
|
|
27
|
+
|
|
28
|
+
`spk-assembly/metadata/seed-data/office-equipment-aggregation-configs.json`
|
|
29
|
+
(standalone example,
|
|
30
|
+
[real file](../examples/standalone-plugin/spk-assembly/metadata/seed-data/office-equipment-aggregation-configs.json)):
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"entity": "entity_aggregation_config",
|
|
35
|
+
"keyFields": ["sweep_code"],
|
|
36
|
+
"source": "office-equipment",
|
|
37
|
+
"rows": [
|
|
38
|
+
{
|
|
39
|
+
"sweep_code": "equipment-status-reconciliation",
|
|
40
|
+
"source_entity": "oeq_equipment",
|
|
41
|
+
"aggregation": "count",
|
|
42
|
+
"group_by_field": "status",
|
|
43
|
+
"target_entity": "oeq_reconciliation",
|
|
44
|
+
"target_key_field": "metric_key",
|
|
45
|
+
"target_key_prefix": "equipment_status:",
|
|
46
|
+
"target_value_field": "metric_value",
|
|
47
|
+
"target_timestamp_field": "last_reconciled_at",
|
|
48
|
+
"seeded_by": "office-equipment",
|
|
49
|
+
"active": true
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
You also ship: the `oeq_reconciliation` summary entity (`metric_key` text,
|
|
56
|
+
`metric_value` numeric, `last_reconciled_at` datetime), the shared
|
|
57
|
+
`entity_aggregation_config` entity file (idempotent-additive), and an
|
|
58
|
+
`AFTER_CREATE` register rule (below).
|
|
59
|
+
|
|
60
|
+
## Line by line
|
|
61
|
+
|
|
62
|
+
- **`sweep_code`** — unique id for this config; the natural key.
|
|
63
|
+
- **`source_entity`** — the table to fold.
|
|
64
|
+
- **`aggregation`** — `count` | `sum` | `avg` | `min` | `max`. The last four need
|
|
65
|
+
**`agg_field`** (the numeric column to fold).
|
|
66
|
+
- **`group_by_field`** — the bucket. `count` grouped by `status` → one number per
|
|
67
|
+
status value.
|
|
68
|
+
- **`when_status_in` / `status_field`** — optional filter: only fold rows in
|
|
69
|
+
these statuses.
|
|
70
|
+
- **`target_entity`** — where results go.
|
|
71
|
+
- **`target_key_field` + `target_key_prefix`** — for each bucket, the target row
|
|
72
|
+
is keyed `<prefix><bucket value>` (e.g. `equipment_status:AVAILABLE`). Prefix
|
|
73
|
+
lets several configs share one summary table.
|
|
74
|
+
- **`target_value_field`** — the column that receives the number.
|
|
75
|
+
- **`target_timestamp_field`** — stamped with the run time.
|
|
76
|
+
|
|
77
|
+
### The "write onto the parent row" variant
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"sweep_code": "maintenance-cost-rollup",
|
|
82
|
+
"source_entity": "oeq_maintenance_cost",
|
|
83
|
+
"aggregation": "sum",
|
|
84
|
+
"agg_field": "total_cost",
|
|
85
|
+
"group_by_field": "maintenance_id",
|
|
86
|
+
"target_entity": "oeq_maintenance",
|
|
87
|
+
"target_key_field": "id",
|
|
88
|
+
"target_value_field": "synced_cost",
|
|
89
|
+
"target_timestamp_field": "cost_synced_at",
|
|
90
|
+
"active": true
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Here `group_by_field` values **are** `oeq_maintenance` primary keys, so the sum
|
|
95
|
+
is written straight onto each maintenance row. Leave `target_key_prefix` blank.
|
|
96
|
+
|
|
97
|
+
## The register rule
|
|
98
|
+
|
|
99
|
+
Ship `spk-assembly/metadata/rules/ensure_aggregation_sweep_job_registered.json`:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"entityType": "entity_aggregation_config",
|
|
104
|
+
"name": "ensure_aggregation_sweep_job_registered",
|
|
105
|
+
"triggerEvent": "AFTER_CREATE",
|
|
106
|
+
"conditions": null,
|
|
107
|
+
"actions": "[{\"type\": \"EXECUTE_SERVICE\", \"service\": \"ensureEntityAggregationSweepJobRegistered\"}]",
|
|
108
|
+
"priority": 10,
|
|
109
|
+
"active": true
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Ground yourself first
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
erp schema pull entity-aggregation-config
|
|
117
|
+
erp examples patterns --kind jobs
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The `cross-row-aggregation-into-summary-entity` and `target-key-field-id-rollup`
|
|
121
|
+
patterns each point at a real shipped file.
|
|
122
|
+
|
|
123
|
+
## How to verify it worked
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
erp api get "/api/v1/jobs/engine-entity.aggregation-sweep"
|
|
127
|
+
# → { "status": "ENABLED", ... }
|
|
128
|
+
|
|
129
|
+
erp api post "/api/v1/jobs/engine-entity.aggregation-sweep/execute" --body "{}"
|
|
130
|
+
erp api get "/api/v1/entities/oeq_reconciliation/records/query?size=10"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{ "rows": [
|
|
135
|
+
{ "metric_key": "equipment_status:AVAILABLE", "metric_value": 8, "last_reconciled_at": "2026-09-10T..." },
|
|
136
|
+
{ "metric_key": "equipment_status:ASSIGNED", "metric_value": 4, "last_reconciled_at": "2026-09-10T..." }
|
|
137
|
+
], "total": 2 }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Common mistakes
|
|
141
|
+
|
|
142
|
+
| Symptom | Cause | Fix |
|
|
143
|
+
| --- | --- | --- |
|
|
144
|
+
| job not registered | no `AFTER_CREATE` register rule shipped | ship it |
|
|
145
|
+
| `sum`/`avg` writes null | `agg_field` missing | required for all aggregations except `count` |
|
|
146
|
+
| summary rows collide with another config's | no `target_key_prefix` | namespace each config's keys |
|
|
147
|
+
| rollup creates rows instead of updating | wanted the parent-row shape but set a prefix | use `target_key_field: "id"`, blank prefix |
|
|
148
|
+
|
|
149
|
+
## What to read next
|
|
150
|
+
|
|
151
|
+
- [Add a cadence-generator job](./add-a-cadence-job.md)
|
|
152
|
+
- [Add a compliance-sweep job](./add-a-compliance-job.md)
|
|
153
|
+
- Recipe: [cross-row aggregation into a summary entity](../recipes/cross-row-aggregation.md)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Add an approval workflow
|
|
3
|
+
audience: tenant
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Add an approval workflow
|
|
7
|
+
|
|
8
|
+
## What you're doing
|
|
9
|
+
|
|
10
|
+
Making a record require sign-off before it advances. When a user submits a
|
|
11
|
+
checkout, its status goes to `PENDING_APPROVAL`; a manager approves or rejects;
|
|
12
|
+
the platform flips the status to `APPROVED` or `REJECTED`. You write **no**
|
|
13
|
+
approval code — you declare three things:
|
|
14
|
+
|
|
15
|
+
1. a **workflow definition** — the stages, the approval task, who may decide
|
|
16
|
+
2. an **entity rule** — "when status enters `PENDING_APPROVAL`, start that
|
|
17
|
+
workflow"
|
|
18
|
+
3. nothing else — a generic callback controller writes the decision back
|
|
19
|
+
|
|
20
|
+
## The mental model
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
user sets status = PENDING_APPROVAL
|
|
24
|
+
│
|
|
25
|
+
▼
|
|
26
|
+
entity rule (AFTER_UPDATE) ──START_WORKFLOW──▶ workflow instance + a human task
|
|
27
|
+
│ │
|
|
28
|
+
│ manager approves / rejects
|
|
29
|
+
▼ │
|
|
30
|
+
generic workflow-callback controller ◀────────────────┘
|
|
31
|
+
│
|
|
32
|
+
▼
|
|
33
|
+
record.status = APPROVED (or REJECTED)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## The complete example
|
|
37
|
+
|
|
38
|
+
### 1. The workflow — `spk-assembly/metadata/workflow/office-equipment.checkout-approval.json`
|
|
39
|
+
|
|
40
|
+
[Real file](../tutorial/example-plugin/spk-assembly/metadata/workflow/office-equipment.checkout-approval.json):
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"name": "office-equipment.checkout-approval",
|
|
45
|
+
"description": "Single-stage Office Equipment Manager approval for a checkout submitted for approval.",
|
|
46
|
+
"stagesJson": "[\"manager\"]",
|
|
47
|
+
"tasksJson": "[{\"taskKey\":\"manager-approve\",\"stage\":\"manager\",\"taskType\":\"approval\",\"kind\":\"human\",\"payload\":{\"approvalObject\":\"office-equipment.checkout-approval\",\"amount\":0,\"dueInSeconds\":259200}}]",
|
|
48
|
+
"transitionsJson": "[]",
|
|
49
|
+
"approversJson": "{}",
|
|
50
|
+
"slasJson": "[]",
|
|
51
|
+
"escalationsJson": "[]",
|
|
52
|
+
"notificationsJson": "[]",
|
|
53
|
+
"metadataJson": "{}",
|
|
54
|
+
"approvalPermissions": [
|
|
55
|
+
{ "roleCode": "OFFICE_EQUIPMENT_MANAGER", "approvalObject": "office-equipment.checkout-approval", "maxAmount": null }
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### 2. The rule — `spk-assembly/metadata/rules/oeq_checkout_submit_workflow.json`
|
|
61
|
+
|
|
62
|
+
[Real file](../tutorial/example-plugin/spk-assembly/metadata/rules/oeq_checkout_submit_workflow.json):
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"entityType": "oeq_checkout",
|
|
67
|
+
"name": "oeq_checkout_submit_workflow",
|
|
68
|
+
"description": "On transition INTO PENDING_APPROVAL, start office-equipment.checkout-approval; the generic workflow callback controller flips status to APPROVED/REJECTED on the decision.",
|
|
69
|
+
"triggerEvent": "AFTER_UPDATE",
|
|
70
|
+
"conditions": "{\"all\":[{\"field\":\"status\",\"op\":\"eq\",\"value\":\"PENDING_APPROVAL\"},{\"field\":\"status__previous\",\"op\":\"neq\",\"value\":\"PENDING_APPROVAL\"}]}",
|
|
71
|
+
"actions": "[{\"type\":\"START_WORKFLOW\",\"workflowName\":\"office-equipment.checkout-approval\",\"callbackUrl\":\"http://localhost:8080/api/v1/entities/workflow-callback\",\"config\":{\"entityType\":\"oeq_checkout\",\"idField\":\"id\",\"fields\":[{\"name\":\"status\",\"approved\":\"APPROVED\",\"rejected\":\"REJECTED\"}]}}]",
|
|
72
|
+
"priority": 20,
|
|
73
|
+
"active": true
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 3. The Submit button on the page
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"blockType": "core.button",
|
|
82
|
+
"properties": { "labelKey": { "source": "static", "value": "office-equipment.equipment-checkout.detail.submitBtn" } },
|
|
83
|
+
"events": { "clicked": { "source": "action-chain", "actions": [
|
|
84
|
+
{ "id": "a0", "order": 0, "type": "callApi",
|
|
85
|
+
"config": { "connectionRef": "self", "path": "/api/v1/entities/oeq_checkout/records/${page.detailRecord.id}", "httpMethod": "PUT", "params": { "status": "PENDING_APPROVAL" } } },
|
|
86
|
+
{ "id": "a1", "order": 1, "type": "callApi",
|
|
87
|
+
"config": { "connectionRef": "self", "path": "/api/v1/entities/oeq_checkout/records/${page.detailRecord.id}", "httpMethod": "GET", "params": {} }, "output": "refreshed" },
|
|
88
|
+
{ "id": "a2", "order": 2, "type": "setValue", "config": { "field": "page.detailRecord", "value": "${refreshed}" } },
|
|
89
|
+
{ "id": "a3", "order": 3, "type": "showToast", "config": { "message": "Submitted for approval." } }
|
|
90
|
+
] } }
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Line by line
|
|
95
|
+
|
|
96
|
+
### The workflow
|
|
97
|
+
|
|
98
|
+
- **`name`** — must be `<pluginId>.<something>`, globally unique. The rule
|
|
99
|
+
references it by this exact string.
|
|
100
|
+
- **`stagesJson` / `tasksJson` / `transitionsJson`** — **each is a JSON-encoded
|
|
101
|
+
*string***, not a nested object. `stagesJson: "[\"manager\"]"` is the string
|
|
102
|
+
`["manager"]`. Same rule as an entity's `label`.
|
|
103
|
+
- **`stagesJson`** — ordered stage codes. One stage = `["manager"]`. A single
|
|
104
|
+
actor who both requests and decides = `["only"]`.
|
|
105
|
+
- **`tasksJson`** — one approval task per stage. `payload.approvalObject` is the
|
|
106
|
+
string that `approvalPermissions` and the platform's approval inbox key off.
|
|
107
|
+
`dueInSeconds: 259200` = 3 days.
|
|
108
|
+
- **`transitionsJson: "[]"`** — no transitions needed for a single stage;
|
|
109
|
+
reaching the last stage with no matching transition completes the instance.
|
|
110
|
+
For a linear two-stage flow: `"[{\"fromStage\":\"manager\",\"toStage\":\"hr\",\"condition\":{\"field\":\"decision\",\"op\":\"eq\",\"value\":\"approved\"}}]"`.
|
|
111
|
+
- **`approvalPermissions`** — a **real top-level array** (not a `*Json` string).
|
|
112
|
+
`roleCode` references a role by its stable `code` — never a display name. List
|
|
113
|
+
every role that may decide.
|
|
114
|
+
|
|
115
|
+
### The rule
|
|
116
|
+
|
|
117
|
+
- **`triggerEvent: "AFTER_UPDATE"`** — fires after any update to an
|
|
118
|
+
`oeq_checkout` row.
|
|
119
|
+
- **`conditions`** — a JSON string. `status__previous` is the value *before* the
|
|
120
|
+
update; the `all` clause means "status is now `PENDING_APPROVAL` **and** it
|
|
121
|
+
wasn't before" — so the workflow starts exactly once, on the transition.
|
|
122
|
+
- **`actions`** — a JSON string containing one `START_WORKFLOW`:
|
|
123
|
+
- `workflowName` — matches the workflow's `name`.
|
|
124
|
+
- `callbackUrl` — the generic entity workflow-callback endpoint. On this
|
|
125
|
+
environment its base is `http://localhost:8080`; on yours use your ERP's base
|
|
126
|
+
URL. (Platform teams are moving this to a relative path — check
|
|
127
|
+
`erp platform describe` if `START_WORKFLOW` config changes.)
|
|
128
|
+
- `config.fields` — "when the workflow approves, set `status` to `APPROVED`;
|
|
129
|
+
when it rejects, `REJECTED`." This is what makes the decision land back on
|
|
130
|
+
the row with zero code.
|
|
131
|
+
- **`priority`** — lower runs first when multiple rules match. `20` leaves room
|
|
132
|
+
for validation rules at `10`.
|
|
133
|
+
|
|
134
|
+
## Ground yourself first
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
erp schema pull workflow-definition
|
|
138
|
+
erp schema pull entity-rule-definition
|
|
139
|
+
erp workflow validate spk-assembly/metadata/workflow/office-equipment.checkout-approval.json
|
|
140
|
+
erp examples patterns --kind workflow
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`erp examples patterns --kind workflow` names three shapes:
|
|
144
|
+
`single-stage-self-decide`, `multi-stage-linear`,
|
|
145
|
+
`multi-stage-conditional-branching`, each pointing at a real shipped file.
|
|
146
|
+
|
|
147
|
+
## How to verify it worked
|
|
148
|
+
|
|
149
|
+
After publishing, the rule is installed:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
erp api get "/api/v1/entity-rules?entityType=oeq_checkout"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
[ { "id": 388, "entityType": "oeq_checkout", "name": "oeq_checkout_submit_workflow",
|
|
157
|
+
"triggerEvent": "AFTER_UPDATE", "active": true,
|
|
158
|
+
"actions": "[{\"type\": \"START_WORKFLOW\", ...}]" } ]
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Then submit a checkout and confirm the status transitions correctly:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
erp api put "/api/v1/entities/oeq_checkout/records/3" --body '{"status":"PENDING_APPROVAL"}'
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{ "id": 3, "checkout_number": "CO-200", "status": "PENDING_APPROVAL" }
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The record stays `PENDING_APPROVAL` — correct — until a manager decides the task,
|
|
172
|
+
at which point the callback flips it to `APPROVED` / `REJECTED`.
|
|
173
|
+
|
|
174
|
+
Confirm a real workflow instance was created and see its pending human task —
|
|
175
|
+
`engine-api` re-exposes the workflow engine's read API on its own base URL, so
|
|
176
|
+
this works even when the workflow engine runs as a separate service:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
erp workflow list --definition oeq_checkout_submit_workflow --record 3
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
```json
|
|
183
|
+
[ { "id": 5012, "definitionName": "oeq_checkout_submit_workflow",
|
|
184
|
+
"definitionVersion": 1, "status": "RUNNING", "currentStage": "manager-approval",
|
|
185
|
+
"correlationId": "3" } ]
|
|
186
|
+
|
|
187
|
+
1 instance(s) for record 3 of "oeq_checkout_submit_workflow".
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
erp workflow tasks 5012
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
[ { "id": 88, "stage": "manager-approval", "status": "PENDING",
|
|
196
|
+
"approvalObject": "oeq_checkout.approve", "candidateApprovers": ["MANAGER"] } ]
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
A manager then decides it from the ERP's approval inbox (or, for automation,
|
|
200
|
+
`POST /api/v1/workflow-bridge/human-tasks/{id}/decide`), and the callback lands
|
|
201
|
+
the decision back on the record.
|
|
202
|
+
|
|
203
|
+
> **Verified in the tutorial:** the rule installs, the submit transition
|
|
204
|
+
> persists without auto-resolving, and `erp workflow list/tasks` returns the
|
|
205
|
+
> real running instance and its pending task.
|
|
206
|
+
|
|
207
|
+
## Common mistakes
|
|
208
|
+
|
|
209
|
+
| Symptom | Cause | Fix |
|
|
210
|
+
| --- | --- | --- |
|
|
211
|
+
| workflow never starts | rule condition missing the `status__previous` guard | add it, or the rule re-fires on every later update |
|
|
212
|
+
| workflow starts on every save | same | as above |
|
|
213
|
+
| decision never lands back on the record | `config.fields` missing or wrong status values | `{ "name": "status", "approved": "APPROVED", "rejected": "REJECTED" }` |
|
|
214
|
+
| install rejects the workflow | `stagesJson`/`tasksJson` authored as objects | they are JSON *strings* |
|
|
215
|
+
| nobody can approve | `approvalPermissions` references a role display name | use `roleCode` (the stable `code`) |
|
|
216
|
+
| `!= null` never matches in a rule | action-engine quirk | use `is_not_null` / `is_null` operators |
|
|
217
|
+
|
|
218
|
+
## What to read next
|
|
219
|
+
|
|
220
|
+
- [Add business rules and expressions](./add-business-rules.md)
|
|
221
|
+
- Recipe: [entity with an approval workflow + reminder job](../recipes/entity-with-approval-workflow.md)
|
|
222
|
+
- Tutorial chapter 6: [the checkout approval workflow](../tutorial/06-checkout-approval-workflow.md)
|