diff --git a/components/engine/engine-camel/src/generated/resources/META-INF/org/eclipse/dirigible/components/engine/camel/components/dirigible-java-script.json b/components/engine/engine-camel/src/generated/resources/META-INF/org/eclipse/dirigible/components/engine/camel/components/dirigible-java-script.json index de407a13194..f9a8da5dc24 100644 --- a/components/engine/engine-camel/src/generated/resources/META-INF/org/eclipse/dirigible/components/engine/camel/components/dirigible-java-script.json +++ b/components/engine/engine-camel/src/generated/resources/META-INF/org/eclipse/dirigible/components/engine/camel/components/dirigible-java-script.json @@ -11,7 +11,7 @@ "supportLevel": "Stable", "groupId": "org.eclipse.dirigible", "artifactId": "dirigible-components-engine-camel", - "version": "14.0.0-SNAPSHOT", + "version": "15.0.0-SNAPSHOT", "scheme": "dirigible-java-script", "extendsScheme": "", "syntax": "dirigible-java-script:javaScriptPath", diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index 9bbf877d399..c81bc8e33fe 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -279,7 +279,7 @@ Semantics worth knowing: - **`init: ` on a to-one relation = the FK's DB-level default (`RelationIntent.init` → `dataDefaultValue` on the FK property, in both `relationProperty` and `crossModelRelationProperty`).** The relation analogue of a field's `defaultValue`; a new row gets this FK on insert when the column is left unset (e.g. `Status` defaults to the DRAFT seed, `PaymentMethod` to Bank, `SentMethod` to E-mail). **Use `init` for an INITIAL status, never a process step.** A DB default is applied at insert — atomic, no ordering to reason about — while a start-step setter is extra moving parts for the same effect. (Historical note: a start-step `setRelationField` also used to be *clobbered* by the trigger's `ProcessId` write-back, which was a full-row `updateWithoutEvent` merge of a stale snapshot — confirmed live: the invoice reached the Approve task but `Status` stayed null. The trigger now persists `ProcessId` — and a minted business key — via the targeted single-column `repository.updateProperty(...)` (SDK `JavaRepository`/`JavaEntityStore`), so that race is gone; `init:` remains the right modeling for an initial status.) `setRelationField` is for *transitions* (after a user task / on a decision branch). - **`trigger: { onCreate|onUpdate|onDelete: , when: "" }` starts the process on the named `` lifecycle event** - fully wired (Java). Any of the three events is supported: `onCreate` binds the entity's base topic, `onUpdate`/`onDelete` the `-updated`/`-deleted` topics the Java DAO publishes (`TriggerSupport` + `EventBinding`); an optional `when` guard (a single `field ==|!= literal`, via `NotificationSupport.guard`) gates `Process.start`. Three parts: (1) the parser validates at most one event kind and that the target is a declared entity; (2) the EDM generator adds a `ProcessId` back-reference property (VARCHAR) to that entity and a `triggers` collection to the `.model` (`TriggerSupport` + `EdmIntentGenerator.buildTriggers`); (3) the **`template-application-events-java`** template (intent-driven, like the other language templates) reads that `triggers` collection and emits one **`gen/events/Trigger.java`** per trigger - a client-Java self-describing `MessageHandler` (a `@Component` whose `destination()` is the entity's per-operation topic via `topicSuffix` and whose `kind()` is `TOPIC`) that loads the entity, applies the `when` guard, calls `Process.start(, businessKey, )`, and writes the instance id back to `ProcessId` (so it starts at most once). The Java DAO template (`template-application-dao-java`) now publishes the create event (`Producer.sendToTopic('${projectName}-${perspectiveName}-${name}', json)`) the way the TS DAO does - that's the topic the handler binds to. `gen/events` is a sibling of `gen/`, so it survives the per-model regeneration wipe. The events template iterates the model's `triggers` via a new **`triggers` collection case in `service-generate/template/generateUtils.js`** (the engine's collection switch is hardcoded; the case has its own loop because triggers are not entity-shaped). The BPM **business key** defaults to the entity's primary key but is **configurable**: `trigger: { ..., businessKey: }` names which trigger-entity field becomes the started instance's business key (the listener still loads the entity by its PK via `findById`; only the business key differs — a separate `businessKeyProperty` in `.glue`). An optional `businessKeyStrategy: timestamp` mints a `yyyyMMddHHmmss` value into that field when it is blank and persists it via the listener's existing update — the simple "for now" generator and the **extension point** for richer pluggable number generators later (sequential, zero-padded, config-prefixed invoice numbers); the parser validates the field exists, the strategy is supported, and (for `timestamp`) the field is `string`/`text`. `TriggerSupport.triggerBusinessKey`/`triggerBusinessKeyStrategy` read them; `GlueIntentGenerator` emits `businessKeyProperty` + `generateBusinessKey`; `Trigger.java.template` renders the mint-if-blank block. `onSchedule` is still unmodelled. **Casing subtlety in the generated handler:** its `import gen..data..{Entity,Repository}` must use the **lowercased** Java package segment (`javaPerspective` = `sanitizeJavaIdentifier(perspective)`, matching the DAO/entity templates' `javaPerspectiveName` folder), while the `destination()` topic (`"--"`) keeps the **raw** perspective so it matches the topic the DAO publishes to (`${projectName}-${perspectiveName}-${name}`). The `triggers` collection case in `generateUtils.js` supplies both (`javaPerspective` for the import, `perspective` for the topic). Using the raw perspective in the import compiled on macOS (case-insensitive FS) but failed `javac` with "package gen.x.data.Member does not exist" because the entity files declare the lowercased package. - **`dependsOn` on a to-one relation or a field = the EDM Depends-On feature (cascading dropdowns + auto-populated fields).** `dependsOn: { relation: , valueFrom?: , filterBy?: }` — the widget reacts to the sibling trigger: the generated form loads the trigger's selected record, reads `valueFrom` (default: the trigger target's PK), then a **relation** re-filters its dropdown options where its own target's `filterBy` (default: that target's PK) equals the value (`POST /search` with an EQ condition; a single remaining option auto-selects), while a **field** copies the value (auto-population; `valueFrom` mandatory, `filterBy` rejected). Emitted by `EdmIntentGenerator.putDependsOn` as the four scalar `widgetDependsOn*` property attributes the AngularJS stacks already consume (so those work for free); the Harmonia runtime was added in the same pass (`form-page.js.template` per-property watcher + `applyDependsOn` methods covering manage/master-detail/allocation forms; `document-page.js.template` header watchers + a generic metadata-driven `applyDraftDependsOn` for the line-item dialog off `detail-register.js.template`'s `editColumns[].dependsOn`, with filtered options in a separate `draftOptions` store so the items table's label resolution keeps the full set; `parameterUtils.js` precomputes `widgetDependsOnControllerUrl` from the trigger sibling). `valueFrom`/`filterBy` use the target's **authored** property names (field lower-camel / relation as declared); same-model references are parse-validated, cross-model ones generation-validated against the resolved owner model (`CrossModelSupport.TargetInfo.propertyNames`). A `documentStatus` relation can neither declare nor trigger a dependsOn. Canonical cases (the `codbex-sample-model-depends-on` set): Country→City cascade (`filterBy` only), Product→UoM narrow-to-referenced (`valueFrom` only), Product→price auto-populate (field). -- **`postings:` (top-level) = declarative posting (source-document status → generated local document + computed items).** The accounting "documents → ledger" capability, generalized (spike-derived; see the KeyFolders catalog's spike findings). `PostingIntent` + parser `validatePostings` (creates = local document owning a composition items child; backReference = its to-one to the source, the at-most-once guard; event `when: " == "`; item cells = `rule()` refs into a single-selector rule entity or Calc arithmetic over the source; row `when: ==|!= `). `GlueIntentGenerator.buildPostings` pre-renders EVERYTHING as Java expressions (the expansions convention — the template stays shape-only): topic + re-load coordinates via `CrossModelSupport`, guard, header assignments (copy / literal / `{placeholder}` concat), `ruleRow.` refs, `Calc.eval("", source, )` amounts with the scale from the LOCAL item field, null-safe Calc row guards. `postings` glue collection → `generateUtils.js` case (source gen folder = sanitized model alias, topic keeps the RAW perspective) → `Posting.java.template`: a `MessageHandler` on `---transitioned` (#6220's channel) that re-loads the source by id (the payload lacks later-step data — the stamped number), guards, resolves the rule row (missing row / null referenced column → SKIP, the unposted worklist), and writes target + items through the repositories — so numbering / status `init:` / `checks:` fire on the created document. **Idempotent + resumable, not transactional** (the cloud-native consistency model — there is NO cross-step DB rollback; each write commits on its own): the back-reference identifies an existing post, so a redelivery of a COMPLETE post (item count ≥ the derived `expectedItems`) is a no-op, and a redelivery of a HALF-post (an item write failed after the target was saved) clears the partial items and rebuilds the full set — it never throws or half-posts. Concurrent-redelivery de-duplication is best-effort (a check-then-act on the back-reference) until a real UNIQUE key on the back-reference lands with schema constraint emission. Storno/negation mode and the explicit Reverse action — the **compensation** that undoes a genuinely-failed or reversed post — are the deliberate follow-up (needs the void-document event); compensation, not a transaction, is how a bad post is unwound. +- **`postings:` (top-level) = declarative posting (source-document status → generated local document + computed items).** The accounting "documents → ledger" capability, generalized (spike-derived; see the KeyFolders catalog's spike findings). `PostingIntent` + parser `validatePostings` (creates = local document owning a composition items child; backReference = its to-one to the source, the at-most-once guard; event `when: " == "`; item cells = `rule()` refs into a single-selector rule entity or Calc arithmetic over the source; row `when: ==|!= `). `GlueIntentGenerator.buildPostings` pre-renders EVERYTHING as Java expressions (the expansions convention — the template stays shape-only): topic + re-load coordinates via `CrossModelSupport`, guard, header assignments (copy / literal / `{placeholder}` concat), `ruleRow.` refs, `Calc.eval("", source, )` amounts with the scale from the LOCAL item field, null-safe Calc row guards. `postings` glue collection → `generateUtils.js` case (source gen folder = sanitized model alias, topic keeps the RAW perspective) → `Posting.java.template`: a `MessageHandler` on `---transitioned` (#6220's channel) that re-loads the source by id (the payload lacks later-step data — the stamped number), guards, resolves the rule row (missing row / null referenced column → SKIP, the unposted worklist), and writes target + items through the repositories — so numbering / status `init:` / `checks:` fire on the created document. **Idempotent + resumable, not transactional** (the cloud-native consistency model — there is NO cross-step DB rollback; each write commits on its own): the back-reference identifies an existing post, so a redelivery of a COMPLETE post (item count ≥ the derived `expectedItems`) is a no-op, and a redelivery of a HALF-post (an item write failed after the target was saved) clears the partial items and rebuilds the full set — it never throws or half-posts. Concurrent-redelivery de-duplication is best-effort (a check-then-act on the back-reference) until a real UNIQUE key on the back-reference lands with schema constraint emission. Storno/negation mode LANDED as **`reverses:`** (paired with the `transitions:` void primitive - the "void-document event" is a transition into the void status): a reversal posting inherits creates/backReference/rule/map/items from the reversed sibling, negates every item amount expression on the SAME side (`Calc.eval("-()", ...)` - red storno), locates the original through the empty `storno:` self-link (none -> fail-soft skip), stamps the link on its creation, and both handlers' idempotency guards discriminate by that link (reversal counts linked rows, the sibling counts unlinked ones - `stornoProperty`/`stornoFilterProperty` in the glue). The explicit manual Reverse action (no source void) remains a follow-up. Compensation, not a transaction, is how a bad post is unwound. - **`immutableWhen:` / `immutable:` on an entity = user-write immutability.** `immutableWhen: "Status == 2"` (a boolean expression over EntityStatus seed ids, terms joined with `||`) makes update/delete through the generated REST controller answer 409 CONFLICT while the record's `function: EntityStatus` FK satisfies it; `immutable: true` is the unconditional append-only variant (mutually exclusive with `immutableWhen`; a non-existent id still yields 404, not 409). Emitted as the entity-level `immutableStatusProperty` + `immutableStatusValues` (or `immutableAlways`) model attrs; `requireMutable` fetches the existing row before writing. Repository writes are deliberately unaffected — the workflow (storno generation, roll-ups, ProcessId write-back) keeps working; this guards the USER surface, per the accounting audit-trail requirement (corrections are reversals, never edits). Parser requires an EntityStatus relation. Alongside it (no DSL): every generated controller now maps a **database constraint violation on DELETE to 409** ("referenced by other records") instead of a 500. Caveat discovered while verifying: the generated schema currently emits **no FK constraints at all** (`constraints: []` on every table — same-model included), so this mapping only engages once constraint emission lands; whether to emit them (a suite-wide data-integrity semantics change: deploy order, CSVIM import order, existing tables unaffected by ALTER) is a separate decision, raised with the accounting findings. Date-based period locking (records whose date falls in a Locked period) is deliberately NOT part of this — its shape needs the real fiscal-period module and follows as its own PR. - **`checks:` on an entity = declarative cross-field / cross-line validations (the double-entry shape).** Three kinds (`CheckIntent`): row-level `exactlyOne` (`fields:` — exactly one non-null; emitted PascalCased into the `.model` `checks` list and enforced in the generated REST `validate()` with 400) and document-level `itemsSumEqual` (`over:` two item fields whose sums must match) / `itemsMin` (`count:`), both REQUIRING a `status:` gate (an EntityStatus seed id) — parser-enforced, because an ungated sum check would forbid drafting a document item by item. The EDM generator precomputes everything template-side (`buildChecks`: items entity + back-FK via the composition child, `statusProperty`, PascalCased fields); `parameterUtils` splits `rowChecks`/`documentChecks`; the **DAO repository** enforces document checks in `save`/`update`/**`updateWithoutEvent`** whenever the persisted entity carries the gate status — so the workflow setter flipping DRAFT→POSTED hits `enforceChecks` and an unbalanced document FAILS the write instead of silently posting: it throws the SDK `org.eclipse.dirigible.sdk.db.ValidationException`, which the client-controller dispatcher (`ControllerInvoker`) maps to **HTTP 400** with the authored message on a REST create/update, and which rolls back the task completion on the BPMN path (the capacity guard on roll-ups throws the same). `recalculate()` deliberately bypasses it (goes straight to `super.update`). No Harmonia-side mirror in v1 — the task-completion error surfaces the authored message. - **`where` on a user-picked to-one relation = a static dropdown option filter.** `where: { : }` — a single constant condition that permanently narrows the relation's option list to matching target rows (the canonical case: a stock line's Product picker showing only `Type: 1` real products, never services). The `dependsOn` sibling for conditions that do not react to anything. Parser (`validateWhere`): to-one only, never on a composition parent (preset by the layout) or an `EntityStatus`, exactly one pair, scalar literal, same-model property checked immediately (cross-model at generation, like the relation target). Emitted by `EdmIntentGenerator.putOptionsFilter` as two scalar attrs `widgetOptionsFilterBy` (PascalCased) / `widgetOptionsFilterValue` (a YAML integer renders without the Gson `.0` — `stripTrailingZero`); `parameterUtils.js` pre-renders `widgetOptionsFilterValueJs` (numeric stays bare, else quoted+escaped) so the templates emit it verbatim. Harmonia consumption: the **chooser** dropdowns load via `POST /search` with the EQ condition (`form-page` + `document-page` header `loadOptions`), the item dialog keeps a third `filteredOptions` store (`dialogOptionsFor` prefers `draftOptions || filteredOptions || itemOptions`; metadata via `detail-register`'s `#optionsFilterMeta` → `col.filter`), and a combined `dependsOn` + `where` search sends BOTH conditions — while **label-resolution** lookups (list/master/table columns, `itemOptions`) deliberately keep the full set so historical rows referencing now-filtered-out targets still resolve. AngularJS stacks ignore the attrs (Harmonia-only for now — noted in the PR). diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java index 1a7ad6d3ec0..06796b40078 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java @@ -702,17 +702,45 @@ static List> buildGeneratesForTest(IntentModel model) { private static List> buildPostings(IntentModel model, Map byName, Map compositionParents, IntentSettings settings, IntentGenerationContext context) { List> out = new ArrayList<>(); + // A reversal posting's storno link doubles as the discriminator between the reversed + // sibling's own documents (link empty) and reversals (link set) - the SIBLING's handler + // must filter its idempotency lookup by it too, so map: base posting name -> storno. + Map stornoOfReversed = new LinkedHashMap<>(); for (org.eclipse.dirigible.components.intent.model.PostingIntent posting : model.getPostings()) { + if (posting.getReverses() != null && !posting.getReverses() + .isBlank() + && posting.getStorno() != null) { + stornoOfReversed.put(posting.getReverses(), IntentNaming.pascalCase(posting.getStorno())); + } + } + for (org.eclipse.dirigible.components.intent.model.PostingIntent posting : model.getPostings()) { + boolean isReverse = posting.getReverses() != null && !posting.getReverses() + .isBlank(); + // Reversal mode: creates/backReference/rule/map/items come from the reversed sibling; + // the reversal contributes its own event plus the storno link, and every item amount + // expression is negated (same sides - red storno). + org.eclipse.dirigible.components.intent.model.PostingIntent effective = posting; + if (isReverse) { + for (org.eclipse.dirigible.components.intent.model.PostingIntent candidate : model.getPostings()) { + if (candidate != posting && posting.getReverses() + .equals(candidate.getName())) { + effective = candidate; + } + } + if (effective == posting || posting.getStorno() == null) { + continue; // parser already reported it + } + } if (posting.getName() == null || posting.getName() .isBlank() - || posting.getEvent() == null || posting.getCreates() == null) { + || posting.getEvent() == null || effective.getCreates() == null) { continue; // parser already reported it } if (!settings.shouldGenerate("postings", posting.getName())) { LOGGER.info("Settings opt-out: keeping existing handler for posting [{}] (not generated)", posting.getName()); continue; } - EntityIntent creates = byName.get(posting.getCreates()); + EntityIntent creates = byName.get(effective.getCreates()); EntityIntent itemsEntity = creates == null ? null : compositionChild(creates, byName); if (creates == null || itemsEntity == null) { continue; // parser already reported it @@ -769,22 +797,27 @@ private static List> buildPostings(IntentModel model, Map usedRuleColumns = new java.util.LinkedHashSet<>(); if (hasRule) { - String ruleEntityName = String.valueOf(posting.getRule() - .get("entity")); + String ruleEntityName = String.valueOf(effective.getRule() + .get("entity")); e.put("ruleEntity", ruleEntityName); // A setting rule entity (the normal case) lives under the global Settings perspective. EntityIntent ruleEntityIntent = byName.get(ruleEntityName); e.put("rulePerspective", ruleEntityIntent != null && ruleEntityIntent.isSetting() ? "Settings" : IntentEntities.resolvePerspective(ruleEntityName, compositionParents)); - Map match = (Map) posting.getRule() - .get("match"); + Map match = (Map) effective.getRule() + .get("match"); Map.Entry selector = match.entrySet() .iterator() .next(); @@ -793,16 +826,16 @@ private static List> buildPostings(IntentModel model, Map> headerAssignments = new ArrayList<>(); - if (posting.getMap() != null) { - for (Map.Entry entry : posting.getMap() - .entrySet()) { + if (effective.getMap() != null) { + for (Map.Entry entry : effective.getMap() + .entrySet()) { headerAssignments.add(postingAssignment(entry.getKey(), entry.getValue())); } } e.put("headerAssignments", headerAssignments); // Item rows: rule(...) refs read the rule row; expressions run through Calc on the source. List> itemRows = new ArrayList<>(); - for (Map row : posting.getItems() == null ? List.>of() : posting.getItems()) { + for (Map row : effective.getItems() == null ? List.>of() : effective.getItems()) { Map rendered = new LinkedHashMap<>(); List> assigns = new ArrayList<>(); String rowGuard = ""; @@ -832,7 +865,9 @@ private static List> buildPostings(IntentModel model, Map the status FK + // id column) - previously it passed through untranslated and broke the generated SQL. + // The negative lookahead skips join-alias usages (`Customer."CUSTOMER_NAME"` from the + // dotted-ref pass above); the lookbehind skips already-qualified column tokens. + if (source.getRelations() != null) { + for (RelationIntent relation : source.getRelations()) { + if (relation.getName() != null && !relation.getName() + .isBlank()) { + where = where.replaceAll("(?=`/`!=` are untouched. // Normalize only OUTSIDE single-quoted string literals so a value literal that itself contains diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/PostingIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/PostingIntent.java index 0e5642ad8ab..3f9f6753228 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/PostingIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/PostingIntent.java @@ -70,6 +70,37 @@ public class PostingIntent { * {@code == 0} on a source field). */ private List> items; + /** + * Reversal (red storno) mode: names a SIBLING posting in this block whose created document this + * posting reverses. The reversal re-derives the sibling's header and items from the source with + * every amount expression NEGATED on the SAME side (never swapped - turnovers stay honest), links + * {@link #storno} to the original, and skips fail-soft when no original exists (the source was + * never posted). {@code creates}/{@code backReference}/{@code rule}/{@code map}/ {@code items} are + * inherited from the sibling and must not be declared here. + */ + private String reverses; + /** + * The created entity's to-one SELF-relation linked to the reversed (original) document - required + * with {@link #reverses}. Doubles as the discriminator between the sibling's own documents (link + * empty) and reversals (link set) for both handlers' idempotency guards. + */ + private String storno; + + public String getReverses() { + return reverses; + } + + public void setReverses(String reverses) { + this.reverses = reverses; + } + + public String getStorno() { + return storno; + } + + public void setStorno(String storno) { + this.storno = storno; + } public String getName() { return name; diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java index 9620e86ceb7..4524652e5da 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java @@ -2280,6 +2280,47 @@ private static void validatePostings(IntentModel model, Set usesAliases, issues.add(subject + " event requires `when: \" == \"`"); } } + // Reversal mode: creates/backReference/rule/map/items are inherited from the reversed + // sibling; the reversal declares only its own event + the storno self-link. + if (posting.getReverses() != null && !posting.getReverses() + .isBlank()) { + PostingIntent sibling = null; + for (PostingIntent candidate : model.getPostings()) { + if (candidate != posting && posting.getReverses() + .equals(candidate.getName())) { + sibling = candidate; + } + } + if (sibling == null) { + issues.add(subject + " reverses unknown posting [" + posting.getReverses() + "] - it must name a sibling" + + " posting in this block"); + continue; + } + if (posting.getCreates() != null || posting.getBackReference() != null || posting.getRule() != null + || posting.getMap() != null || (posting.getItems() != null && !posting.getItems() + .isEmpty())) { + issues.add(subject + " is a reversal - creates/backReference/rule/map/items are inherited from [" + + posting.getReverses() + "] and must not be declared"); + } + EntityIntent reversed = sibling.getCreates() == null ? null : byName.get(sibling.getCreates()); + if (posting.getStorno() == null || posting.getStorno() + .isBlank()) { + issues.add(subject + " requires `storno: ` - the created entity's link to the reversed document"); + } else if (reversed != null) { + RelationIntent storno = toOneRelationByName(reversed, posting.getStorno()); + if (storno == null || !reversed.getName() + .equals(storno.getTo()) + || storno.isCrossModel()) { + issues.add(subject + " storno [" + posting.getStorno() + "] must be a to-one SELF-relation of [" + + reversed.getName() + "]"); + } + } + continue; + } + if (posting.getStorno() != null && !posting.getStorno() + .isBlank()) { + issues.add(subject + " declares storno without reverses - the storno link belongs to the reversal posting"); + } // creates + items child + backReference EntityIntent creates = posting.getCreates() == null ? null : byName.get(posting.getCreates()); if (creates == null) { diff --git a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md index 90d7bc34b10..35179b4c443 100644 --- a/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md +++ b/components/engine/engine-intent/src/main/resources/intent-assistant-guide.md @@ -209,6 +209,21 @@ composition is opt-in. A missing rule row or null referenced column SKIPS the posting (the unposted worklist = final-status documents with no back-referencing target), never throws. All writes go through the generated repositories, so numbering/status-init/`checks:` fire on the created document. + **Reversal mode (red storno):** a posting with `reverses: ` undoes the + sibling's document when the source is voided/cancelled - pair it with a `transitions:` void: + ```yaml + - name: invoiceStorno + event: { onTransition: SalesInvoice, model: kf-billing, when: "Status == 8" } # the void status + reverses: salesInvoicePosting # sibling posting in this block + storno: Storno # the created entity's to-one SELF-relation to the original + ``` + `creates`/`backReference`/`rule`/`map`/`items` are inherited from the sibling and must not be + declared. Semantics: locate the ORIGINAL (back-reference = this source, storno link empty) - none + -> skip fail-soft; create the negated copy (every item amount expression negated on the SAME + debit/credit side - never swapped) with the `storno` link stamped; idempotent (rows carrying the + link are the reversal's own; the sibling's guard symmetrically counts only rows without it). The + reversal lands as a normal new document (DRAFT status init, numbering, checks), dated by its own + `map`-inherited header - corrections post into the open period. - `calculatedOnCreate` / `calculatedOnUpdate` - an expression the generated repository assigns to the property on insert / update. Prefer a **neutral arithmetic expression** for numeric totals (`"Quantity * Price"`, `"round(Net * 0.2, 2)"`) - the SDK `Calc` evaluator runs it on the server and diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostingsReversesTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostingsReversesTest.java new file mode 100644 index 00000000000..e36d3702a2b --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GluePostingsReversesTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.generator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; + +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * The reversal (red storno) posting glue: the reversal inherits the reversed sibling's + * creates/backReference/rule/map/items, negates every item amount expression on the SAME side, and + * both entries carry the storno coordinates their handlers' idempotency guards discriminate by. + */ +class GluePostingsReversesTest { + + private static final String YAML = """ + name: ledger + uses: + - { model: acme-billing } + entities: + - name: Account + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string } + - name: PostingRule + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: documentType, type: string } + relations: + - { name: ReceivableAccount, kind: manyToOne, to: Account } + - { name: RevenueAccount, kind: manyToOne, to: Account } + - name: JournalEntry + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: entryDate, type: date } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, model: acme-billing } + - { name: Storno, kind: manyToOne, to: JournalEntry } + - name: JournalEntryItem + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: debit, type: decimal, precision: 18, scale: 2 } + - { name: credit, type: decimal, precision: 18, scale: 2 } + relations: + - { name: JournalEntry, kind: manyToOne, to: JournalEntry, composition: true, required: true } + - { name: Account, kind: manyToOne, to: Account, required: true } + postings: + - name: invoicePosting + event: { onTransition: Invoice, model: acme-billing, when: "Status == 3" } + creates: JournalEntry + backReference: Invoice + map: { entryDate: date } + rule: { entity: PostingRule, match: { documentType: "Invoice" } } + items: + - { Account: rule(receivableAccount), debit: "Net + Vat" } + - { Account: rule(revenueAccount), credit: "Net + Vat" } + - name: invoiceStorno + event: { onTransition: Invoice, model: acme-billing, when: "Status == 8" } + reverses: invoicePosting + storno: Storno + """; + + @SuppressWarnings("unchecked") + @Test + void reversalInheritsTheSiblingAndNegatesTheAmounts() { + List> postings = GlueIntentGenerator.buildPostingsForTest(IntentParser.parse(YAML)); + assertEquals(2, postings.size()); + + Map base = postings.get(0); + assertEquals("InvoicePosting", base.get("className")); + // The reversed sibling's guard must exclude reversal rows (they share its back-reference). + assertEquals("", base.get("stornoProperty")); + assertEquals("Storno", base.get("stornoFilterProperty")); + + Map storno = postings.get(1); + assertEquals("InvoiceStorno", storno.get("className")); + assertEquals("8", storno.get("guardValue")); + // Inherited coordinates. + assertEquals("JournalEntry", storno.get("targetEntity")); + assertEquals("JournalEntryItem", storno.get("itemsEntity")); + assertEquals("Invoice", storno.get("backRefProperty")); + assertEquals("\"Invoice\"", storno.get("ruleMatchValueJava")); + assertEquals("Storno", storno.get("stornoProperty")); + assertEquals("", storno.get("stornoFilterProperty")); + + // The header is the sibling's map; the amounts are the sibling's expressions NEGATED on the + // SAME side (red storno - never swapped sides). + List> header = (List>) storno.get("headerAssignments"); + assertTrue(header.stream() + .anyMatch(a -> "EntryDate".equals(a.get("targetProp")) && "source.Date".equals(a.get("expr")))); + List> rows = (List>) storno.get("itemRows"); + assertEquals(2, rows.size()); + List> firstAssigns = (List>) rows.get(0) + .get("assigns"); + assertTrue(firstAssigns.stream() + .anyMatch(a -> "Account".equals(a.get("targetProp")) && "ruleRow.ReceivableAccount".equals(a.get("expr")))); + assertTrue(firstAssigns.stream() + .anyMatch(a -> "Debit".equals(a.get("targetProp")) + && "Calc.eval(\"-(Net + Vat)\", source, 2)".equals(a.get("expr")))); + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java index aafda3f5a1b..a76f6bb0352 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/report/ReportIntentGeneratorTest.java @@ -192,6 +192,49 @@ void listWidgetCarriesTheLimitAndLiteralPinsKeepTheirValue() { .get("token")); } + private static final String STATUS_FILTER_INTENT = """ + name: billing + entities: + - name: InvoiceStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Invoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string } + - { name: due, type: date } + - { name: balance, type: decimal } + relations: + - { name: Status, kind: manyToOne, to: InvoiceStatus, function: EntityStatus, init: 1 } + - { name: Customer, kind: manyToOne, to: Customer } + reports: + - name: OverdueInvoices + source: Invoice + dimensions: [number, due, Customer.name] + filter: "due <= CURRENT_DATE AND Customer.name != 'X' AND Status != 8" + """; + + @Test + void filterTranslatesABareToOneRelationToItsFkColumn() { + IntentModel model = IntentParser.parse(STATUS_FILTER_INTENT); + Map document = ReportIntentGenerator.buildForTest(TestContexts.context(model), model.getReports() + .get(0)); + String query = (String) document.get("query"); + // A bare to-one relation name filters by its FK column - previously it passed through + // untranslated (`AND Status != 8`) and broke the generated SQL. + assertTrue(query.contains("Invoice.\"INVOICE_STATUS\" != 8"), query); + // The dotted ref keeps its join-alias form - the bare-relation pass must not mangle the + // alias token it produced. + assertTrue(query.contains("Customer.\"CUSTOMER_NAME\" != 'X'"), query); + assertTrue(!query.contains(" Status "), query); + } + private static final String LEDGER_INTENT = """ name: ledger entities: diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/PostingsReversesIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/PostingsReversesIntentTest.java new file mode 100644 index 00000000000..2256c736629 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/PostingsReversesIntentTest.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2010-2026 Eclipse Dirigible contributors + * + * All rights reserved. This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v20.html + * + * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0 + */ +package org.eclipse.dirigible.components.intent.parser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.junit.jupiter.api.Test; + +/** Parse + validation coverage for the postings {@code reverses:} (red storno) mode. */ +class PostingsReversesIntentTest { + + private static final String VALID = """ + name: ledger + entities: + - name: Doc + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: amount, type: decimal } + relations: + - { name: Status, kind: manyToOne, to: DocStatus, function: EntityStatus, init: 1 } + - name: DocStatus + kind: setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: Entry + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date } + relations: + - { name: Doc, kind: manyToOne, to: Doc } + - { name: Storno, kind: manyToOne, to: Entry } + - name: EntryLine + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: debit, type: decimal } + - { name: credit, type: decimal } + relations: + - { name: Entry, kind: manyToOne, to: Entry, composition: true, required: true } + postings: + - name: docPosting + event: { onTransition: Doc, when: "Status == 2" } + creates: Entry + backReference: Doc + map: { date: date } + items: + - { debit: "Amount" } + - { credit: "Amount" } + - name: docStorno + event: { onTransition: Doc, when: "Status == 3" } + reverses: docPosting + storno: Storno + """; + + @Test + void parsesAValidReversal() { + IntentModel model = IntentParser.parse(VALID); + assertEquals(2, model.getPostings() + .size()); + assertEquals("docPosting", model.getPostings() + .get(1) + .getReverses()); + assertEquals("Storno", model.getPostings() + .get(1) + .getStorno()); + } + + @Test + void rejectsAnUnknownSibling() { + String yaml = VALID.replace("reverses: docPosting", "reverses: missingPosting"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("reverses unknown posting [missingPosting]")), + "got: " + ex.getIssues()); + } + + @Test + void rejectsInheritedKeysOnAReversal() { + String yaml = VALID.replace("storno: Storno", "storno: Storno\n creates: Entry"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("creates/backReference/rule/map/items are inherited")), + "got: " + ex.getIssues()); + } + + @Test + void rejectsAMissingOrNonSelfStorno() { + String missing = VALID.replace("\n storno: Storno", ""); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(missing)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("requires `storno: `")), + "got: " + ex.getIssues()); + + String nonSelf = VALID.replace("storno: Storno", "storno: Doc"); + IntentValidationException ex2 = assertThrows(IntentValidationException.class, () -> IntentParser.parse(nonSelf)); + assertTrue(ex2.getIssues() + .stream() + .anyMatch(i -> i.contains("storno [Doc] must be a to-one SELF-relation of [Entry]")), + "got: " + ex2.getIssues()); + } + + @Test + void rejectsStornoWithoutReverses() { + String yaml = VALID.replace("map: { date: date }", "map: { date: date }\n storno: Storno"); + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains("declares storno without reverses")), + "got: " + ex.getIssues()); + } +} diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Posting.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Posting.java.template index 64db534678f..6d1cd8d029a 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Posting.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Posting.java.template @@ -24,7 +24,12 @@ import org.eclipse.dirigible.sdk.utils.Json; * genuinely failed post is undone by a compensation action, not this handler; * - a missing rule row or a null referenced rule column SKIPS the posting (the document stays on the * unposted worklist - final-status documents with no back-referencing ${targetEntity}); - * - all writes go through the generated repositories (numbering, status init, validations, checks). + * - all writes go through the generated repositories (numbering, status init, validations, checks); + * - REVERSAL mode (intent reverses:): the handler locates the ORIGINAL document (back-reference set, + * storno link empty), skips fail-soft when none exists, and creates the negated copy (same sides, + * negative amounts - red storno) with the storno link stamped; its idempotency guard counts only + * documents CARRYING the storno link, while the reversed sibling's guard counts only documents + * WITHOUT it. */ @Component public class ${className}Posting implements MessageHandler { @@ -88,8 +93,40 @@ public class ${className}Posting implements MessageHandler { expectedItems++; #end #end - java.util.List existingTargets = + java.util.List relatedTargets = targetRepository.findAll(Criteria.create().eq("${backRefProperty}", source.${sourceKeyField})); +#if($stornoProperty != "") + // Reversal (red storno): the ORIGINAL is the reversed posting's document for the SAME + // source - back-reference set, storno link empty. No original -> the source was never + // posted, nothing to reverse (fail-soft skip). Rows carrying the link are THIS handler's + // own creations (the idempotency set). + gen.${javaGenFolderName}.data.${targetJavaPerspective}.${targetEntity}Entity original = null; + java.util.List existingTargets = + new java.util.ArrayList<>(); + for (gen.${javaGenFolderName}.data.${targetJavaPerspective}.${targetEntity}Entity candidate : relatedTargets) { + if (candidate.${stornoProperty} == null) { + original = candidate; + } else { + existingTargets.add(candidate); + } + } + if (original == null) { + return; // nothing was posted for this source - nothing to reverse + } +#elseif($stornoFilterProperty != "") + // A sibling reversal posting back-references the same source (with its storno link set) - + // only rows WITHOUT the link are THIS posting's own documents. + java.util.List existingTargets = + new java.util.ArrayList<>(); + for (gen.${javaGenFolderName}.data.${targetJavaPerspective}.${targetEntity}Entity candidate : relatedTargets) { + if (candidate.${stornoFilterProperty} == null) { + existingTargets.add(candidate); + } + } +#else + java.util.List existingTargets = + relatedTargets; +#end gen.${javaGenFolderName}.data.${targetJavaPerspective}.${targetEntity}Entity saved; if (!existingTargets.isEmpty()) { saved = existingTargets.get(0); @@ -109,6 +146,9 @@ public class ${className}Posting implements MessageHandler { target.${a.targetProp} = ${a.expr}; #end target.${backRefProperty} = source.${sourceKeyField}; +#if($stornoProperty != "") + target.${stornoProperty} = original.${targetPk}; +#end saved = targetRepository.save(target); } #foreach($row in $itemRows) diff --git a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js index a39e4385a1e..e625ab8ff3d 100644 --- a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js +++ b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js @@ -958,6 +958,8 @@ export function generateFiles(model, parameters, templateSources) { itemsJavaPerspective: sanitizeJavaIdentifier(po.itemsPerspective), itemsFk: po.itemsFk, backRefProperty: po.backRefProperty, + stornoProperty: po.stornoProperty, + stornoFilterProperty: po.stornoFilterProperty, hasRule: po.hasRule, ruleEntity: po.ruleEntity, ruleJavaPerspective: po.rulePerspective ? sanitizeJavaIdentifier(po.rulePerspective) : "", diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java index bb2a1743341..c21d424ab16 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEmissionCoverageIT.java @@ -48,9 +48,11 @@ * Covered here: {@code immutableWhen} / {@code immutable} (409 on write/delete), {@code checks} * (exactlyOne / itemsMin / itemsSumEqual), {@code hierarchy}/{@code leafOnly}, {@code multilingual} * (read-time overlay), seed rows carrying a RELATION column, aggregate totals, {@code transitions} - * (the guarded on-demand status flip: allowed-status 200, wrong-status/guard 409), and the personal - * (my) surface ({@code identity}/{@code personal}/{@code sensitive}: scoped reads, forced owner, - * stripped fields). + * (the guarded on-demand status flip: allowed-status 200, wrong-status/guard 409), {@code postings} + * with {@code reverses} (post on a transition; red-storno reversal on void - negated amounts, + * storno link, fail-soft), and the personal (my) surface + * ({@code identity}/{@code personal}/{@code sensitive}: scoped reads, forced owner, stripped + * fields). */ class IntentEmissionCoverageIT extends IntegrationTest { @@ -116,6 +118,19 @@ class IntentEmissionCoverageIT extends IntegrationTest { relations: - { name: Account, kind: manyToOne, to: Account, leafOnly: true } - { name: Status, kind: manyToOne, to: EntryStatus, function: EntityStatus, init: 1 } + # postings back-reference + the reversal's storno self-link (reverses fixture). + - { name: Doc, kind: manyToOne, to: Doc } + - { name: Storno, kind: manyToOne, to: Entry } + + # postings source: PostDoc flips it POSTED (posting fires), VoidDoc flips it + # CANCELLED (the reverses posting fires - red storno). + - name: Doc + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date, required: true } + - { name: amount, type: decimal } + relations: + - { name: Status, kind: manyToOne, to: EntryStatus, function: EntityStatus, init: 1 } - name: EntryLine checks: @@ -221,6 +236,35 @@ class IntentEmissionCoverageIT extends IntegrationTest { when: "Paid == 0" label: Cancel icon: ban + - name: PostDoc + forEntity: Doc + from: [1] + setStatus: 2 + label: Post + icon: check + - name: VoidDoc + forEntity: Doc + from: [2] + setStatus: 3 + label: Void + icon: ban + + # postings + reverses (red storno): a POSTED Doc posts one balanced Entry (debit + + # credit); a VOIDED Doc posts the reversal - the SAME lines negated on the SAME sides, + # linked to the original through Entry.Storno, fail-soft when nothing was posted. + postings: + - name: docPosting + event: { onTransition: Doc, when: "Status == 2" } + creates: Entry + backReference: Doc + map: { date: date } + items: + - { debit: "Amount" } + - { credit: "Amount" } + - name: docStorno + event: { onTransition: Doc, when: "Status == 3" } + reverses: docPosting + storno: Storno seeds: - name: people @@ -433,6 +477,17 @@ private void assertEmission() { assertTrue(transitionExtension.contains("-custom-action"), "the transition button must contribute to the app's custom-action extension point"); + // postings reverses: the reversal handler negates the sibling's amount expressions on the + // SAME side, locates the original through the empty storno link (fail-soft skip when none) + // and stamps the link; the sibling's idempotency guard symmetrically excludes linked rows. + String stornoPosting = contentOf("gen/events/DocStornoPosting.java"); + assertTrue(stornoPosting.contains("Calc.eval(\"-(Amount)\", source, 2)"), + "the reversal must negate the sibling's amount expression on the same side"); + assertTrue(stornoPosting.contains("nothing to reverse"), "the reversal must skip fail-soft when the source was never posted"); + assertTrue(stornoPosting.contains("target.Storno = original.Id;"), "the reversal must stamp the storno link to the original"); + String basePosting = contentOf("gen/events/DocPostingPosting.java"); + assertTrue(basePosting.contains("candidate.Storno == null"), "the reversed posting's idempotency guard must exclude reversal rows"); + // label: the repository recomputes the stored display Name on every write path. String claimRepository = contentOf("gen/emission/data/claim/ClaimRepository.java"); assertTrue(claimRepository.contains("computeName"), "label must emit the display-name computation into the repository"); @@ -608,6 +663,60 @@ private void assertRuntimeEnforcement() { .statusCode(200) .body("Status", equalTo(1))); + // postings: posting a Doc creates the balanced Entry (async handler - poll)... + AtomicInteger doc = new AtomicInteger(); + restAssuredExecutor.execute(() -> doc.set(given().contentType("application/json") + .body("{\"Date\":\"2026-01-17\",\"Amount\":250}") + .when() + .post(API + "/doc/DocController") + .then() + .statusCode(200) + .extract() + .path("Id"))); + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"id\":" + doc.get() + "}") + .when() + .post("/services/java/" + PROJECT + "/gen/events/PostDocTransition/run") + .then() + .statusCode(200)); + AtomicInteger originalEntry = new AtomicInteger(); + restAssuredExecutor.execute(() -> originalEntry.set(given().when() + .get(API + "/entry/EntryController") + .then() + .statusCode(200) + .body("findAll { it.Doc == " + doc.get() + + " && it.Storno == null }.size()", equalTo(1)) + .extract() + .path("find { it.Doc == " + doc.get() + " && it.Storno == null }.Id")), + 30); + // ...and reverses: voiding the Doc creates the red storno - the SAME lines negated on the + // SAME sides, linked to the original through the storno self-relation. + restAssuredExecutor.execute(() -> given().contentType("application/json") + .body("{\"id\":" + doc.get() + "}") + .when() + .post("/services/java/" + PROJECT + "/gen/events/VoidDocTransition/run") + .then() + .statusCode(200)); + AtomicInteger reversalEntry = new AtomicInteger(); + restAssuredExecutor.execute(() -> reversalEntry.set(given().when() + .get(API + "/entry/EntryController") + .then() + .statusCode(200) + .body("findAll { it.Doc == " + doc.get() + " && it.Storno == " + + originalEntry.get() + " }.size()", equalTo(1)) + .extract() + .path("find { it.Doc == " + doc.get() + " && it.Storno == " + + originalEntry.get() + " }.Id")), + 30); + restAssuredExecutor.execute(() -> given().when() + .get(API + "/entry/EntryLineController") + .then() + .statusCode(200) + .body("findAll { it.Entry == " + reversalEntry.get() + + " && it.Debit != null && it.Debit < 0 }.size()", equalTo(1)) + .body("findAll { it.Entry == " + reversalEntry.get() + + " && it.Credit != null && it.Credit < 0 }.size()", equalTo(1))); + // personal: the my-surface lists ONLY the current user's rows, with the sensitive field // stripped; a foreign record is a 404 (indistinguishable from missing). restAssuredExecutor.execute(() -> given().when()