Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion components/engine/engine-intent/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,7 @@ Implemented and generating annotated client-Java off the shared `EventBinding` /
- **Editor renders the glue + outputs.** New `renderGlue()` "Glue & Outputs" mxGraph section with SAP-icon-badged cards edged to their entities (see the diagram section). `IntentEditorLoadsIT` asserts the `loanUpdated` card renders.
- **Parser hardening.** A wrong-typed scalar (e.g. an unquoted brace recipient `to: {member.email}`, which YAML parses as an object) now surfaces as a clean `IntentValidationException` issue with a helpful message instead of a raw 500 Gson error that wedged the editor. `IntentParserTest` covers it.
- **Externalized AI system prompt.** Moved from an inline string to `intent-assistant-guide.md` (classpath resource, fail-fast load), corrected to the full current schema incl. the glue catalog + `businessKey`/`businessKeyStrategy`, and restored the propose-the-whole-file tool contract the draft had dropped.
- **Settlements re-allocate a corrected payment (#6818).** The payment spread handler used to bind the payment's bare create topic only, so a payment booked for the wrong amount and corrected afterwards - or created incomplete and completed later - was never re-allocated and the invoice kept the original settled figure. It is now emitted once per bound payment event (create + `-updated`) from its own glue collection, **`settlementListeners`** - the `settlements` collection still drives the one-per-settlement `<Name>OnInvoice` delegate, and a second collection is what lets the two templates fan out differently while sharing one descriptor (`rollupEntry` copies it per class name + topic suffix, exactly as roll-ups and expansions do). Note the two traps a new glue collection carries: it needs a `case` in **both** `GlueGenerator` (Java) and `generateUtils.js` (JS) or it renders whole-model with raw `${...}` placeholders, and `GlueGenerator.copy` is a per-key allow-list, so a new descriptor key that is not listed there never reaches the template. The handler itself was already a recompute of the payment's *unallocated* balance (re-delivery is a no-op by construction); it now also **releases** the excess - newest allocation first, through the junction repository - when the payment is corrected below what it already covers, so the recompute converges in both directions.
- **Settlements re-allocate a corrected payment (#6818).** The payment spread handler used to bind the payment's bare create topic only, so a payment booked for the wrong amount and corrected afterwards - or created incomplete and completed later - was never re-allocated and the invoice kept the original settled figure. It is now emitted once per bound payment event (create + `-updated`) from its own glue collection, **`settlementListeners`** - the `settlements` collection still drives the one-per-settlement `<Name>OnInvoice` delegate, and a second collection is what lets the two templates fan out differently while sharing one descriptor (`rollupEntry` copies it per class name + topic suffix, exactly as roll-ups and expansions do). Note the two traps a new glue collection carries: it needs a `case` in `GlueGenerator` (Java - the only generator since #6707; the old `generateUtils.js` twin is gone) or it renders whole-model with raw `${...}` placeholders, and `GlueGenerator.copy` is a per-key allow-list, so a new descriptor key that is not listed there never reaches the template. The handler itself was already a recompute of the payment's *unallocated* balance (re-delivery is a no-op by construction); it now also **releases** the excess - newest allocation first, through the junction repository - when the payment is corrected below what it already covers, so the recompute converges in both directions. Two boundaries of that release: the CREATE handler never releases - a create event is the first word about a payment, so a negative pot there can only be a DELAYED create racing a correction the updated handler already allocated, and releasing on the stale payload would undo it (#6865). And a corrected MATCH column (the payment re-filed under another Customer) is invisible to the amount-based recompute (`pot - allocated == 0`), so the payment's match columns are grouping keys and a third listener on the payment's `-rekeyed` topic releases everything and re-allocates from the STORE - both re-key notices run the same store-driven recompute, so delivery order cannot matter; emitted only for a LOCAL payment, since a cross-model payment's DAO belongs to the owner model and a projection has no repository to re-read (#6864).
- **CI runs on Corretto 24** (compile target stays 21); the integration-test fork gets `-Xmx6g`. (Root-level change; recorded here because it landed alongside the intent work.)

**Cross-artefact field naming:** the `.form` control `model` (and control `id`) bind to the entity property, so they use `IntentNaming.pascalCase` to match the EDM property names (`loanedOn` -> `LoanedOn`). The `.report` references physical UPPER_SNAKE columns and humanized display aliases (no camelCase property identifiers), so it needs no PascalCasing.
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,14 @@ private static List<Map<String, Object>> buildSettlementListeners(List<Map<Strin
// The create handler keeps its established class name; the correction one is suffixed.
listeners.add(rollupEntry(settlement, name + "OnPayment", ""));
listeners.add(rollupEntry(settlement, name + "OnPaymentUpdated", "-updated"));
if (!Boolean.TRUE.equals(settlement.get("crossModel"))) {
// A corrected MATCH column re-targets the allocation wholesale: the payment's DAO
// publishes "-rekeyed" for the move (the match columns are grouping keys), and this
// handler releases everything and re-allocates from the STORE - which needs the
// payment's repository, so it exists only for a local payment. A cross-model payment's
// DAO belongs to the owner model, which knows nothing of this settlement.
listeners.add(rollupEntry(settlement, name + "OnPaymentRekeyed", "-rekeyed"));
}
}
return listeners;
}
Expand Down Expand Up @@ -1277,6 +1285,14 @@ static List<Map<String, Object>> buildRollupsForTest(IntentModel model) {
null);
}

