From 6e23b4979c3c831b1acd6489348bdb1ea1d40a61 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 11 Sep 2026 15:40:13 +0300 Subject: [PATCH] intent: a scheduled reminder can record what it sent and escalate by days past due (#7276) A `schedules[].notify` could mail but leave no trace, and could not tell a document three days overdue from one ninety days overdue. So a reminder history filled only from manual clicks, and a seeded Second reminder / Final notice was never applied by the system - only the first level was. codbex `sales-invoices` models dunning correctly at the data level and defers the behaviour in its own comments for exactly this reason. Two capabilities, each independently useful: 1. **notify AND generate in one tick.** They are no longer mutually exclusive. Per matched row the target record is created first and the mail goes out after it, in the same fail-soft try, and the generate's `unique:` natural key gates BOTH - a row whose record already exists is skipped entirely, mail included. `unique:` is therefore required on a combined schedule (refused without it): without a key the tick re-mails every matched row every time it fires. 2. **`escalate:` - a days-past-due ladder.** `{ ladder, after, since, into }` places the row at the HIGHEST level whose threshold it has passed, counted in whole days from a date of the row; the level is written onto the generated record through `into` and joins the natural key, which is what sends each level exactly once. A row that has passed no threshold is left for a later tick and counted. `{escalation.}` reads one field of the chosen level in the subject and body - the per-level wording a flat schedule cannot express. The parser refuses every way the ladder would read as working and not be: an escalation without a generate (nothing distinguishes a level already sent from one still due), a key that omits `into` (the guard finds the first reminder forever), a non-integer threshold, a non-date `since`, an `into` that does not point at the ladder or that `map`/`defaults` also assigns, a cross-model source or target, and an `{escalation.}` the ladder does not declare - which would otherwise mail its own braces to the customer. The job template is now one row body with `generates` / `notifies` flags rather than two branches; `action` stays in the glue so a `.glue` written before this renders exactly what it always did. The chosen level is frozen into an effectively final local before the generation lambda closes over it, and the two halves' one-hop relation loads are merged so a relation both reach is loaded once. Verified: engine-intent unit suite (1253 tests) green, including new ScheduleEscalateIntentTest and GlueScheduleEscalationTest; IntentEngineIT 81/81 green with a new test pinning the generated job's ordering; IntentEmissionCoverageIT green with an escalating dunning tick added to its fixture, so the combined job is COMPILED by the publish; IntentCrossModelScheduleSourceIT 5/5 green; `formatter:validate` green with the cache wiped. Co-Authored-By: Claude Opus 5 --- .../intent/generator/GlueIntentGenerator.java | 157 ++++++++++++- .../intent/generator/NotificationSupport.java | 66 +++++- .../intent/model/EscalateIntent.java | 84 +++++++ .../intent/model/ScheduleIntent.java | 24 +- .../intent/parser/IntentParser.java | 217 +++++++++++++++++- .../main/resources/intent-assistant-guide.md | 76 +++++- .../generator/GlueScheduleEscalationTest.java | 215 +++++++++++++++++ .../intent/parser/IntentParserTest.java | 26 ++- .../parser/ScheduleEscalateIntentTest.java | 175 ++++++++++++++ .../template/service/model/GlueGenerator.java | 20 +- .../events/Job.java.template | 143 ++++++++---- .../tests/api/IntentEmissionCoverageIT.java | 64 ++++++ .../integration/tests/api/IntentEngineIT.java | 110 +++++++++ 13 files changed, 1298 insertions(+), 79 deletions(-) create mode 100644 components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EscalateIntent.java create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueScheduleEscalationTest.java create mode 100644 components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/ScheduleEscalateIntentTest.java 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 77fe8f27c70..fe926f59e9c 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 @@ -26,6 +26,7 @@ import org.eclipse.dirigible.components.intent.generator.WriterSupport.Writer; import org.eclipse.dirigible.components.intent.generator.edm.CrossModelSupport; import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.EscalateIntent; import org.eclipse.dirigible.components.intent.model.FieldIntent; import org.eclipse.dirigible.components.intent.model.GenerateChildIntent; import org.eclipse.dirigible.components.intent.model.GeneratesIntent; @@ -3032,13 +3033,27 @@ private static Map postingAssignment(String targetProperty, Stri "existing", "candidate", "item", "raw", "values", "req", "id", "entity", "rows", "day", "monthEnd", "recordUrl", "inboxUrl", "subject", "body", "document", "part", "parts", "from", "to"); + /** + * The extra locals a schedule's escalation ladder (issue #7276) declares in the same scope. Kept + * apart from {@link #CREATE_FROM_LOCALS} deliberately: they exist only where an {@code escalate:} + * block does, and folding them in would refuse a perfectly ordinary relation named {@code Level} on + * every create-from that never declares one. + */ + private static final Set ESCALATION_LOCALS = + Set.of(NotificationSupport.ESCALATION_LOCAL, "escalationCandidate", "overdueDays", "level"); + /** * The first one-hop load whose local would collide with a name the template already declares, or * {@code null} when none does. */ private static String collidingLocal(List loads) { + return collidingLocal(loads, CREATE_FROM_LOCALS); + } + + /** The first load whose local is one of the given reserved names, or {@code null} when none is. */ + private static String collidingLocal(List loads, Set reserved) { for (NotificationSupport.RelationLoad load : loads) { - if (CREATE_FROM_LOCALS.contains(load.local())) { + if (reserved.contains(load.local())) { return load.local(); } } @@ -4077,6 +4092,43 @@ private static List> buildOutbound(IntentModel model, Map + * Everything here is resolved against LOCAL entities - the parser refuses a cross-model source and + * a cross-model generate target for exactly that reason - so a miss is a model that reached + * generation unvalidated, and the schedule is dropped loudly rather than emitting a ladder that + * does not compile. + * + * @return the template keys, or {@code null} when the ladder cannot be resolved (reported) + */ + private static Map escalationFields(ScheduleIntent schedule, Map byName, + Map compositionParents, IntentModel model, IntentGenerationContext context) { + EscalateIntent escalate = schedule.getEscalate(); + EntityIntent ladder = escalate.getLadder() == null ? null : byName.get(escalate.getLadder()); + if (ladder == null || escalate.getAfter() == null || escalate.getSince() == null || escalate.getInto() == null) { + reportDroppedGlue(context, + "Schedule [" + schedule.getName() + "] escalate does not resolve: ladder [" + escalate.getLadder() + "], after [" + + escalate.getAfter() + "], since [" + escalate.getSince() + "], into [" + escalate.getInto() + + "] - the schedule was NOT generated"); + return null; + } + Map fields = new LinkedHashMap<>(); + fields.put("escalationEntity", ladder.getName()); + // The local the generated loop holds the chosen level in - the SAME name an + // {escalation.} placeholder renders against, which is what keeps the two in step. + fields.put("escalationLocal", NotificationSupport.ESCALATION_LOCAL); + fields.put("escalationPerspective", IntentEntities.resolvePerspective(ladder.getName(), compositionParents, model)); + fields.put("escalationKeyProperty", IntentEntities.keyFieldName(ladder)); + fields.put("escalationAfterProperty", IntentNaming.pascalCase(escalate.getAfter())); + fields.put("escalationSinceProperty", IntentNaming.pascalCase(escalate.getSince())); + fields.put("escalationIntoProperty", IntentNaming.pascalCase(escalate.getInto())); + return fields; + } + private static List> buildSchedules(IntentModel model, Map byName, Map compositionParents, IntentSettings settings, IntentGenerationContext context) { List> schedules = new ArrayList<>(); @@ -4110,7 +4162,8 @@ private static List> buildSchedules(IntentModel model, Map> buildSchedules(IntentModel model, Map allLoads = new ArrayList<>(); + // The escalation ladder (issue #7276): resolved BEFORE the generate, whose assignments and + // natural key both carry the chosen level. + EscalateIntent escalate = schedule.getEscalate(); + Map escalation = null; + if (escalate != null) { + if (!generates || sourceCrossModel) { + // The parser refuses both, precisely; a generation reached by another route drops the + // schedule rather than emitting a ladder with nowhere to record what it applied. + reportDroppedGlue(context, + "Schedule [" + schedule.getName() + "] declares escalate" + + (generates + ? " on the cross-model source [" + entity + "], whose properties belong to the [" + + schedule.getModel() + "] model" + : " without a generate to record the level it applies") + + " - the schedule was NOT generated"); + continue; + } + escalation = escalationFields(schedule, byName, compositionParents, model, context); + if (escalation == null) { + continue; // reported above + } + entry.putAll(escalation); + } + entry.put("hasEscalation", escalation != null); if (generates) { // Scheduled record generation: the queried row is the source, so its create-from maps the @@ -4218,8 +4303,15 @@ private static List> buildSchedules(IntentModel model, Map> buildSchedules(IntentModel model, Map> buildSchedules(IntentModel model, Map> buildSchedules(IntentModel model, Map loads = dedupeLoads(allLoads); + if (escalation != null) { + // The ladder block declares locals of its own in the loop's scope, so a relation hopped + // through under one of those names would shadow it and not compile. + String ladderCollision = collidingLocal(loads, ESCALATION_LOCALS); + if (ladderCollision != null) { + reportDroppedGlue(context, + "Schedule [" + schedule.getName() + "] hops through the relation [" + ladderCollision + "] of [" + entity + + "], whose name is one the escalation ladder already uses for a local of its own" + + " - rename the relation, or reference a direct property instead - the schedule was NOT generated"); + continue; + } + } + entry.put("relationLoads", relationLoads(loads)); schedules.add(entry); } return schedules; @@ -4954,6 +5063,24 @@ private static List> relationLoads(NotificationSupport.Plan */ private static List> relationLoads(NotificationSupport.Plan plan, NotifySupport.PrintAttachment attachment, NotifySupport.ReportAttachment report) { + return relationLoads(mergedLoads(plan, attachment, report)); + } + + /** + * The one-hop loads a notify block needs, in first-use order and deduplicated by local: the message + * text's, then a print attachment's file-name ones, then a report attachment's bindings. Returned + * as the typed records (rather than the glue projection) so a caller that also has loads of its own + * - a schedule that both generates and notifies (issue #7276) - can merge before projecting: the + * generated loop declares each local ONCE, so the same relation reached by both halves must not be + * loaded twice. + * + * @param plan the translated notify block + * @param attachment the resolved print attachment, or {@code null} + * @param report the resolved report attachment, or {@code null} + * @return the merged loads, message-text ones first + */ + private static List mergedLoads(NotificationSupport.Plan plan, + NotifySupport.PrintAttachment attachment, NotifySupport.ReportAttachment report) { List merged = new ArrayList<>(plan.loads()); Set declared = new LinkedHashSet<>(); for (NotificationSupport.RelationLoad load : merged) { @@ -4973,7 +5100,19 @@ private static List> relationLoads(NotificationSupport.Plan } } } - return relationLoads(merged); + return merged; + } + + /** The loads with every repeated local dropped, keeping first-use order. */ + private static List dedupeLoads(List loads) { + List unique = new ArrayList<>(); + Set declared = new LinkedHashSet<>(); + for (NotificationSupport.RelationLoad load : loads) { + if (declared.add(load.local())) { + unique.add(load); + } + } + return unique; } private static List> relationLoads(List resolved) { diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java index b10849c1b57..4db9951575a 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NotificationSupport.java @@ -95,6 +95,20 @@ public final class NotificationSupport { */ static final String INBOX_URL_TOKEN = "inboxUrl"; + /** + * The {@code escalation.} scope - the level a schedule's days-past-due ladder placed the row + * at (issue #7276), reachable from the message text so the wording can differ per level ("a + * friendly reminder" at the first, "final notice before collection" at the last). It is also the + * NAME of the local the generated job holds that level in, which is what keeps the rendered access + * and the declaration in step. + * + *

+ * Placeholders only, exactly like {@link NotifySupport#RECORD_SCOPE}: a recipient is a person, and + * a ladder of settings has no mailbox. One field of the level, never a walk on - a second hop would + * be a load per message, and the composed value belongs on the level itself. + */ + public static final String ESCALATION_LOCAL = "escalation"; + private NotificationSupport() {} /** @@ -228,10 +242,39 @@ public static Plan plan(NotificationIntent notification, EntityIntent eventEntit */ public static Plan plan(NotificationIntent notification, EntityIntent eventEntity, Map byName, Map compositionParents, CrossModelLookup crossModel) { + return plan(notification, eventEntity, null, byName, compositionParents, crossModel); + } + + /** + * The same translation with an escalation LADDER in scope: the message text may read one field of + * the level a schedule's {@code escalate:} placed the row at, through {@code {escalation.}} + * (issue #7276). Pass {@code null} for the ladder everywhere an escalation cannot apply - a + * placeholder then stays unresolvable and degrades to its own literal text, as every unknown + * placeholder does. + * + * @param notification the notification + * @param eventEntity the entity whose event fires it + * @param escalation the escalation ladder entity, or {@code null} + * @param byName all LOCAL entities by name (to resolve same-model relation targets) + * @param compositionParents composition-parent map (to resolve a target's perspective) + * @param crossModel resolver for a cross-model relation's owner facts, or {@code null} + * @return the plan, or {@code null} if the {@code to} recipient cannot be resolved + */ + public static Plan plan(NotificationIntent notification, EntityIntent eventEntity, EntityIntent escalation, + Map byName, Map compositionParents, CrossModelLookup crossModel) { Object when = notification.getEvent() .get("when"); - return plan(notification.getTo(), notification.getSubject(), notification.getBody(), when, eventEntity, byName, compositionParents, - crossModel); + Resolver resolver = new Resolver(eventEntity, null, escalation, byName, compositionParents, crossModel); + String recipient = resolver.value(notification.getTo()); + if (recipient == null) { + return null; // an unresolvable recipient relation.field - skip rather than email garbage + } + // Rendered BEFORE the loads are read: a placeholder is what registers most one-hop loads, and an + // argument list evaluated left to right would snapshot the loads before the text added any. + String subjectExpression = resolver.text(notification.getSubject()); + String bodyExpression = resolver.text(notification.getBody()); + return new Plan(resolver.loads(), guard(when, eventEntity, byName), recipient, subjectExpression, bodyExpression, + resolver.usesRecordUrl(), resolver.usesInboxUrl()); } /** @@ -276,7 +319,7 @@ public static Plan plan(String to, String subject, String body, Object when, Ent */ public static Plan plan(String to, String subject, String body, Object when, EntityIntent entity, EntityIntent anchor, Map byName, Map compositionParents, CrossModelLookup crossModel) { - Resolver resolver = new Resolver(entity, anchor, byName, compositionParents, crossModel); + Resolver resolver = new Resolver(entity, anchor, null, byName, compositionParents, crossModel); String recipient = resolver.value(to); if (recipient == null) { return null; // an unresolvable recipient relation.field - skip rather than email garbage @@ -394,7 +437,7 @@ static String quote(String value) { */ static Resolver resolver(EntityIntent entity, Map byName, Map compositionParents, CrossModelLookup crossModel) { - return new Resolver(entity, null, byName, compositionParents, crossModel); + return new Resolver(entity, null, null, byName, compositionParents, crossModel); } /** Resolves values/text against the event entity, accumulating the relation loads they require. */ @@ -402,6 +445,7 @@ static final class Resolver { private final EntityIntent entity; private final EntityIntent anchor; + private final EntityIntent escalation; private final Map byName; private final Map compositionParents; private final Set settingEntities; @@ -410,10 +454,11 @@ static final class Resolver { private boolean usesRecordUrl; private boolean usesInboxUrl; - Resolver(EntityIntent entity, EntityIntent anchor, Map byName, Map compositionParents, - CrossModelLookup crossModel) { + Resolver(EntityIntent entity, EntityIntent anchor, EntityIntent escalation, Map byName, + Map compositionParents, CrossModelLookup crossModel) { this.entity = entity; this.anchor = anchor; + this.escalation = escalation; this.byName = byName; this.compositionParents = compositionParents; this.settingEntities = IntentEntities.settingEntities(byName.values()); @@ -503,6 +548,15 @@ String access(String path, boolean recordScope) { usesInboxUrl = true; return INBOX_URL_TOKEN; } + if (recordScope && escalation != null && path.startsWith(ESCALATION_LOCAL + ".")) { + // The escalation level this row was placed at, already loaded by the generated job: one + // field of it, never a walk on - the same rule the anchor scope below states. + String field = path.substring(ESCALATION_LOCAL.length() + 1); + if (field.isEmpty() || field.indexOf('.') >= 0 || fieldOf(escalation, field) == null) { + return null; + } + return ESCALATION_LOCAL + "." + IntentNaming.pascalCase(field); + } if (recordScope && anchor != null && path.startsWith(NotifySupport.RECORD_SCOPE + ".")) { // The anchor record of a fan-out, already loaded by the generated code: one field of it, // never a walk on (that would need a second load per message, and the composed value diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EscalateIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EscalateIntent.java new file mode 100644 index 00000000000..df175959090 --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/EscalateIntent.java @@ -0,0 +1,84 @@ +/* + * 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.model; + +/** + * A days-past-due escalation ladder over a schedule's matched row (issue #7276): pick the + * level that applies to how overdue the row is, and let the tick advance through the ladder as the + * document ages. + * + *

+ * The ladder is an ordinary entity of the model - the {@code kind: setting} table a dunning module + * already has ({@code ReminderLevel}: "First reminder" after 3 days, "Second reminder" after 14, + * "Final notice" after 30). {@link #getAfter()} names the integer threshold on it and + * {@link #getSince()} the date on the ROW the threshold is measured from; the level applied to a + * row is the highest whose threshold has been passed, and a row that has passed none is left + * alone this tick rather than mailed at the bottom level. + * + *

+ * An escalation always accompanies a {@code generate}: the chosen level is written onto the + * generated record through {@link #getInto()}, and that record - with the level in its + * {@code generate.unique:} natural key - is what makes each level go out exactly once. + * Without a record there is nothing to tell a level already sent from one still due, which is why a + * {@code notify}-only escalation is refused rather than silently re-sending every tick. + * + *

+ * Inside the accompanying {@code notify}, the chosen level's own fields are reachable as + * {@code {escalation.}} placeholders - the per-level wording ("a friendly reminder" vs + * "final notice before collection") that a flat schedule cannot express. + */ +public class EscalateIntent { + + /** + * The entity holding the levels - a local entity of this model, normally a {@code kind: setting}. + */ + private String ladder; + + /** The integer property of {@link #ladder} holding the days-past-{@code since} threshold. */ + private String after; + + /** The {@code date} property of the queried row the threshold is measured from. */ + private String since; + + /** The property of the {@code generate} target that receives the chosen level. */ + private String into; + + public String getLadder() { + return ladder; + } + + public void setLadder(String ladder) { + this.ladder = ladder; + } + + public String getAfter() { + return after; + } + + public void setAfter(String after) { + this.after = after; + } + + public String getSince() { + return since; + } + + public void setSince(String since) { + this.since = since; + } + + public String getInto() { + return into; + } + + public void setInto(String into) { + this.into = into; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ScheduleIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ScheduleIntent.java index f60028d56bf..0f1665c6d39 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ScheduleIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/ScheduleIntent.java @@ -30,7 +30,15 @@ * reused {@link GeneratesIntent#getFrom()} is the schedule's {@link #entity}; item cloning is out * of scope here (use an on-demand {@code generates} action for document-to-document cloning). * - * Exactly one of {@code notify} / {@code generate} must be set. + * At least one of {@code notify} / {@code generate} must be set, and they may be declared + * together (issue #7276): one tick then both creates the record and mails about it, with the + * {@code generate}'s {@code unique:} natural key gating the send as well - so the history is a + * record of what was actually sent, and the same (document, level) is never mailed twice. A + * combined block without that key is refused: it would re-mail every matched row on every tick. + * + *

+ * {@link #getEscalate()} adds the other half of real dunning - the days-past-due ladder that picks + * WHICH level a row is at, so the tick advances First -> Second -> Final as the document ages. */ public class ScheduleIntent { @@ -50,6 +58,12 @@ public class ScheduleIntent { private NotificationIntent notify; private GeneratesIntent generate; + /** + * Optional days-past-due escalation ladder (issue #7276). Requires {@link #generate} - the created + * record, keyed on the chosen level, is what makes a level go out once. + */ + private EscalateIntent escalate; + public String getName() { return name; } @@ -105,4 +119,12 @@ public GeneratesIntent getGenerate() { public void setGenerate(GeneratesIntent generate) { this.generate = generate; } + + public EscalateIntent getEscalate() { + return escalate; + } + + public void setEscalate(EscalateIntent escalate) { + this.escalate = escalate; + } } 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 f3d8978db87..d9e72b3eb5e 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 @@ -37,6 +37,7 @@ import org.eclipse.dirigible.components.intent.generator.CheckSupport; import org.eclipse.dirigible.components.intent.generator.ResolvePathSupport; import org.eclipse.dirigible.components.intent.generator.ProcessWaitSupport; +import org.eclipse.dirigible.components.intent.generator.IntentNaming; import org.eclipse.dirigible.components.intent.generator.ScheduleSupport; import org.eclipse.dirigible.components.intent.generator.StatementSupport; import org.eclipse.dirigible.components.intent.generator.StepEventSupport; @@ -51,6 +52,7 @@ import org.eclipse.dirigible.components.intent.model.PostIntent; import org.eclipse.dirigible.components.intent.model.PostingIntent; import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.EscalateIntent; import org.eclipse.dirigible.components.intent.model.FieldIntent; import org.eclipse.dirigible.components.intent.model.FormIntent; import org.eclipse.dirigible.components.intent.model.GeneratesIntent; @@ -241,6 +243,9 @@ public final class IntentParser { /** The {@code {record.}} placeholders of a subject / body. */ private static final java.util.regex.Pattern RECORD_PLACEHOLDER = java.util.regex.Pattern.compile("\\{(" + RECORD_SCOPE + "\\.[A-Za-z0-9_.]*)\\}"); + /** The {@code {escalation.}} placeholders of a subject / body (issue #7276). */ + private static final java.util.regex.Pattern ESCALATION_PLACEHOLDER = + java.util.regex.Pattern.compile("\\{(" + NotificationSupport.ESCALATION_LOCAL + "\\.[A-Za-z0-9_.]*)\\}"); /** A {@code {path}} placeholder of a notify subject / body - a field or a one-hop path. */ private static final java.util.regex.Pattern NOTIFY_PLACEHOLDER = java.util.regex.Pattern.compile("\\{([A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)?)\\}"); @@ -937,14 +942,15 @@ private static void validateSchedules(IntentModel model, Set entityNames validateScheduleMoment(condition, source, "schedule [" + name + "]", issues); validateWhereStatusValue(condition, source, "schedule [" + name + "]", issues); } - // A schedule performs exactly one per-row action: notify (mail) or generate (create-from). + // A schedule performs at least one per-row action: notify (mail), generate (create-from), or + // BOTH (issue #7276) - one tick that records what it sent, which is what a reminder history + // needs to be a record of the automated sends and not only of manual clicks. boolean hasNotify = schedule.getNotify() != null; boolean hasGenerate = schedule.getGenerate() != null; - if (hasNotify && hasGenerate) { - issues.add("schedule [" + name + "] has both notify and generate - a schedule performs exactly one per-row action"); - } else if (!hasNotify && !hasGenerate) { + if (!hasNotify && !hasGenerate) { issues.add("schedule [" + name + "] has no action (add a notify or a generate)"); - } else if (hasNotify) { + } + if (hasNotify) { if (crossModelSource) { // The source's own properties are the OWNER's, resolved at GENERATION time against // its .model (dirigible #7030) - the same split validation the where / map / generate @@ -955,12 +961,199 @@ private static void validateSchedules(IntentModel model, Set entityNames } else { validateNotifyBlock(schedule.getNotify(), "schedule [" + name + "] notify", schedule.getEntity(), model, false, issues); } - } else { + } + if (hasGenerate) { validateScheduleGenerate(schedule, source, byName, entityNames, usesAliases, issues); } + if (hasNotify && hasGenerate && !schedule.getGenerate() + .hasUnique()) { + // The natural key is ADVISORY for a generate-only schedule (every intent authored before + // it keeps generating what it did), but a combined tick has no such history: without it + // the same row is mailed again on every single tick, forever, and the record written + // beside each send says it was a new one. That is the failure the combined form exists to + // remove, so it is refused rather than advised. + issues.add("schedule [" + name + "] declares both notify and generate but no generate unique: natural key" + + " - the key is what makes one (row, level) send once; without it every tick re-mails every matched [" + + schedule.getEntity() + "] and writes another record beside it"); + } + validateScheduleEscalate(schedule, source, byName, crossModelSource, issues); + validateEscalationPlaceholders(schedule, byName, issues); + } + } + + /** + * The {@code {escalation.}} placeholders of a schedule's message (issue #7276) - the + * per-level wording the ladder exists to make possible. + * + *

+ * An unresolvable placeholder degrades to its own literal text at generation, which for a dunning + * mail means the customer is sent the characters {@code {escalation.Name}} where the level's name + * should be. That is the silent failure this parser refuses everywhere else, so both ways of + * getting there - no ladder at all, and a field the ladder does not declare - are errors here. + */ + private static void validateEscalationPlaceholders(ScheduleIntent schedule, Map byName, List issues) { + NotificationIntent notify = schedule.getNotify(); + if (notify == null) { + return; + } + List paths = new ArrayList<>(); + collectEscalationScopedPaths(notify.getSubject(), paths); + collectEscalationScopedPaths(notify.getBody(), paths); + if (paths.isEmpty()) { + return; + } + String subject = "schedule [" + schedule.getName() + "] notify"; + EscalateIntent escalate = schedule.getEscalate(); + if (escalate == null) { + issues.add(subject + " uses the " + NotificationSupport.ESCALATION_LOCAL + ". scope in [{" + paths.get(0) + + "}] but the schedule declares no escalate: ladder - there is no level to read"); + return; + } + EntityIntent ladder = escalate.getLadder() == null ? null : byName.get(escalate.getLadder()); + if (ladder == null) { + return; // the ladder itself is already reported + } + for (String path : paths) { + String field = path.substring(NotificationSupport.ESCALATION_LOCAL.length() + 1); + if (field.isEmpty() || field.indexOf('.') >= 0 || fieldByName(ladder, field) == null) { + issues.add(subject + " placeholder [{" + path + "}] is not a field of the escalate ladder [" + ladder.getName() + + "] - one field of the level, never a walk on"); + } + } + } + + /** The {@code {escalation.}} placeholder paths of a subject / body. */ + private static void collectEscalationScopedPaths(String text, List paths) { + if (text == null || text.isEmpty()) { + return; + } + java.util.regex.Matcher matcher = ESCALATION_PLACEHOLDER.matcher(text); + while (matcher.find()) { + paths.add(matcher.group(1)); + } + } + + /** + * The days-past-due escalation ladder of a schedule (issue #7276): a row is placed at the HIGHEST + * level whose {@code after} threshold it has passed, that level lands on the generated record + * through {@code into}, and the record's natural key is what sends each level once. + * + *

+ * The rules are the ones that make the ladder mean what it reads as. It needs a {@code generate}: + * without a record there is nothing that distinguishes a level already sent from one still due, so + * a {@code notify}-only escalation would re-send its top level on every tick - the exact behaviour + * the ladder is there to replace. It needs a LOCAL source, because the days are counted off a date + * of the queried row and a cross-model row's properties are the owner's. And {@code into} must be + * part of the {@code unique:} key: a key without the level identifies the FIRST reminder of a + * document and then skips it forever, so the second and final notices are seeded but never sent - + * which is precisely the symptom reported. + */ + private static void validateScheduleEscalate(ScheduleIntent schedule, EntityIntent source, Map byName, + boolean crossModelSource, List issues) { + EscalateIntent escalate = schedule.getEscalate(); + if (escalate == null) { + return; + } + String subject = "schedule [" + schedule.getName() + "] escalate"; + GeneratesIntent g = schedule.getGenerate(); + if (g == null) { + issues.add(subject + " has no generate - an escalation records the level it applied on the generated record," + + " which is what sends each level once; add a generate with a unique: key naming the into: property"); + return; + } + if (crossModelSource) { + issues.add(subject + " counts days off [" + escalate.getSince() + "] of the cross-model source [" + schedule.getEntity() + + "], whose properties belong to the [" + schedule.getModel() + "] model - keep an escalating schedule" + + " in the model that owns the row it ages"); + return; + } + String ladder = escalate.getLadder(); + EntityIntent ladderEntity = ladder == null ? null : byName.get(ladder); + if (ladderEntity == null) { + issues.add(subject + " ladder [" + ladder + "] is not an entity of this model"); + } + FieldIntent after = ladderEntity == null || escalate.getAfter() == null ? null : fieldByName(ladderEntity, escalate.getAfter()); + if (ladderEntity != null && after == null) { + issues.add(subject + " after [" + escalate.getAfter() + "] is not a field of the ladder [" + ladder + "]"); + } else if (after != null && !"integer".equals(after.getType())) { + issues.add(subject + " after [" + escalate.getAfter() + "] is a [" + after.getType() + + "] field - the threshold is a whole number of days, so it must be an integer"); + } + FieldIntent since = source == null || escalate.getSince() == null ? null : fieldByName(source, escalate.getSince()); + if (source != null && since == null) { + issues.add(subject + " since [" + escalate.getSince() + "] is not a field of the queried entity [" + schedule.getEntity() + + "] - the days are counted off a date of the row"); + } else if (since != null && !"date".equals(since.getType())) { + issues.add(subject + " since [" + escalate.getSince() + "] is a [" + since.getType() + + "] field - the ladder counts whole days, so it is measured from a date"); + } + String into = escalate.getInto(); + if (into == null || into.isBlank()) { + issues.add(subject + " has no into - name the property of [" + g.getTo() + "] the chosen level is written to"); + return; + } + boolean crossModelTarget = g.getUses() != null && !g.getUses() + .isBlank(); + EntityIntent target = crossModelTarget || g.getTo() == null ? null : byName.get(g.getTo()); + if (crossModelTarget) { + issues.add(subject + " writes into [" + into + "] of the cross-model target [" + g.getTo() + + "] - whether that property points at this model's ladder is known only to the [" + g.getUses() + + "] model, so keep the history entity local"); + return; + } + if (target != null) { + RelationIntent relation = toOneRelationNamed(target, into); + if (relation == null) { + issues.add(subject + " into [" + into + "] is not a to-one relation of [" + g.getTo() + "]"); + } else if (ladder != null && !ladder.equals(relation.getTo())) { + issues.add(subject + " into [" + into + "] points at [" + relation.getTo() + "], not at the ladder [" + ladder + "]"); + } + } + if (assignsProperty(g, into)) { + issues.add(subject + " into [" + into + "] is also assigned by the generate's map or defaults" + + " - the escalation is what picks the level, so remove the other assignment"); + } + if (!uniqueNames(g, into)) { + issues.add(subject + " into [" + into + "] is not part of the generate unique: key - without the level in the key" + + " the first reminder of a row is what the guard finds forever, so no row is ever escalated;" + + " key on the row's back-reference AND [" + into + "]"); } } + /** Whether a generate block's {@code map} or {@code defaults} already writes the named property. */ + private static boolean assignsProperty(GeneratesIntent g, String property) { + String target = IntentNaming.pascalCase(property); + return namesProperty(g.getMap(), target) || namesProperty(g.getDefaults(), target); + } + + /** Whether a map's keys, read as target properties, include the given PascalCase property. */ + private static boolean namesProperty(Map assignments, String target) { + if (assignments == null) { + return false; + } + for (String key : assignments.keySet()) { + if (key != null && target.equals(IntentNaming.pascalCase(key))) { + return true; + } + } + return false; + } + + /** Whether the generate's {@code unique:} natural key names the given target property. */ + private static boolean uniqueNames(GeneratesIntent g, String property) { + if (!g.hasUnique()) { + return false; + } + for (UniqueKeyIntent entry : g.getUnique()) { + if (entry != null && !entry.isRun() && entry.getProperty() != null && IntentNaming.pascalCase(property) + .equals(IntentNaming.pascalCase( + entry.getProperty()))) { + return true; + } + } + return false; + } + /** * The extra rules a {@code notify} carries when the schedule's SOURCE lives in another model * ({@code model: }, dirigible #7030). Everything about the source row - its properties, @@ -1109,7 +1302,12 @@ private static void validateScheduleGenerate(ScheduleIntent schedule, EntityInte issues.add("schedule [" + name + "] generate declares items - item cloning is not supported for a scheduled generation;" + " use an on-demand generates action for document-to-document cloning"); } - validateScheduleGenerateUnique(name, g, crossModel, byName, issues); + // The escalation writes its chosen level onto the target too (issue #7276), so the natural key + // may - and must - name it, although no map / defaults entry does. + validateScheduleGenerateUnique(name, g, crossModel, byName, schedule.getEscalate() == null ? null + : schedule.getEscalate() + .getInto(), + issues); if (g.getChildren() != null) { validateGenerateChildren(name, g.getChildren(), 1, source, entityNames, usesAliases, issues); } @@ -1140,7 +1338,7 @@ private static void validateScheduleGenerate(ScheduleIntent schedule, EntityInte * the first matching row would generate and every other row be skipped as if it had already run. */ private static void validateScheduleGenerateUnique(String name, GeneratesIntent g, boolean crossModel, Map byName, - List issues) { + String escalatedInto, List issues) { if (!g.hasUnique()) { return; } @@ -1159,6 +1357,9 @@ private static void validateScheduleGenerateUnique(String name, GeneratesIntent assigned.add(key.toLowerCase(Locale.ROOT)); } } + if (escalatedInto != null && !escalatedInto.isBlank()) { + assigned.add(escalatedInto.toLowerCase(Locale.ROOT)); + } int properties = 0; boolean run = false; for (UniqueKeyIntent entry : g.getUnique()) { 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 6ca8b722174..46b23a5b5f3 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 @@ -2717,6 +2717,7 @@ Where the block can sit - the three places an intent acts, plus the standalone ` | `serviceTask` `args.notify` | the process's trigger record | the flow reaches that step ("after Issue, mail it") | | `transitions[].notify` | the transitioned record | AFTER the status flip commits ("on Void, tell the customer") | | `schedules[].notify` | each matched row | on every cron tick, per row (dunning runs) | +| `schedules[].notify` + `generate` | each matched row, once per natural key | a tick that mails AND records what it sent | | `notifications[]` | the event record | on the entity's create / update / delete | **Rules:** `attach` is `print` (the record the block is about - inside a fan-out, the ROW) or @@ -2943,7 +2944,9 @@ processes: ### schedules - run on a cron and notify or generate records **Use when:** something must run **on a schedule** (cron), find records matching conditions, and, per -matching row, perform **exactly one** per-row action: `notify` (email) or `generate` (create a record). +matching row, perform a per-row action: `notify` (email), `generate` (create a record), or **both** - +one tick that mails AND records what it sent (dunning). An `escalate:` ladder additionally picks +WHICH level the row is at from how overdue it is. **notify** - e.g. "every morning, email members with overdue loans": @@ -3129,6 +3132,75 @@ schedules: dayField: day ``` +**notify AND generate together - a tick that records what it sent.** A `notify` mails but leaves no +trace, so a reminder history fed only by it fills from manual clicks and never from the automated +sends. Declare both: per matched row the target record is created first and the mail goes out after +it, in the same fail-soft try, and the `generate.unique:` key gates **both** - a row whose record +already exists is skipped entirely, mail included. `unique:` is therefore **required** on a combined +schedule (refused without it): without a key the tick re-mails every matched row every time it fires +and writes another record beside each send. + +**`escalate:` - pick the level from a days-past-due ladder.** Real dunning is not one wording repeated +weekly: it is First reminder -> Second reminder -> Final notice as the document ages, each sent once. +The ladder is an ordinary entity of the model (the `function: Setting` table the module already has), +the threshold an integer column on it, and the date the days are counted from a `date` field of the +queried row: + +```yaml +entities: + - name: ReminderLevel # the ladder, seeded First(3) / Second(14) / Final(30) + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - { name: daysAfterDue, type: integer } + - { name: wording, type: string, length: 500 } + - name: PaymentReminder # the HISTORY - what was actually sent, and at which level + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: sentOn, type: date } + relations: + - { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true } + - { name: Level, kind: manyToOne, to: ReminderLevel } + +schedules: + - name: overdue-invoice-reminders + cron: "0 0 8 * * MON" # every Monday at 08:00 + entity: SalesInvoice + where: + - { field: Status, op: eq, value: OVERDUE } + - { field: dueOn, op: lt, value: CURRENT_DATE } + escalate: + ladder: ReminderLevel # the levels + after: daysAfterDue # the integer threshold on the ladder + since: dueOn # the row's date the days are counted from + into: Level # where the chosen level is written on the target + generate: + to: PaymentReminder + unique: [SalesInvoice, Level] # (document, level) - each level goes out ONCE + map: { SalesInvoice: id } + defaults: { sentOn: now } + notify: + to: contactEmail + subject: "Invoice {number} - {escalation.name}" + body: "{escalation.wording}" # the level's own text - per-level wording + attach: print +``` + +- The level applied is the **highest** whose `after` threshold the row has passed + (`today - since >= after`). A row that has passed **none** is left for a later tick - not mailed at + the bottom rung - and the tick logs how many those were. +- `escalate` requires a `generate`, and `into` must be a term of its `unique:` key. That is what makes + each level go out once: keyed on the document alone, the guard finds the FIRST reminder forever and + the seeded second and final notices are never applied. Both are refused at parse. +- `{escalation.}` reads **one field of the chosen level** in the subject and body - the + per-level wording. A field the ladder does not declare is an authoring error, not a placeholder that + mails its own braces to the customer. +- `into` may not also be assigned by `map` / `defaults` (the escalation is what picks it), and an + escalating schedule must have a **local** source and a **local** generate target: the days are + counted off the row's own date, and whether the target property points at this model's ladder is + knowable only here. + **Cross-model source (`model:`).** By default the `entity` is a **local** entity of this model. When the module that owns the CREATED rows is not where the source entity lives, add `model: ` to read the source from another model - so the schedule can live with the consumer (the module it @@ -3859,6 +3931,8 @@ or a seeded name. - "send the invoice / payslip / document itself to its customer or employee by e-mail" -> a **notify block with `attach: print`** (on a `serviceTask` step, a `transitions[]`, or a `schedules[]`) - "mail each customer their statement / activity list for the period" -> a **notify block with `attach: { report, bind }`** over a report whose `parameters:` scope it to the recipient (a `schedules[]` for the periodic run, a `transitions[]` for on demand) - "every day/hour, check X and notify" -> **schedules** (`notify`) +- "dunning / payment reminders that escalate and are recorded" -> **schedules** with `notify` AND + `generate` plus an `escalate:` ladder (the record is what sends each level once) - "show whether the invoice / payslip / reminder actually went out, and react when it did not" -> **`outcome:` on the notify block** plus, for the reaction, `event: { onNotifyFailed: }` on a `notifications:` / `integrations:` / `outbound:` entry or a process `trigger:`; the retry is an ordinary `transitions[]` button from the failure status carrying the same notify block - "on a schedule / every month, create a Y for each X / recurring invoices / auto-generate timesheets" -> **schedules** (`generate`) - "post / notify / create from a value a listener computes AFTER the record is inserted (a moving-average cost, a snapshot column, an external lookup)" -> declare a **`phases:`** entry on the entity and bind **`event: { onPhase: , phase: }`** - never `onCreate`, which races the listener diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueScheduleEscalationTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueScheduleEscalationTest.java new file mode 100644 index 00000000000..8374412a436 --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueScheduleEscalationTest.java @@ -0,0 +1,215 @@ +/* + * 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.model.IntentModel; +import org.eclipse.dirigible.components.intent.parser.IntentParser; +import org.junit.jupiter.api.Test; + +/** + * The glue a dunning schedule emits (issue #7276): a tick that BOTH writes a + * {@code PaymentReminder} and mails it, and picks the level from a days-past-due ladder. + * + *

+ * The two halves are one entry, not two jobs: the mail is what the generate's natural key gates, so + * the same (invoice, level) is never sent twice, and the record beside it is what makes the + * reminder history a record of what actually went out rather than only of the manual clicks. + */ +class GlueScheduleEscalationTest { + + private static final String DUNNING = """ + name: billing + entities: + - name: ReminderLevel + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - { name: daysAfterDue, type: integer } + - { name: wording, type: string } + - name: SalesInvoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, documentTitle: true } + - { name: dueOn, type: date } + - { name: contactEmail, type: string } + - name: PaymentReminder + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: sentOn, type: date } + relations: + - { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true } + - { name: Level, kind: manyToOne, to: ReminderLevel } + schedules: + - name: overdue-invoice-reminders + cron: "0 0 8 * * MON" + entity: SalesInvoice + where: + - { field: dueOn, op: lt, value: CURRENT_DATE } + escalate: + ladder: ReminderLevel + after: daysAfterDue + since: dueOn + into: Level + generate: + to: PaymentReminder + unique: [SalesInvoice, Level] + map: { SalesInvoice: id } + defaults: { sentOn: now } + notify: + to: contactEmail + subject: "Invoice {number} - {escalation.name}" + body: "{escalation.wording}" + """; + + @SuppressWarnings("unchecked") + @Test + void aDunningTickBothGeneratesAndNotifies() { + Map schedule = dunning(); + + assertEquals(Boolean.TRUE, schedule.get("generates")); + assertEquals(Boolean.TRUE, schedule.get("notifies")); + assertEquals("PaymentReminder", schedule.get("genToEntity")); + // The mail plan is kept alongside the create-from, so the two run in one pass over the row. + assertEquals("entity.ContactEmail", schedule.get("toExpression")); + assertEquals(Boolean.TRUE, schedule.get("hasGenUnique")); + List> unique = (List>) schedule.get("genUnique"); + assertTrue(unique.stream() + .anyMatch(u -> "Level".equals(u.get("property")) && "escalation.Id".equals(u.get("expr"))), + "the level belongs in the key that makes each level send once: " + unique); + } + + @SuppressWarnings("unchecked") + @Test + void theChosenLevelIsWrittenOntoTheGeneratedRecord() { + List> assignments = (List>) dunning().get("genFieldAssignments"); + + assertTrue(assignments.contains(Map.of("targetProp", "Level", "expr", "escalation.Id")), "assignments: " + assignments); + // ...alongside what the author mapped - the escalation adds a column, it does not replace them. + assertTrue(assignments.contains(Map.of("targetProp", "SalesInvoice", "expr", "entity.Id")), "assignments: " + assignments); + } + + @Test + void theLadderFactsReachTheTemplate() { + Map schedule = dunning(); + + assertEquals(Boolean.TRUE, schedule.get("hasEscalation")); + assertEquals("ReminderLevel", schedule.get("escalationEntity")); + assertEquals("DaysAfterDue", schedule.get("escalationAfterProperty")); + assertEquals("DueOn", schedule.get("escalationSinceProperty")); + assertEquals("Level", schedule.get("escalationIntoProperty")); + assertEquals("Id", schedule.get("escalationKeyProperty")); + // The local the job holds the level in IS the scope the placeholders render against. + assertEquals("escalation", schedule.get("escalationLocal")); + } + + @Test + void theMessageReadsTheLevelItIsAt() { + Map schedule = dunning(); + + assertTrue(String.valueOf(schedule.get("subjectExpression")) + .contains("escalation.Name"), + "subject: " + schedule.get("subjectExpression")); + assertTrue(String.valueOf(schedule.get("bodyExpression")) + .contains("escalation.Wording"), + "body: " + schedule.get("bodyExpression")); + } + + @Test + void aPlainNotifyScheduleIsUnchanged() { + // The combined form is additive: a schedule with one action keeps the flags of that action + // alone, so nothing authored before this renders differently. + String yaml = DUNNING.replaceAll("(?s)\n {4}escalate:.*?\n {4}notify:", "\n notify:") + .replace("Invoice {number} - {escalation.name}", "Invoice {number} is overdue") + .replace("{escalation.wording}", "Please settle the attached invoice."); + + Map schedule = GlueIntentGenerator.buildSchedulesForTest(IntentParser.parse(yaml)) + .get(0); + + assertEquals(Boolean.FALSE, schedule.get("generates")); + assertEquals(Boolean.TRUE, schedule.get("notifies")); + assertEquals("notify", schedule.get("action")); + assertEquals(Boolean.FALSE, schedule.get("hasEscalation")); + } + + @SuppressWarnings("unchecked") + @Test + void aRelationBothHalvesReachIsLoadedOnce() { + // The recipient hops through Customer and the generate maps a field off the same relation: the + // generated loop declares each local ONCE, so a duplicated load would not compile. + String yaml = """ + name: billing + entities: + - name: ReminderLevel + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - { name: daysAfterDue, type: integer } + - name: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: email, type: string } + - name: SalesInvoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: dueOn, type: date } + relations: + - { name: Customer, kind: manyToOne, to: Customer } + - name: PaymentReminder + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: sentTo, type: string } + relations: + - { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true } + - { name: Level, kind: manyToOne, to: ReminderLevel } + schedules: + - name: overdue-invoice-reminders + cron: "0 0 8 * * MON" + entity: SalesInvoice + where: + - { field: dueOn, op: lt, value: CURRENT_DATE } + escalate: + ladder: ReminderLevel + after: daysAfterDue + since: dueOn + into: Level + generate: + to: PaymentReminder + unique: [SalesInvoice, Level] + map: { SalesInvoice: id, sentTo: Customer.email } + notify: + to: Customer.email + subject: "Your invoice is overdue" + body: "Please settle it." + """; + + Map schedule = GlueIntentGenerator.buildSchedulesForTest(IntentParser.parse(yaml)) + .get(0); + + List> loads = (List>) schedule.get("relationLoads"); + assertEquals(1, loads.size(), "loads: " + loads); + assertEquals("Customer", loads.get(0) + .get("local")); + } + + private static Map dunning() { + IntentModel model = IntentParser.parse(DUNNING); + List> schedules = GlueIntentGenerator.buildSchedulesForTest(model); + assertEquals(1, schedules.size()); + return schedules.get(0); + } +} diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java index 0e64826e019..adfec32f461 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/IntentParserTest.java @@ -1896,7 +1896,27 @@ void scheduleGenerateParsesWithoutIssues() { } @Test - void scheduleWithBothNotifyAndGenerateIsRejected() { + void scheduleMayBothNotifyAndGenerate() { + // Issue #7276: one tick that BOTH mails and records what it sent - what a reminder history + // needs to reflect the automated sends, not only the manual clicks. + String yaml = SCHEDULE_GEN_HEAD + """ + notify: + to: status + subject: "x" + body: "y" + generate: + to: EmployeeTimesheet + unique: [Employee] + map: + Employee: id + """; + IntentParser.parse(yaml); + } + + @Test + void scheduleThatNotifiesAndGeneratesWithoutUniqueIsRejected() { + // Without the natural key the combined tick re-mails every matched row on every tick and + // writes another record beside each send - the failure the combined form exists to remove. String yaml = SCHEDULE_GEN_HEAD + """ notify: to: status @@ -1910,8 +1930,8 @@ void scheduleWithBothNotifyAndGenerateIsRejected() { IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); assertTrue(ex.getIssues() .stream() - .anyMatch(i -> i.contains("has both notify and generate")), - "expected a both-actions issue, got: " + ex.getIssues()); + .anyMatch(i -> i.contains("both notify and generate but no generate unique")), + "expected a missing-unique issue, got: " + ex.getIssues()); } @Test diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/ScheduleEscalateIntentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/ScheduleEscalateIntentTest.java new file mode 100644 index 00000000000..8a53ed3ae6d --- /dev/null +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/parser/ScheduleEscalateIntentTest.java @@ -0,0 +1,175 @@ +/* + * 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; + +/** + * The days-past-due escalation ladder of a schedule (issue #7276) - the half of dunning that picks + * WHICH level an overdue document is at, so the weekly tick advances First -> Second -> Final + * as the document ages instead of re-sending one flat wording forever. + * + *

+ * Each refusal here is a way the ladder would read as working and not be: a {@code notify}-only + * escalation has no record to tell a level already sent from one still due; a key without the level + * finds the FIRST reminder of a document forever, so the seeded second and final notices are never + * applied - the exact symptom reported; and an {@code {escalation.}} the ladder does not + * declare renders as its own literal text, which for a dunning mail means those characters reach + * the customer. + */ +class ScheduleEscalateIntentTest { + + /** + * The dunning shape of codbex `sales-invoices`: a level ladder, a reminder history, a weekly run. + */ + private static final String DUNNING = """ + name: billing + entities: + - name: ReminderLevel + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - { name: daysAfterDue, type: integer } + - { name: wording, type: string } + - name: SalesInvoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, documentTitle: true } + - { name: dueOn, type: date } + - { name: contactEmail, type: string } + - name: PaymentReminder + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: sentOn, type: date } + relations: + - { name: SalesInvoice, kind: manyToOne, to: SalesInvoice, composition: true, required: true } + - { name: Level, kind: manyToOne, to: ReminderLevel } + schedules: + - name: overdue-invoice-reminders + cron: "0 0 8 * * MON" + entity: SalesInvoice + where: + - { field: dueOn, op: lt, value: CURRENT_DATE } + escalate: + ladder: ReminderLevel + after: daysAfterDue + since: dueOn + into: Level + generate: + to: PaymentReminder + unique: [SalesInvoice, Level] + map: { SalesInvoice: id } + defaults: { sentOn: now } + notify: + to: contactEmail + subject: "Invoice {number} - {escalation.name}" + body: "{escalation.wording}" + """; + + @Test + void aDunningLadderParses() { + IntentModel model = IntentParser.parse(DUNNING); + + assertEquals(1, model.getSchedules() + .size()); + assertEquals("ReminderLevel", model.getSchedules() + .get(0) + .getEscalate() + .getLadder()); + assertEquals("Level", model.getSchedules() + .get(0) + .getEscalate() + .getInto()); + } + + @Test + void anEscalationWithoutAGenerateIsRejected() { + // Nothing records which level went out, so the top level reached would be re-sent every tick - + // the behaviour the ladder exists to replace. + String yaml = DUNNING.replaceAll("(?s)\n {4}generate:.*?\n {4}notify:", "\n notify:"); + + assertIssue(yaml, "has no generate"); + } + + @Test + void anEscalationWhoseLevelIsNotInTheNaturalKeyIsRejected() { + String yaml = DUNNING.replace("unique: [SalesInvoice, Level]", "unique: [SalesInvoice]"); + + assertIssue(yaml, "is not part of the generate unique: key"); + } + + @Test + void anEscalationOntoAPropertyTheGenerateAlsoAssignsIsRejected() { + String yaml = DUNNING.replace("map: { SalesInvoice: id }", "map: { SalesInvoice: id, Level: id }"); + + assertIssue(yaml, "is also assigned by the generate's map or defaults"); + } + + @Test + void anEscalationLadderThatIsNotAnEntityIsRejected() { + String yaml = DUNNING.replace("ladder: ReminderLevel", "ladder: DunningLevel"); + + assertIssue(yaml, "ladder [DunningLevel] is not an entity of this model"); + } + + @Test + void aNonIntegerThresholdIsRejected() { + String yaml = DUNNING.replace("{ name: daysAfterDue, type: integer }", "{ name: daysAfterDue, type: string }"); + + assertIssue(yaml, "the threshold is a whole number of days"); + } + + @Test + void aNonDateSinceIsRejected() { + // The ladder counts whole days; a string "due date" would not even compile into the subtraction. + String yaml = DUNNING.replace("{ name: dueOn, type: date }", "{ name: dueOn, type: string }"); + + assertIssue(yaml, "the ladder counts whole days"); + } + + @Test + void anIntoThatDoesNotPointAtTheLadderIsRejected() { + String yaml = DUNNING.replace("- { name: Level, kind: manyToOne, to: ReminderLevel }", + "- { name: Level, kind: manyToOne, to: SalesInvoice }"); + + assertIssue(yaml, "points at [SalesInvoice], not at the ladder [ReminderLevel]"); + } + + @Test + void anEscalationPlaceholderOnASchedulesWithoutALadderIsRejected() { + // Without a ladder the placeholder degrades to its own literal text - the characters + // {escalation.name} would be mailed to the customer. + String yaml = DUNNING.replaceAll("(?s)\n {4}escalate:.*?\n {4}generate:", "\n generate:") + .replace("unique: [SalesInvoice, Level]", "unique: [SalesInvoice]"); + + assertIssue(yaml, "declares no escalate: ladder"); + } + + @Test + void anEscalationPlaceholderTheLadderDoesNotDeclareIsRejected() { + String yaml = DUNNING.replace("{escalation.wording}", "{escalation.tone}"); + + assertIssue(yaml, "is not a field of the escalate ladder [ReminderLevel]"); + } + + private static void assertIssue(String yaml, String fragment) { + IntentValidationException ex = assertThrows(IntentValidationException.class, () -> IntentParser.parse(yaml)); + assertTrue(ex.getIssues() + .stream() + .anyMatch(i -> i.contains(fragment)), + "expected an issue containing [" + fragment + "], got: " + ex.getIssues()); + } +} diff --git a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java index 1aa5c902546..3158f1d67ce 100644 --- a/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java +++ b/components/ide/ide-template/src/main/java/org/eclipse/dirigible/components/ide/template/service/model/GlueGenerator.java @@ -398,15 +398,31 @@ private static void bindNotification(Map item, Map item, Map context, Map parameters) { - boolean generates = "generate".equals(str(item, "action")); + // A tick may do BOTH (issue #7276). The flags are read from the descriptor when it carries them + // and fall back to the single `action` a .glue written before the combined form has, so such a + // job renders exactly what it always did. + boolean generates = item.containsKey("generates") ? truthy(item, "generates") : "generate".equals(str(item, "action")); + boolean notifies = item.containsKey("notifies") ? truthy(item, "notifies") : !"generate".equals(str(item, "action")); copy(context, item, "name", "className", "cron", "entity", "perspective", "criteriaExpression", "toExpression", "subjectExpression", "bodyExpression", "attachKeyProperty", "attach", "attachEntity", "attachLanguageExpression", "attachLanguageFkProperty", "attachLanguageTargetEntity", "attachFileNameExpression", "attachReport", "genToEntity", "genToPk", "genFieldAssignments", // The scheduled generation's natural key (issue #7070). Absent on a .glue written // before it existed, which `copy` turns into an absent context key - so the guard's // `#if` is false and such a job renders byte-identically to what it always did. - "hasGenUnique", "genUnique"); + "hasGenUnique", "genUnique", + // The days-past-due escalation ladder (issue #7276), likewise absent on an older .glue. + "hasEscalation", "escalationEntity", "escalationLocal", "escalationKeyProperty", "escalationAfterProperty", + "escalationSinceProperty", "escalationIntoProperty"); + context.put("generates", generates); + context.put("notifies", notifies); context.put("javaPerspective", sanitize(item, "perspective")); + // The ladder is a LOCAL entity (the parser refuses a cross-model source and target for an + // escalation), so its generated classes live in this project's own gen folder. + String escalationPackage = "gen." + str(parameters, "javaGenFolderName") + ".data." + sanitize(item, "escalationPerspective") + "."; + context.put("escalationEntityClass", + truthy(item, "hasEscalation") ? escalationPackage + str(item, "escalationEntity") + "Entity" : ""); + context.put("escalationRepositoryClass", + truthy(item, "hasEscalation") ? escalationPackage + str(item, "escalationEntity") + "Repository" : ""); // The source's generation folder is the owner model's when the source is cross-model, else // this project's - always supplied, so a local source stays unchanged. context.put("sourceGenFolder", diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Job.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Job.java.template index ba66917b789..356fa02abf2 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Job.java.template +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Job.java.template @@ -1,14 +1,14 @@ package gen.events.${javaGenFolderName}; import java.util.List; -#if($action != "generate") +#if($notifies) import java.util.HashMap; import java.util.Map; #end import org.eclipse.dirigible.components.data.store.java.repository.Criteria; import org.eclipse.dirigible.sdk.component.Component; -#if($action != "generate") +#if($notifies) import org.eclipse.dirigible.sdk.core.Configurations; import org.eclipse.dirigible.sdk.mail.Mail; #end @@ -28,9 +28,9 @@ import gen.${attachLanguageJavaGenFolder}.data.${attachLanguageJavaTargetPerspec #end /** - * Runs the ${name} schedule: on each cron tick, query ${entity} and, per matching row, either notify - * (mail) or generate a target record (create-from, saved through the target's generated repository so - * its numbering / status init / calculated fields fire). + * Runs the ${name} schedule: on each cron tick, query ${entity} and, per matching row, notify (mail), + * generate a target record (create-from, saved through the target's generated repository so its + * numbering / status init / calculated fields fire), or BOTH - a tick that records what it sent. * * Generated from the intent schedules block - do not edit; it is re-generated with the application. * Entity access goes ONLY through the generated repositories. The mail sender comes from the @@ -50,37 +50,82 @@ public class ${className}Job implements JobHandler { @Override public void run() { List<${entity}Entity> rows = new ${entity}Repository().findAll(${criteriaExpression}); -#if($action != "generate") + int failed = 0; +#if($notifies) int sent = 0; int skipped = 0; - int failed = 0; -#else +#end +#if($generates) int created = 0; - int failed = 0; #if($hasGenUnique) int existed = 0; #end +#end +#if($hasEscalation) + int notDue = 0; #end for (${entity}Entity entity : rows) { -#if($action == "generate") - // ONE TRANSACTION PER SOURCE ROW, and the WHOLE row is FAIL-SOFT. The header, its children and - // their grandchildren were each written in a transaction of their own, so a tick that died - // halfway left a childless header behind - and with the `unique:` guard below finding exactly - // that header, every later run reported "already existed" and the row stayed incomplete - // forever (#7133). A refused row now leaves nothing behind, so the next tick really does - // complete what this one could not; and one failing row no longer aborts the loop, which used - // to leave every later matching row silently ungenerated and the summary line unlogged. - // The try opens HERE, before the row's relation loads and the guard's lookup (#7178): those - // read the database too, and a throw from either - a connection blip, a foreign key at a row a - // concurrent delete removed - aborted the tick exactly as a refused generation did, with the - // rows after it never generated and no line saying so. One bad row costs one `failed`. +#if($notifies) + // Declared outside the try so the row's failure line can still name the recipient; null + // there says the row failed before one was resolved. + String to = null; +#end + // ONE TRY PER SOURCE ROW, and the WHOLE row is FAIL-SOFT. A schedule tick is a batch - a + // dunning run mails every overdue invoice, a monthly run generates a timesheet per employee - + // and one bad row used to abort the loop, so every row after it was silently skipped and the + // next tick started over from the top. Each row is attempted, counted and logged with its own + // key, and the tick reports the totals. + // + // The try opens HERE, before the row's relation loads, the escalation lookup, the guard's + // lookup and the attachment render (#7178, #7233): those read the database too - a foreign + // key at a row a concurrent delete removed, a connection blip, a print template that fails + // for ONE document - and a throw from any of them used to abort the tick exactly as a refused + // generation or an unreachable mailbox did. One bad row costs one `failed`. + // + // A generation is written in ONE transaction per row (header, children and grandchildren + // together): each used to commit on its own, so a tick that died halfway left a childless + // header behind - and with the `unique:` guard finding exactly that header, every later run + // reported "already existed" and the row stayed incomplete forever (#7133). A refused row now + // leaves nothing behind, so the next tick really does complete what this one could not. try { #foreach($load in $relationLoads) - // A one-hop `relation.field` map source: the queried row's foreign key loads the related row, - // once per row, and the mapping below reads a field off it. Null-guarded - a nullable relation - // is a legitimate empty value, not a reason to skip the row. + // A one-hop `relation.field` source: the queried row's foreign key loads the related row, + // once per row, and the recipient / placeholder / mapping below reads a field off it. + // Null-guarded - a nullable relation is a legitimate empty value, not a reason to skip. ${load.targetEntity}Entity ${load.local} = entity.${load.fkProperty} == null ? null : new ${load.targetEntity}Repository().findById(entity.${load.fkProperty}); #end +#if($hasEscalation) + // ESCALATION LADDER (intent `escalate:` - issue #7276). How overdue this row is decides + // WHICH level applies: the highest ${escalationEntity} whose ${escalationAfterProperty} + // threshold the row has passed, counted in whole days from its ${escalationSinceProperty}. + // A row that has passed none is left alone this tick rather than mailed at the bottom + // level - "not due yet" is a real answer, and it is counted so the log can say so. + // The ladder is a settings table (three rows for a dunning ladder), so it is read whole + // and the maximum picked here rather than ordered in the database. + // The pick is made into a mutable candidate and then FROZEN into the local the rest of + // the row reads: the generation below runs inside a lambda, which may only close over an + // effectively final variable - a level assigned in the loop is not one. + ${escalationEntityClass} escalationCandidate = null; + if (entity.${escalationSinceProperty} != null) { + long overdueDays = java.time.temporal.ChronoUnit.DAYS.between(entity.${escalationSinceProperty}, + java.time.LocalDate.now()); + for (${escalationEntityClass} level : new ${escalationRepositoryClass}().findAll(Criteria.create())) { + if (level.${escalationAfterProperty} == null || level.${escalationAfterProperty} > overdueDays) { + continue; + } + if (escalationCandidate == null + || level.${escalationAfterProperty} > escalationCandidate.${escalationAfterProperty}) { + escalationCandidate = level; + } + } + } + if (escalationCandidate == null) { + notDue++; + continue; + } + final ${escalationEntityClass} ${escalationLocal} = escalationCandidate; +#end +#if($generates) #if($hasGenUnique) // IDEMPOTENCY (intent `generate.unique:` - issues #7070, #7106). A tick used to create // unconditionally, so running this job twice - a redeploy, a Quartz misfire recovery, an @@ -92,6 +137,10 @@ public class ${className}Job implements JobHandler { // ("one bill per template per month") is still keyed. Best-effort against two concurrent // ticks, exactly as the event-driven create-from's guard is - a UNIQUE database key on the // same columns is the durable backstop. +#if($notifies) + // It gates the MAIL too (#7276): the send happens only where a record was written, so the + // same (row, level) is never mailed twice and the history is a record of what went out. +#end #set($genUniqueValueTerms = 0) #foreach($u in $genUnique) #if($u.kind != "range") @@ -205,27 +254,8 @@ public class ${className}Job implements JobHandler { #end }); created++; - } catch (Exception ex) { - failed++; - LOG.error("Schedule ${name}: could not generate ${genToEntity} from ${entity} [{}]", entity.${attachKeyProperty}, ex); - } -#else - // FAIL-SOFT PER ROW. A schedule tick is a batch - a dunning run mails every overdue - // invoice - and one unreachable mailbox used to abort the whole loop, so every row after it - // was silently never mailed and the next tick started over from the top. Each row is now - // attempted, counted and logged with its own key, and the tick reports the totals. - // The try opens HERE, before the row's relation loads and the attachment render (#7233): - // those read the database too - a foreign key at a row a concurrent delete removed, a - // connection blip, a print template that fails to render for ONE document - and a throw - // from any of them left the loop exactly as an unreachable mailbox once did, with the rows - // after it never mailed and no summary line saying so. One bad row costs one `failed`. The - // recipient is declared outside the try so the row's failure line can still name it; null - // there says the row failed before one was resolved. - String to = null; - try { -#foreach($load in $relationLoads) - ${load.targetEntity}Entity ${load.local} = entity.${load.fkProperty} == null ? null : new ${load.targetEntity}Repository().findById(entity.${load.fkProperty}); #end +#if($notifies) #if($usesRecordUrl == "true") // {recordUrl} - the deep link to the row this message is about. The ROUTE is composed HERE, // in the layer that knows the generated application's URL layout; the intent layer @@ -300,20 +330,31 @@ public class ${className}Job implements JobHandler { sent++; #if($notifyOutcomeProperty != "") stampNotifyOutcome(entity.${notifyOutcomeKeyProperty}, null); +#end #end } catch (Exception ex) { failed++; +#if($generates && $notifies) + LOG.error("Schedule ${name}: could not generate ${genToEntity} from ${entity} [{}] and mail it to [{}]", + entity.${attachKeyProperty}, to, ex); +#elseif($generates) + LOG.error("Schedule ${name}: could not generate ${genToEntity} from ${entity} [{}]", entity.${attachKeyProperty}, ex); +#else LOG.error("Schedule ${name}: could not mail ${entity} [{}] at [{}]", entity.${attachKeyProperty}, to, ex); +#end #if($notifyOutcomeProperty != "") stampNotifyOutcome(entity.${notifyOutcomeKeyProperty}, ex); #end } -#end } -#if($action != "generate") - LOG.info("Schedule ${name}: mailed [{}] of [{}] matching ${entity} row(s), no recipient [{}], failed [{}]", sent, rows.size(), - skipped, failed); -#else +#if($hasEscalation) + // "not due at any level" is a normal outcome of an escalating tick, not a failure: it is every + // row that has not yet aged past the bottom rung. Counted so "matched 40, created 3" reads as + // the ladder working rather than as a query that nearly missed. + LOG.info("Schedule ${name}: [{}] of [{}] matching ${entity} row(s) had passed no ${escalationEntity} threshold yet", notDue, + rows.size()); +#end +#if($generates) #if($hasGenUnique) // "matched 40, created 0, already existed 40" is the line that says a re-run did nothing - // which is the whole point of the natural key, and is otherwise indistinguishable from a tick @@ -325,6 +366,10 @@ public class ${className}Job implements JobHandler { LOG.info("Schedule ${name}: created [{}] ${genToEntity}(s) from [{}] matching ${entity} row(s), failed [{}]", created, rows.size(), failed); #end +#end +#if($notifies) + LOG.info("Schedule ${name}: mailed [{}] of [{}] matching ${entity} row(s), no recipient [{}], failed [{}]", sent, rows.size(), + skipped, failed); #end } #if($attach == "report") 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 39be1db0773..4ac2457bc14 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 @@ -785,6 +785,8 @@ class IntentEmissionCoverageIT extends IntegrationTest { # is fail-soft, so without this the mail that never left was a log line and nothing # else - and this instance has no SMTP, which is exactly the case being asserted. - { name: sendOutcome, type: string, length: 128, readOnly: true } + # #7276: the date the dunning ladder below counts days from. + - { name: dueOn, type: date } relations: - { name: Person, kind: manyToOne, to: Person } - { name: Status, kind: manyToOne, to: EntryStatus, function: EntityStatus, init: 1 } @@ -807,6 +809,24 @@ class IntentEmissionCoverageIT extends IntegrationTest { - { name: Bill, kind: manyToOne, to: Bill, required: true } - { name: Person, kind: manyToOne, to: Person } + # #7276 dunning: the escalation LADDER (a settings table of levels and their + # days-past-due thresholds) and the HISTORY the scheduled run writes - what was + # actually sent, and at which level. + - name: ReminderLevel + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - { name: daysAfterDue, type: integer } + - { name: wording, type: string, length: 500 } + - name: BillReminder + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: sentOn, type: date } + relations: + - { name: Bill, kind: manyToOne, to: Bill, required: true } + - { name: Level, kind: manyToOne, to: ReminderLevel } + # keyed cross-entity aggregate: a signed ledger summed per (Person, Unit) into a # materialised total row keyed by the same two FKs. Ledger.amount is SENSITIVE and # LedgerTotal is personal-rooted, so the parser must also auto-scrub LedgerTotal.total @@ -1073,6 +1093,30 @@ class IntentEmissionCoverageIT extends IntegrationTest { languageFrom: Person.locale outcome: sendOutcome + # #7276: a tick that BOTH records and mails, escalating by how overdue the bill is. It + # never fires here (the 1st of January at 07:00); it is in this fixture so the combined + # job - the ladder lookup, the level written into the natural key, the guard that gates + # the send - is COMPILED by the publish below, which is the only proof it builds. + - name: bill-dunning + cron: "0 0 7 1 1 *" + entity: Bill + where: + - { field: dueOn, op: lt, value: CURRENT_DATE } + escalate: + ladder: ReminderLevel + after: daysAfterDue + since: dueOn + into: Level + generate: + to: BillReminder + unique: [Bill, Level] + map: { Bill: id } + defaults: { sentOn: now } + notify: + to: Person.email + subject: "Bill {note} - {escalation.name}" + body: "{escalation.wording}" + processes: # assignee: personal - the confirm task lands in exactly the owner's Inbox (the IT # runs as admin, mapped by the Person seed below). @@ -2851,6 +2895,26 @@ private void assertEmission() { assertTrue(dunningTry > 0 && dunningTry < dunningLoad && dunningLoad < dunningRender && dunningRender < dunningCatch, "the row's loads and the attachment render must run inside the fail-soft try: " + dunning); + // #7276 - the escalating dunning tick that records what it sent. The ladder lookup, the level + // written onto the history row AND into the natural key, and the guard that skips the send for + // a level already sent all have to COMPILE against the generated entities: `escalation` is a + // typed local read for a `long` comparison and for an Integer foreign key, and the publish + + // client-Java javac below is the first thing that proves it. + String escalating = contentOf("gen/events/emission/BillDunningJob.java"); + assertTrue( + escalating.contains("ReminderLevelEntity escalationCandidate = null;") + && escalating.contains("ReminderLevelEntity escalation = escalationCandidate;"), + "the chosen level is a typed, effectively final local of the row's try: " + escalating); + assertTrue(escalating.contains("java.time.temporal.ChronoUnit.DAYS.between(entity.DueOn"), + "how overdue the row is decides the level: " + escalating); + assertTrue(escalating.contains("target.Level = escalation.Id;"), "the level is written onto the history row: " + escalating); + assertTrue(escalating.contains(".eq(\"Level\", keyLevel)"), + "the level is part of the key that sends each level once: " + escalating); + int escalatingGuard = escalating.indexOf("BillReminderRepository().findAll(Criteria.create()"); + int escalatingSend = escalating.indexOf("Mail.send("); + assertTrue(escalatingGuard > 0 && escalatingSend > escalatingGuard, + "the natural key gates the send as well as the write: " + escalating); + // month widget: the YYYY-MM field renders the Harmonia month picker on BOTH writable // surfaces - the power form and the personal form (my-shell parity). assertTrue(contentOf("gen/emission/views/Claim/Claim-form.html").contains("x-h-month-picker"), diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java index 2f7b521d2ea..aa572a3dd70 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/IntentEngineIT.java @@ -2478,6 +2478,116 @@ void a_scheduled_notification_keeps_its_row_loads_and_attachment_render_inside_t "the tick's summary still reports the totals"); } + @Test + void a_dunning_schedule_records_what_it_sent_and_escalates_by_days_past_due() { + // Issue #7276. A `schedules[].notify` could mail but not RECORD, and could not tell a document + // three days overdue from one ninety days overdue - so a reminder history filled only from + // manual clicks, and a seeded Second reminder / Final notice was never applied by the system. + // One tick now does both: it picks the level off a days-past-due ladder, writes the history row + // with that level on it, and mails the level's own wording - with the generate's `unique:` key + // gating the SEND too, so each (invoice, level) goes out exactly once. + String yaml = """ + name: billing + entities: + - name: ReminderLevel + function: Setting + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - { name: daysAfterDue, type: integer } + - { name: wording, type: string, length: 500 } + - name: Customer + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: email, type: string } + - name: Invoice + function: Document + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: dueDate, type: date } + relations: + - { name: Customer, kind: manyToOne, to: Customer } + - name: PaymentReminder + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: sentOn, type: date } + relations: + - { name: Invoice, kind: manyToOne, to: Invoice, composition: true, required: true } + - { name: Level, kind: manyToOne, to: ReminderLevel } + schedules: + - name: overdue-dunning + cron: "0 0 8 * * MON" + entity: Invoice + where: + - { field: dueDate, op: lt, value: CURRENT_DATE } + escalate: + ladder: ReminderLevel + after: daysAfterDue + since: dueDate + into: Level + generate: + to: PaymentReminder + unique: [Invoice, Level] + map: { Invoice: id } + defaults: { sentOn: now } + notify: + to: Customer.email + subject: "Invoice {id} - {escalation.name}" + body: "{escalation.wording}" + """; + writeIntent(yaml); + restAssuredExecutor.execute(() -> given().when() + .post(GENERATE_URL) + .then() + .statusCode(200)); + generateFromModel("template-application-events-java/template/template.js", "billing.glue"); + + String job = codeOf("gen/events/billing/OverdueDunningJob.java"); + int loop = job.indexOf("for (InvoiceEntity entity : rows) {"); + int ladder = job.indexOf("ReminderLevelEntity escalationCandidate = null;", loop); + int days = job.indexOf("java.time.temporal.ChronoUnit.DAYS.between(entity.DueDate", loop); + int notDue = job.indexOf("notDue++;", loop); + int guard = job.indexOf("PaymentReminderRepository().findAll(Criteria.create()", loop); + int create = job.indexOf("UnitOfWork.run(", loop); + int send = job.indexOf("Mail.send(", loop); + assertTrue(loop > 0 && ladder > 0 && days > 0 && notDue > 0 && guard > 0 && create > 0 && send > 0, "got: " + job); + // The ladder is read first: which level a row is at decides both what is written and what is + // said, so it cannot be resolved after either. + assertTrue(ladder < days && days < notDue && notDue < guard, "the level is picked before the idempotency guard: " + job); + // The highest threshold the row has PASSED - not the first, and not the bottom rung for a row + // that has passed none: that row is left for a later tick. + assertTrue(job.contains("if (level.DaysAfterDue == null || level.DaysAfterDue > overdueDays) {"), + "a level whose threshold is not reached yet is skipped: " + job); + assertTrue(job.contains("if (escalationCandidate == null"), "the highest passed threshold wins: " + job); + // Frozen into the local the write and the message both read: the generation runs inside a + // lambda, which may only close over an effectively final variable. + assertTrue(job.contains("ReminderLevelEntity escalation = escalationCandidate;"), + "the chosen level is frozen before the lambda closes over it: " + job); + // The chosen level lands on the history row AND in the natural key - the key is what makes the + // same (invoice, level) send once, so a Monday tick that already sent the second reminder does + // not send it again while the invoice ages towards the final notice. + assertTrue(job.contains("target.Level = escalation.Id;"), "the chosen level is written onto the history row: " + job); + assertTrue(job.contains(".eq(\"Level\", keyLevel)"), "the level is part of the guard's natural key: " + job); + // The guard gates the MAIL as well: it `continue`s before the send, in the same try. + int existed = job.indexOf("existed++;", loop); + assertTrue(guard < existed && existed < create && create < send, "an already-sent level skips the send too: " + job); + // Per-level wording: the message reads the level it is at. + assertTrue(job.contains("escalation.Name") && job.contains("escalation.Wording"), + "the message must be able to read the level's own text: " + job); + // One row, one try, one failure count - the combined tick keeps the fail-soft shape of both. + int tryOpens = job.indexOf("try {", loop); + int catches = job.indexOf("} catch (Exception ex) {", loop); + assertTrue(tryOpens < ladder && catches > send, "one try encloses the level lookup, the write and the send: " + job); + assertTrue(job.indexOf("try {", tryOpens + 1) < 0 || job.indexOf("try {", tryOpens + 1) > catches, + "no second try wraps only one half: " + job); + assertTrue(job.contains("could not generate PaymentReminder from Invoice [{}] and mail it to [{}]"), + "a failed row names both halves it could not complete: " + job); + // Both summaries are reported - what was created, what was mailed, and what was not due yet. + assertTrue(job.contains("had passed no ReminderLevel threshold yet"), "the tick reports the rows no level applied to: " + job); + assertTrue(job.contains("created [{}] PaymentReminder(s)") && job.contains("mailed [{}] of [{}] matching Invoice row(s)"), + "the tick reports both halves: " + job); + } + @Test void an_event_notification_keeps_its_relation_loads_and_attachment_render_inside_the_fail_soft_try() { // Issue #7290 (the single-record twin of #7233/#7278): the per-row try above covers a