Skip to content

Commit 90ee58a

Browse files
delchevclaude
andauthored
feat(intent): postings reverses - red-storno reversal on a source void (#6308)
* feat(intent): transitions - guarded on-demand status flips (void/cancel/close) A document whose create-time process has ended (invoice ISSUED, entry POSTED) has no declarative affordance left to change its status: process triggers fire only on create/update/delete, and actions: only opens a custom page. The new top-level transitions: block adds one - a per-record button that moves the record into a designated EntityStatus, guarded server-side: transitions: - name: VoidInvoice forEntity: Invoice # must declare a function: EntityStatus relation from: [3, 4] # allowed source status seed ids setStatus: 8 # target status seed id when: "Paid == 0" # optional <Field> ==|!= <number> guard (Calc semantics) label: Void icon: ban Two halves, the generates pattern: TransitionsIntentGenerator (@order(470)) contributes the per-record button to <project>-custom-action (descriptor carries the endpoint); GlueIntentGenerator.buildTransitions pre-renders the allowed-statuses expression and the Calc-backed when guard into the transitions glue collection, rendered by Transition.java.template into a @controller at gen/events/<ClassName>Transition/run. The controller re-loads the record, returns 409 with the reason when a guard fails, flips ONLY the status column via the targeted updateProperty (no -updated re-fire), and publishes -transitioned - the same channel workflow setters publish, so postings:/integrations observe a manual void exactly like a workflow transition (the enabler for red-storno reversal postings). ControllerInvoker: a CharSequence return no longer stamps text/plain over a content type the controller set explicitly (the transition controller returns JSON); default unchanged when unset. Verified: - unit: TransitionsIntentTest (7) + GlueTransitionsTest (2) + full engine-intent suite green; ControllerInvokerBindingTest +2 content-type tests, full engine-java suite green - IntentEmissionCoverageIT extended (fixture transition + emission tokens + runtime: cancel 200 with the status flipped, wrong-status 409, when-guard 409 leaving the record untouched) and green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(intent): postings reverses - red-storno reversal on a source void The postings glue could post a document when a source reaches a status, but not UN-post it: a voided/cancelled source left its journal entry standing. The new reverses: mode pairs with the transitions: void primitive: postings: - name: invoicePosting event: { onTransition: Invoice, when: "Status == 3" } creates: JournalEntry backReference: Invoice rule: { ... } items: [ ... ] - name: invoiceStorno event: { onTransition: Invoice, when: "Status == 8" } # the void transition reverses: invoicePosting storno: Storno # the created entity's to-one SELF-relation to the original Semantics (red storno - the accounting correction convention): the reversal inherits creates/backReference/rule/map/items from the reversed sibling and re-derives them from the source with every item amount expression NEGATED on the SAME debit/credit side (never swapped - turnovers stay honest). It locates the ORIGINAL through the empty storno link (back-reference set, link null) and skips fail-soft when none exists (the source was never posted); its creation stamps the link. Both handlers' idempotency guards discriminate by that link: the reversal counts only linked rows, the reversed sibling counts only unlinked ones (stornoProperty/stornoFilterProperty in the glue). The reversal lands as a normal new document - DRAFT status init, number placeholder, checks, the accountant review-and-Post task. Verified: - unit: PostingsReversesIntentTest (5: parse + sibling/inherited-keys/storno validations) + GluePostingsReversesTest (negated exprs, inherited coordinates, storno keys on both entries); full engine-intent suite green - IntentEmissionCoverageIT extended (Doc + PostDoc/VoidDoc transitions + docPosting/docStorno fixture; emission tokens for negation/fail-soft/link; runtime: post -> balanced Entry appears, void -> the reversal appears with negative debit AND credit lines and the storno link to the original) - green Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(intent): report filter translates bare to-one relation names to their FK columns A report filter like `Status != 8` (a to-one RELATION of the source, not a field) passed through buildWhere untranslated - fields rewrote to their qualified physical columns but relation names did not, so the generated query carried a nonexistent column and failed at SQL time. Bare to-one relation names now rewrite to the FK column (Invoice."INVOICE_STATUS"), with guards so join-alias tokens from the dotted-ref pass (Customer."CUSTOMER_NAME") and already-quoted columns are left intact. Found by a suite emission audit: an overdue-invoices report excluding a VOIDED status generated 'AND Status != 8' verbatim into the WHERE. Verified: ReportIntentGeneratorTest +1 (bare relation translated, dotted-ref alias unmangled), full class green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 495cb6b commit 90ee58a

10 files changed

Lines changed: 531 additions & 20 deletions

File tree

components/engine/engine-intent/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/GlueIntentGenerator.java

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -702,17 +702,45 @@ static List<Map<String, Object>> buildGeneratesForTest(IntentModel model) {
702702
private static List<Map<String, Object>> buildPostings(IntentModel model, Map<String, EntityIntent> byName,
703703
Map<String, String> compositionParents, IntentSettings settings, IntentGenerationContext context) {
704704
List<Map<String, Object>> out = new ArrayList<>();
705+
// A reversal posting's storno link doubles as the discriminator between the reversed
706+
// sibling's own documents (link empty) and reversals (link set) - the SIBLING's handler
707+
// must filter its idempotency lookup by it too, so map: base posting name -> storno.
708+
Map<String, String> stornoOfReversed = new LinkedHashMap<>();
705709
for (org.eclipse.dirigible.components.intent.model.PostingIntent posting : model.getPostings()) {
710+
if (posting.getReverses() != null && !posting.getReverses()
711+
.isBlank()
712+
&& posting.getStorno() != null) {
713+
stornoOfReversed.put(posting.getReverses(), IntentNaming.pascalCase(posting.getStorno()));
714+
}
715+
}
716+
for (org.eclipse.dirigible.components.intent.model.PostingIntent posting : model.getPostings()) {
717+
boolean isReverse = posting.getReverses() != null && !posting.getReverses()
718+
.isBlank();
719+
// Reversal mode: creates/backReference/rule/map/items come from the reversed sibling;
720+
// the reversal contributes its own event plus the storno link, and every item amount
721+
// expression is negated (same sides - red storno).
722+
org.eclipse.dirigible.components.intent.model.PostingIntent effective = posting;
723+
if (isReverse) {
724+
for (org.eclipse.dirigible.components.intent.model.PostingIntent candidate : model.getPostings()) {
725+
if (candidate != posting && posting.getReverses()
726+
.equals(candidate.getName())) {
727+
effective = candidate;
728+
}
729+
}
730+
if (effective == posting || posting.getStorno() == null) {
731+
continue; // parser already reported it
732+
}
733+
}
706734
if (posting.getName() == null || posting.getName()
707735
.isBlank()
708-
|| posting.getEvent() == null || posting.getCreates() == null) {
736+
|| posting.getEvent() == null || effective.getCreates() == null) {
709737
continue; // parser already reported it
710738
}
711739
if (!settings.shouldGenerate("postings", posting.getName())) {
712740
LOGGER.info("Settings opt-out: keeping existing handler for posting [{}] (not generated)", posting.getName());
713741
continue;
714742
}
715-
EntityIntent creates = byName.get(posting.getCreates());
743+
EntityIntent creates = byName.get(effective.getCreates());
716744
EntityIntent itemsEntity = creates == null ? null : compositionChild(creates, byName);
717745
if (creates == null || itemsEntity == null) {
718746
continue; // parser already reported it
@@ -769,22 +797,27 @@ private static List<Map<String, Object>> buildPostings(IntentModel model, Map<St
769797
e.put("itemsEntity", itemsEntity.getName());
770798
e.put("itemsPerspective", IntentEntities.resolvePerspective(itemsEntity.getName(), compositionParents));
771799
e.put("itemsFk", IntentNaming.pascalCase(creates.getName()));
772-
e.put("backRefProperty", IntentNaming.pascalCase(posting.getBackReference()));
800+
e.put("backRefProperty", IntentNaming.pascalCase(effective.getBackReference()));
801+
// Reversal coordinates: the reversal handler locates the original through the empty
802+
// storno link and stamps it on its own creation; the reversed sibling's handler filters
803+
// reversals OUT of its idempotency lookup through the same property.
804+
e.put("stornoProperty", isReverse ? IntentNaming.pascalCase(posting.getStorno()) : "");
805+
e.put("stornoFilterProperty", isReverse ? "" : stornoOfReversed.getOrDefault(posting.getName(), ""));
773806
// Rule lookup: a single match selector, columns referenced from the items.
774-
boolean hasRule = posting.getRule() != null && posting.getRule()
775-
.get("entity") != null;
807+
boolean hasRule = effective.getRule() != null && effective.getRule()
808+
.get("entity") != null;
776809
e.put("hasRule", hasRule);
777810
java.util.Set<String> usedRuleColumns = new java.util.LinkedHashSet<>();
778811
if (hasRule) {
779-
String ruleEntityName = String.valueOf(posting.getRule()
780-
.get("entity"));
812+
String ruleEntityName = String.valueOf(effective.getRule()
813+
.get("entity"));
781814
e.put("ruleEntity", ruleEntityName);
782815
// A setting rule entity (the normal case) lives under the global Settings perspective.
783816
EntityIntent ruleEntityIntent = byName.get(ruleEntityName);
784817
e.put("rulePerspective", ruleEntityIntent != null && ruleEntityIntent.isSetting() ? "Settings"
785818
: IntentEntities.resolvePerspective(ruleEntityName, compositionParents));
786-
Map<?, ?> match = (Map<?, ?>) posting.getRule()
787-
.get("match");
819+
Map<?, ?> match = (Map<?, ?>) effective.getRule()
820+
.get("match");
788821
Map.Entry<?, ?> selector = match.entrySet()
789822
.iterator()
790823
.next();
@@ -793,16 +826,16 @@ private static List<Map<String, Object>> buildPostings(IntentModel model, Map<St
793826
}
794827
// Header assignments: copy / literal / {placeholder} template - pre-rendered Java.
795828
List<Map<String, Object>> headerAssignments = new ArrayList<>();
796-
if (posting.getMap() != null) {
797-
for (Map.Entry<String, String> entry : posting.getMap()
798-
.entrySet()) {
829+
if (effective.getMap() != null) {
830+
for (Map.Entry<String, String> entry : effective.getMap()
831+
.entrySet()) {
799832
headerAssignments.add(postingAssignment(entry.getKey(), entry.getValue()));
800833
}
801834
}
802835
e.put("headerAssignments", headerAssignments);
803836
// Item rows: rule(...) refs read the rule row; expressions run through Calc on the source.
804837
List<Map<String, Object>> itemRows = new ArrayList<>();
805-
for (Map<String, String> row : posting.getItems() == null ? List.<Map<String, String>>of() : posting.getItems()) {
838+
for (Map<String, String> row : effective.getItems() == null ? List.<Map<String, String>>of() : effective.getItems()) {
806839
Map<String, Object> rendered = new LinkedHashMap<>();
807840
List<Map<String, Object>> assigns = new ArrayList<>();
808841
String rowGuard = "";
@@ -832,7 +865,9 @@ private static List<Map<String, Object>> buildPostings(IntentModel model, Map<St
832865
} else {
833866
FieldIntent target = fieldOf(itemsEntity, cell.getKey());
834867
int scale = target != null && target.getScale() != null ? target.getScale() : 2;
835-
assign.put("expr", "Calc.eval(\"" + value.replace("\"", "\\\"") + "\", source, " + scale + ")");
868+
// Reversal: the SAME expression negated on the SAME side (red storno).
869+
String expr = isReverse ? "-(" + value + ")" : value;
870+
assign.put("expr", "Calc.eval(\"" + expr.replace("\"", "\\\"") + "\", source, " + scale + ")");
836871
}
837872
assigns.add(assign);
838873
}

components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/PostingIntent.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,37 @@ public class PostingIntent {
7070
* {@code == 0} on a source field).
7171
*/
7272
private List<Map<String, String>> items;
73+
/**
74+
* Reversal (red storno) mode: names a SIBLING posting in this block whose created document this
75+
* posting reverses. The reversal re-derives the sibling's header and items from the source with
76+
* every amount expression NEGATED on the SAME side (never swapped - turnovers stay honest), links
77+
* {@link #storno} to the original, and skips fail-soft when no original exists (the source was
78+
* never posted). {@code creates}/{@code backReference}/{@code rule}/{@code map}/ {@code items} are
79+
* inherited from the sibling and must not be declared here.
80+
*/
81+
private String reverses;
82+
/**
83+
* The created entity's to-one SELF-relation linked to the reversed (original) document - required
84+
* with {@link #reverses}. Doubles as the discriminator between the sibling's own documents (link
85+
* empty) and reversals (link set) for both handlers' idempotency guards.
86+
*/
87+
private String storno;
88+
89+
public String getReverses() {
90+
return reverses;
91+
}
92+
93+
public void setReverses(String reverses) {
94+
this.reverses = reverses;
95+
}
96+
97+
public String getStorno() {
98+
return storno;
99+
}
100+
101+
public void setStorno(String storno) {
102+
this.storno = storno;
103+
}
73104

74105
public String getName() {
75106
return name;

components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/parser/IntentParser.java

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2280,6 +2280,47 @@ private static void validatePostings(IntentModel model, Set<String> usesAliases,
22802280
issues.add(subject + " event requires `when: \"<Property> == <status seed id>\"`");
22812281
}
22822282
}
2283+
// Reversal mode: creates/backReference/rule/map/items are inherited from the reversed
2284+
// sibling; the reversal declares only its own event + the storno self-link.
2285+
if (posting.getReverses() != null && !posting.getReverses()
2286+
.isBlank()) {
2287+
PostingIntent sibling = null;
2288+
for (PostingIntent candidate : model.getPostings()) {
2289+
if (candidate != posting && posting.getReverses()
2290+
.equals(candidate.getName())) {
2291+
sibling = candidate;
2292+
}
2293+
}
2294+
if (sibling == null) {
2295+
issues.add(subject + " reverses unknown posting [" + posting.getReverses() + "] - it must name a sibling"
2296+
+ " posting in this block");
2297+
continue;
2298+
}
2299+
if (posting.getCreates() != null || posting.getBackReference() != null || posting.getRule() != null
2300+
|| posting.getMap() != null || (posting.getItems() != null && !posting.getItems()
2301+
.isEmpty())) {
2302+
issues.add(subject + " is a reversal - creates/backReference/rule/map/items are inherited from ["
2303+
+ posting.getReverses() + "] and must not be declared");
2304+
}
2305+
EntityIntent reversed = sibling.getCreates() == null ? null : byName.get(sibling.getCreates());
2306+
if (posting.getStorno() == null || posting.getStorno()
2307+
.isBlank()) {
2308+
issues.add(subject + " requires `storno: <self relation>` - the created entity's link to the reversed document");
2309+
} else if (reversed != null) {
2310+
RelationIntent storno = toOneRelationByName(reversed, posting.getStorno());
2311+
if (storno == null || !reversed.getName()
2312+
.equals(storno.getTo())
2313+
|| storno.isCrossModel()) {
2314+
issues.add(subject + " storno [" + posting.getStorno() + "] must be a to-one SELF-relation of ["
2315+
+ reversed.getName() + "]");
2316+
}
2317+
}
2318+
continue;
2319+
}
2320+
if (posting.getStorno() != null && !posting.getStorno()
2321+
.isBlank()) {
2322+
issues.add(subject + " declares storno without reverses - the storno link belongs to the reversal posting");
2323+
}
22832324
// creates + items child + backReference
22842325
EntityIntent creates = posting.getCreates() == null ? null : byName.get(posting.getCreates());
22852326
if (creates == null) {

components/engine/engine-intent/src/main/resources/intent-assistant-guide.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,21 @@ composition is opt-in.
209209
A missing rule row or null referenced column SKIPS the posting (the unposted worklist = final-status
210210
documents with no back-referencing target), never throws. All writes go through the generated
211211
repositories, so numbering/status-init/`checks:` fire on the created document.
212+
**Reversal mode (red storno):** a posting with `reverses: <sibling posting name>` undoes the
213+
sibling's document when the source is voided/cancelled - pair it with a `transitions:` void:
214+
```yaml
215+
- name: invoiceStorno
216+
event: { onTransition: SalesInvoice, model: kf-billing, when: "Status == 8" } # the void status
217+
reverses: salesInvoicePosting # sibling posting in this block
218+
storno: Storno # the created entity's to-one SELF-relation to the original
219+
```
220+
`creates`/`backReference`/`rule`/`map`/`items` are inherited from the sibling and must not be
221+
declared. Semantics: locate the ORIGINAL (back-reference = this source, storno link empty) - none
222+
-> skip fail-soft; create the negated copy (every item amount expression negated on the SAME
223+
debit/credit side - never swapped) with the `storno` link stamped; idempotent (rows carrying the
224+
link are the reversal's own; the sibling's guard symmetrically counts only rows without it). The
225+
reversal lands as a normal new document (DRAFT status init, numbering, checks), dated by its own
226+
`map`-inherited header - corrections post into the open period.
212227
- `calculatedOnCreate` / `calculatedOnUpdate` - an expression the generated repository assigns to the
213228
property on insert / update. Prefer a **neutral arithmetic expression** for numeric totals
214229
(`"Quantity * Price"`, `"round(Net * 0.2, 2)"`) - the SDK `Calc` evaluator runs it on the server and

0 commit comments

Comments
 (0)