@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.
Files changed (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/bundle/blocks.json +12229 -0
  4. package/bundle/catalog.json +32475 -0
  5. package/bundle/docs/applications/hcm/employee-profile.md +144 -0
  6. package/bundle/docs/concepts/artifact-types.md +80 -0
  7. package/bundle/docs/concepts/glossary.md +124 -0
  8. package/bundle/docs/concepts/overview.md +88 -0
  9. package/bundle/docs/concepts/page-data-flow.md +84 -0
  10. package/bundle/docs/concepts/plugin-lifecycle.md +81 -0
  11. package/bundle/docs/concepts/sdk-modes.md +71 -0
  12. package/bundle/docs/docs.json +109 -0
  13. package/bundle/docs/guides/add-a-cadence-job.md +134 -0
  14. package/bundle/docs/guides/add-a-compliance-job.md +139 -0
  15. package/bundle/docs/guides/add-a-create-edit-form.md +167 -0
  16. package/bundle/docs/guides/add-a-custom-block.md +225 -0
  17. package/bundle/docs/guides/add-a-data-provider.md +216 -0
  18. package/bundle/docs/guides/add-a-kpi.md +154 -0
  19. package/bundle/docs/guides/add-a-reminder-job.md +181 -0
  20. package/bundle/docs/guides/add-an-aggregation-job.md +153 -0
  21. package/bundle/docs/guides/add-an-approval-workflow.md +222 -0
  22. package/bundle/docs/guides/add-an-entity.md +170 -0
  23. package/bundle/docs/guides/add-business-rules.md +147 -0
  24. package/bundle/docs/guides/add-i18n.md +121 -0
  25. package/bundle/docs/guides/add-menus.md +149 -0
  26. package/bundle/docs/guides/build-a-code-plugin.md +141 -0
  27. package/bundle/docs/guides/build-a-page.md +213 -0
  28. package/bundle/docs/guides/build-a-polyglot-service-plugin.md +201 -0
  29. package/bundle/docs/guides/build-a-tenant-extension-service.md +149 -0
  30. package/bundle/docs/guides/create-a-plugin.md +172 -0
  31. package/bundle/docs/guides/expose-a-plugin-operation-as-an-ai-tool.md +114 -0
  32. package/bundle/docs/guides/index.md +53 -0
  33. package/bundle/docs/guides/publish-and-upgrade.md +152 -0
  34. package/bundle/docs/guides/responsive-plugin.md +117 -0
  35. package/bundle/docs/guides/seed-data-on-install.md +127 -0
  36. package/bundle/docs/guides/set-up-the-sdk.md +120 -0
  37. package/bundle/docs/guides/use-the-mcp-server.md +104 -0
  38. package/bundle/docs/guides/validate-and-test.md +119 -0
  39. package/bundle/docs/guides/wire-a-pages-data.md +153 -0
  40. package/bundle/docs/quickstart/index.md +118 -0
  41. package/bundle/docs/recipes/condition-based-compliance-flag.md +70 -0
  42. package/bundle/docs/recipes/cross-plugin-offboarding-fanout.md +68 -0
  43. package/bundle/docs/recipes/cross-row-aggregation.md +67 -0
  44. package/bundle/docs/recipes/entity-with-approval-workflow.md +72 -0
  45. package/bundle/docs/recipes/index.md +40 -0
  46. package/bundle/docs/recipes/install-lifecycle-data-seeding.md +76 -0
  47. package/bundle/docs/recipes/kpi-dashboard-page.md +92 -0
  48. package/bundle/docs/recipes/master-detail-page.md +84 -0
  49. package/bundle/docs/recipes/n-days-before-reminder.md +72 -0
  50. package/bundle/docs/recipes/per-row-document-generator.md +65 -0
  51. package/bundle/docs/recipes/related-record-lookup-field.md +74 -0
  52. package/bundle/docs/recipes/resolved-reference-grid-column.md +63 -0
  53. package/bundle/docs/recipes/scheduled-record-generation.md +68 -0
  54. package/bundle/docs/reference/action.md +18 -0
  55. package/bundle/docs/reference/block-definition.md +35 -0
  56. package/bundle/docs/reference/block-instance.md +33 -0
  57. package/bundle/docs/reference/composite-pack-manifest.md +33 -0
  58. package/bundle/docs/reference/connector-definition.md +36 -0
  59. package/bundle/docs/reference/data-service-definition.md +22 -0
  60. package/bundle/docs/reference/data-view-definition.md +26 -0
  61. package/bundle/docs/reference/entity-aggregation-config.md +38 -0
  62. package/bundle/docs/reference/entity-cadence-config.md +33 -0
  63. package/bundle/docs/reference/entity-compliance-config.md +37 -0
  64. package/bundle/docs/reference/entity-cross-plugin-action-config.md +37 -0
  65. package/bundle/docs/reference/entity-definition.md +18 -0
  66. package/bundle/docs/reference/entity-document-generator-config.md +35 -0
  67. package/bundle/docs/reference/entity-rule-definition.md +29 -0
  68. package/bundle/docs/reference/entity-status-date-sweep-config.md +27 -0
  69. package/bundle/docs/reference/form.md +35 -0
  70. package/bundle/docs/reference/index.md +37 -0
  71. package/bundle/docs/reference/menu.md +26 -0
  72. package/bundle/docs/reference/mobile-nav.md +22 -0
  73. package/bundle/docs/reference/page-template.md +29 -0
  74. package/bundle/docs/reference/page.md +44 -0
  75. package/bundle/docs/reference/plugin-manifest.md +54 -0
  76. package/bundle/docs/reference/plugin-seed-data.md +21 -0
  77. package/bundle/docs/reference/print-template-definition.md +22 -0
  78. package/bundle/docs/reference/provider-definition.md +22 -0
  79. package/bundle/docs/reference/report-definition.md +22 -0
  80. package/bundle/docs/reference/rule.md +30 -0
  81. package/bundle/docs/reference/workflow-definition.md +28 -0
  82. package/bundle/docs/troubleshooting/grid-shows-zero-rows.md +82 -0
  83. package/bundle/docs/troubleshooting/job-never-runs.md +89 -0
  84. package/bundle/docs/troubleshooting/known-gotchas.md +193 -0
  85. package/bundle/docs/troubleshooting/publishing-errors.md +119 -0
  86. package/bundle/docs/tutorial/01-create-the-plugin.md +157 -0
  87. package/bundle/docs/tutorial/02-category-entity-and-seed.md +94 -0
  88. package/bundle/docs/tutorial/03-equipment-entity.md +121 -0
  89. package/bundle/docs/tutorial/04-equipment-register-page.md +153 -0
  90. package/bundle/docs/tutorial/05-checkout-entity-and-page.md +116 -0
  91. package/bundle/docs/tutorial/06-checkout-approval-workflow.md +139 -0
  92. package/bundle/docs/tutorial/07-return-due-reminder-job.md +129 -0
  93. package/bundle/docs/tutorial/08-menus-i18n-publish.md +123 -0
  94. package/bundle/docs/tutorial/index.md +72 -0
  95. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-aggregation-configs.json +18 -0
  96. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cadence-configs.json +9 -0
  97. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-compliance-configs.json +74 -0
  98. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-cross-plugin-configs.json +9 -0
  99. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-document-configs.json +9 -0
  100. package/bundle/examples/backend/modules/hcm-assets/spk-assembly/metadata/seed-data/hcm-assets-sweep-configs.json +260 -0
  101. package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/entities/hcm_salary_planning_entry.json +41 -0
  102. package/bundle/examples/backend/modules/hcm-compensation/spk-assembly/metadata/page/salary-planning.json +1 -0
  103. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/page/promotions.json +10944 -0
  104. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.movement.json +44 -0
  105. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.promotion-acknowledge.json +19 -0
  106. package/bundle/examples/backend/modules/hcm-employee/spk-assembly/metadata/workflow/employee.resignation.json +29 -0
  107. package/bundle/examples/example-patterns.mjs +94 -0
  108. package/bundle/manifest.json +14 -0
  109. package/bundle/schemas/action.schema.json +102 -0
  110. package/bundle/schemas/block-definition.schema.json +222 -0
  111. package/bundle/schemas/block-instance.schema.json +139 -0
  112. package/bundle/schemas/composite-pack-manifest.schema.json +160 -0
  113. package/bundle/schemas/connector-definition.schema.json +29 -0
  114. package/bundle/schemas/data-service-definition.schema.json +64 -0
  115. package/bundle/schemas/data-view-definition.schema.json +148 -0
  116. package/bundle/schemas/entity-aggregation-config.schema.json +32 -0
  117. package/bundle/schemas/entity-cadence-config.schema.json +27 -0
  118. package/bundle/schemas/entity-compliance-config.schema.json +31 -0
  119. package/bundle/schemas/entity-cross-plugin-action-config.schema.json +31 -0
  120. package/bundle/schemas/entity-definition.schema.json +126 -0
  121. package/bundle/schemas/entity-document-generator-config.schema.json +29 -0
  122. package/bundle/schemas/entity-rule-definition.schema.json +75 -0
  123. package/bundle/schemas/entity-status-date-sweep-config.schema.json +21 -0
  124. package/bundle/schemas/form.schema.json +281 -0
  125. package/bundle/schemas/menu.schema.json +106 -0
  126. package/bundle/schemas/mobile-nav.schema.json +53 -0
  127. package/bundle/schemas/page-template.schema.json +46 -0
  128. package/bundle/schemas/page.schema.json +292 -0
  129. package/bundle/schemas/plugin-manifest.schema.json +139 -0
  130. package/bundle/schemas/plugin-seed-data.schema.json +24 -0
  131. package/bundle/schemas/print-template-definition.schema.json +90 -0
  132. package/bundle/schemas/provider-definition.schema.json +27 -0
  133. package/bundle/schemas/report-definition.schema.json +96 -0
  134. package/bundle/schemas/rule.schema.json +82 -0
  135. package/bundle/schemas/workflow-definition.schema.json +42 -0
  136. package/bundle/validators/block-engine.mjs +23222 -0
  137. package/bundle/validators/page-engine.mjs +21645 -0
  138. package/erp-cli/authoring-root.mjs +344 -0
  139. package/erp-cli/erp.mjs +4065 -0
  140. package/erp-cli/example-patterns.mjs +94 -0
  141. package/erp-mcp-server/index.mjs +818 -0
  142. 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)