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