/** Test hook: build the {@code settlementListeners} glue collection without a repository. */
static List<Map<String, Object>> buildSettlementListenersForTest(IntentModel model) {
IntentGenerationContext context =
new IntentGenerationContext(model, "/" + model.getName(), model.getName(), "workspace", model.getName(), null);
return buildSettlementListeners(buildSettlements(model, IntentEntities.byName(model), IntentEntities.compositionParents(model),
IntentSettings.parse("{}"), context));
}

/** Test hook: build the {@code waits} glue collection without a repository. */
static List<Map<String, Object>> buildWaitsForTest(IntentModel model) {
return buildWaits(model, IntentSettings.parse("{}"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import org.eclipse.dirigible.components.intent.model.RelatedIntent;
import org.eclipse.dirigible.components.intent.model.RelationIntent;
import org.eclipse.dirigible.components.intent.model.RollupIntent;
import org.eclipse.dirigible.components.intent.model.SettlementIntent;
import org.eclipse.dirigible.components.intent.model.SlotsIntent;
import org.eclipse.dirigible.components.intent.model.UsesIntent;
import org.eclipse.dirigible.components.intent.model.UniqueIntent;
Expand Down Expand Up @@ -634,6 +635,19 @@ else if (!extension && !dependent && !setting && !compositionParents.containsVal
addGroupingKey(groupingKeys, seenGroupingKeys, rollup.getVia());
}
}
// A settlement matches invoices by columns of the PAYMENT row (its Customer, its Company),
// so a correction that moves one of them re-targets the whole allocation - the settlement's
// rekey handler releases and re-allocates from the store. Without these keys the DAO never
// notices the move and the junction rows keep paying the OLD counterparty's invoices.
for (SettlementIntent settlement : model.getSettlements()) {
if (entity.getName()
.equals(settlement.getPayment())
&& settlement.getMatch() != null) {
for (String matchColumn : settlement.getMatch()) {
addGroupingKey(groupingKeys, seenGroupingKeys, matchColumn);
}
}
}
if (!groupingKeys.isEmpty()) {
entityMap.put("groupingKeys", groupingKeys);
FieldIntent groupingPk = primaryKeyOf(entity);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* 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;

/**
* A corrected settlement MATCH column re-targets the whole allocation: the payment's DAO must
* notice the move (the match columns are grouping keys) and the settlement must bind a rekey
* handler that releases and re-allocates from the store. Without both, the junction rows kept
* paying the OLD counterparty's invoices forever - the amount-based recompute sees
* {@code pot - allocated == 0} and no-ops.
*/
class GlueSettlementRekeyTest {

private static final String YAML = """
name: settle
entities:
- name: Invoice
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: date, type: date }
- { name: total, type: decimal, precision: 18, scale: 2 }
- { name: paid, type: decimal, precision: 18, scale: 2 }
relations:
- { name: Customer, kind: manyToOne, to: Customer }
- name: Payment
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: date, type: date }
- { name: amount, type: decimal, precision: 18, scale: 2, required: true }
relations:
- { name: Customer, kind: manyToOne, to: Customer }
- name: Customer
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 100 }
- name: InvoicePayment
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: amount, type: decimal, precision: 18, scale: 2, required: true }
relations:
- { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true }
- { name: Payment, kind: manyToOne, to: Payment, required: true }
settlements:
- { name: autoSettle, junction: InvoicePayment, invoice: Invoice, payment: Payment,
amount: amount, total: total, paid: paid, pot: amount, order: date,
match: [Customer] }
""";

@Test
void aLocalPaymentGetsTheRekeyListenerBesideCreateAndUpdated() {
List<Map<String, Object>> listeners = GlueIntentGenerator.buildSettlementListenersForTest(IntentParser.parse(YAML));

assertEquals(3, listeners.size(), "create, correction and re-key - the third is what re-targets a corrected match column");
assertEquals(List.of("", "-updated", "-rekeyed"), listeners.stream()
.map(l -> String.valueOf(l.get("topicSuffix")))
.toList());
assertEquals(List.of("AutoSettleOnPayment", "AutoSettleOnPaymentUpdated", "AutoSettleOnPaymentRekeyed"), listeners.stream()
.map(l -> String.valueOf(
l.get("className")))
.toList());
}

/**
* A cross-model payment's DAO is generated by the OWNER model, which knows nothing of this
* settlement - no "-rekeyed" is ever published for it here, and the store-driven re-allocation
* needs the payment's repository, which a projection does not have. The rekey listener is
* deliberately absent rather than silently dead.
*/
@Test
void aCrossModelPaymentGetsNoRekeyListener() {
// The cross-model shape is the junction's payment relation carrying model:.
String yaml = YAML.replace("name: settle\n", "name: settle\nuses:\n - { model: treasury, project: treasury }\n")
.replace("- { name: Payment, kind: manyToOne, to: Payment, required: true }",
"- { name: Payment, kind: manyToOne, to: Payment, required: true, model: treasury }");
assertTrue(yaml.contains("model: treasury"), "the fixture surgery must actually make the payment cross-model");
List<Map<String, Object>> listeners = GlueIntentGenerator.buildSettlementListenersForTest(IntentParser.parse(yaml));

assertEquals(2, listeners.size(), "a cross-model payment binds create and correction only: " + listeners);
assertTrue(listeners.stream()
.noneMatch(l -> "-rekeyed".equals(l.get("topicSuffix"))));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,59 @@ void rollupChildCarriesItsParentFkAsAGroupingKey() {
assertNull(entityByName(entities(model), "Customer").get("groupingKeys"));
}

/**
* A settlement matches invoices by columns of the PAYMENT row, so a corrected match value (the
* payment re-filed under another Customer) moves the whole allocation between counterparties. The
* payment must track those columns as grouping keys - the DAO's "-rekeyed" publish is the only
* signal the settlement's re-key handler ever gets.
*/
@Test
@SuppressWarnings("unchecked")
void aSettlementsMatchColumnsAreThePaymentsGroupingKeys() {
String yaml = """
name: settle
entities:
- name: Invoice
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: date, type: date }
- { name: total, type: decimal, precision: 18, scale: 2 }
- { name: paid, type: decimal, precision: 18, scale: 2 }
relations:
- { name: Customer, kind: manyToOne, to: Customer }
- name: Payment
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: date, type: date }
- { name: amount, type: decimal, precision: 18, scale: 2, required: true }
relations:
- { name: Customer, kind: manyToOne, to: Customer }
- name: Customer
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: name, type: string, required: true, length: 100 }
- name: InvoicePayment
fields:
- { name: id, type: integer, primaryKey: true, generated: true }
- { name: amount, type: decimal, precision: 18, scale: 2, required: true }
relations:
- { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true }
- { name: Payment, kind: manyToOne, to: Payment, required: true }
settlements:
- { name: autoSettle, junction: InvoicePayment, invoice: Invoice, payment: Payment,
amount: amount, total: total, paid: paid, pot: amount, order: date,
match: [Customer] }
""";
Map<String, Object> model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "settle");
List<Map<String, String>> keys = (List<Map<String, String>>) entityByName(entities(model), "Payment").get("groupingKeys");
assertEquals(1, keys.size(), "the payment must track its settlement match column");
assertEquals("Customer", keys.get(0)
.get("key"));
// The invoice side is matched by the RECORD's own column and never moves an allocation by
// itself - only the payment's match value re-targets the settlement.
assertNull(entityByName(entities(model), "Invoice").get("groupingKeys"));
}

/**
* The two non-blocking guard outcomes. Both PERSIST the row and mark it instead of failing the
* write: {@code task} stamps a boolean marker that the entity's process decision branches on (the
Expand Down
Loading
Loading