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 e52687634c..7fca32c22b 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 @@ -101,11 +101,13 @@ public void generate(IntentGenerationContext context) { List> postings = buildPostings(model, byName, compositionParents, settings, context); List> printFeeders = PrintFeederSupport.buildPrintFeeders(model, byName, compositionParents, context); List> snapshots = SnapshotSupport.buildSnapshots(model, byName, compositionParents); + List> numbering = NumberingSupport.buildNumbering(model, compositionParents); if (triggers.isEmpty() && resolvers.isEmpty() && fieldLoaders.isEmpty() && timerLoaders.isEmpty() && waits.isEmpty() && aborts.isEmpty() && writers.isEmpty() && setters.isEmpty() && notifications.isEmpty() && schedules.isEmpty() && integrations.isEmpty() && inbound.isEmpty() && rollups.isEmpty() && expansions.isEmpty() && settlements.isEmpty() - && generates.isEmpty() && transitions.isEmpty() && printFeeders.isEmpty() && postings.isEmpty() && snapshots.isEmpty()) { + && generates.isEmpty() && transitions.isEmpty() && printFeeders.isEmpty() && postings.isEmpty() && snapshots.isEmpty() + && numbering.isEmpty()) { // No process glue for this intent - any stale .glue is removed by the post-pass scrub. return; } @@ -131,6 +133,7 @@ public void generate(IntentGenerationContext context) { glue.put("postings", postings); glue.put("printFeeders", printFeeders); glue.put("snapshots", snapshots); + glue.put("numbering", numbering); context.writeModelFile(IntentNaming.baseName(context) + ".glue", JsonHelper.toJson(glue)); LOGGER.debug( "Wrote glue with [{}] trigger(s), [{}] resolver(s), [{}] writer(s), [{}] setter(s)," diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NumberingSupport.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NumberingSupport.java new file mode 100644 index 0000000000..0cde32f71a --- /dev/null +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/NumberingSupport.java @@ -0,0 +1,72 @@ +/* + * 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 java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.LinkedHashMap; + +import org.eclipse.dirigible.components.intent.model.EntityIntent; +import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.IntentModel; +import org.eclipse.dirigible.components.intent.model.NumberIntent; + +/** + * Builds the {@code numbering} glue collection: one descriptor per {@code number: { stampOn: issue + * }} field, driving the generated {@code gen/events/NumberStamp.java} delegate. The + * document is created with a UUID placeholder (the uuid auto-fill); this delegate, wired as a + * {@code delegate:} service task at the issue step, replaces it with the real formatted number - + * idempotently, so a re-issue after an amend keeps the number. + * + *

+ * The number is allocated + formatted via {@code sdk.numbering.DocumentNumbers} (the shared + * per-tenant counter) and written with the targeted {@code updateProperty} (a workflow system + * write). + */ +final class NumberingSupport { + + private NumberingSupport() {} + + /** + * One numbering descriptor per {@code stampOn: issue} number field in the model. + * + * @param model the parsed intent model + * @param compositionParents each entity's transitive composition parent (perspective resolution) + * @return the {@code numbering} collection (possibly empty) + */ + static List> buildNumbering(IntentModel model, Map compositionParents) { + List> numbering = new ArrayList<>(); + for (EntityIntent entity : model.getEntities()) { + for (FieldIntent field : entity.getFields()) { + NumberIntent number = field.getNumber(); + if (number == null || !"issue".equalsIgnoreCase(number.getStampOn())) { + continue; // stampOn:create is handled at insert by the DAO; only issue needs a step + } + List scope = new ArrayList<>(); + if (number.getScope() != null) { + for (String scopeName : number.getScope()) { + scope.add("year".equalsIgnoreCase(scopeName) ? "year" : IntentNaming.pascalCase(scopeName)); + } + } + Map descriptor = new LinkedHashMap<>(); + descriptor.put("entity", entity.getName()); + descriptor.put("perspective", IntentEntities.resolvePerspective(entity.getName(), compositionParents)); + descriptor.put("masterPk", IntentEntities.keyFieldName(entity)); + descriptor.put("field", IntentNaming.pascalCase(field.getName())); + descriptor.put("series", number.getSeries() == null ? entity.getName() : number.getSeries()); + descriptor.put("format", number.getFormat() == null ? "" : number.getFormat()); + descriptor.put("scope", scope); + numbering.add(descriptor); + } + } + return numbering; + } +} diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java index ada15a6e64..5abd425193 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGenerator.java @@ -34,6 +34,7 @@ import org.eclipse.dirigible.components.intent.model.EntityIntent; import org.eclipse.dirigible.components.intent.model.LabelExpression; import org.eclipse.dirigible.components.intent.model.FieldIntent; +import org.eclipse.dirigible.components.intent.model.NumberIntent; import org.eclipse.dirigible.components.intent.model.IntentModel; import org.eclipse.dirigible.components.intent.model.RelationIntent; import org.eclipse.dirigible.components.intent.model.RollupIntent; @@ -918,6 +919,38 @@ private static Map propertyMap(String entityName, FieldIntent fi if (field.isReadOnly() || "uuid".equalsIgnoreCase(field.getType())) { p.put("isReadOnlyProperty", "true"); } + // A uuid field is platform-generated: the generated repository assigns a random UUID on create + // when the value is empty (no hand-written calculatedActionOnCreate needed). The author can + // still seed/import an explicit value - it is only filled when blank. + if ("uuid".equalsIgnoreCase(field.getType())) { + p.put("generatedUuid", "true"); + } + // First-class document numbering (intent `number: {}`): the platform maintains a per-series + // counter and stamps the formatted number. stampOn:create stamps the real number on insert + // (the generated repository calls sdk.numbering.DocumentNumbers); stampOn:issue holds a UUID + // placeholder on create (reusing the uuid auto-fill above) until the generated stamp step runs. + if (field.getNumber() != null) { + NumberIntent number = field.getNumber(); + List numberScope = new ArrayList<>(); + if (number.getScope() != null) { + for (String scopeName : number.getScope()) { + // Scope names index the counter AND read the entity's field on create; PascalCase them + // to match the generated entity property (year stays the literal token). + numberScope.add("year".equalsIgnoreCase(scopeName) ? "year" : IntentNaming.pascalCase(scopeName)); + } + } + p.put("numberSeries", number.getSeries() == null ? entityName : number.getSeries()); + p.put("numberFormat", number.getFormat() == null ? "" : number.getFormat()); + p.put("numberScope", numberScope); + p.put("isReadOnlyProperty", "true"); + if ("issue".equalsIgnoreCase(number.getStampOn())) { + p.put("numberStampOn", "issue"); + p.put("generatedUuid", "true"); // UUID placeholder on create; stamped at the issue step + } else { + p.put("numberStampOn", "create"); + p.put("numberStampOnCreate", "true"); // real number allocated + formatted on insert + } + } if (field.isSensitive()) { // Hidden from the personal (my) surface: absent from its pages and stripped from the // personal REST controller's responses. The power surface ignores this attribute. diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java index ea5ee86164..62b3a2c7a4 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/edm/EdmIntentGeneratorTest.java @@ -66,8 +66,10 @@ void customerEmitsCrossModelProjectionsAndForeignKeys() { .noneMatch(p -> "Country".equals(p.get("name"))), "projection targets must not create perspectives"); - // uuid carries the unique constraint; the four audit columns are present. + // uuid carries the unique constraint and is platform-generated on create (no custom action); + // the four audit columns are present. assertEquals("true", propertyByName(customer, "Uuid").get("dataUnique")); + assertEquals("true", propertyByName(customer, "Uuid").get("generatedUuid")); assertEquals("CREATED_AT", propertyByName(customer, "CreatedAt").get("auditType")); assertEquals("UPDATED_BY", propertyByName(customer, "UpdatedBy").get("auditType")); @@ -75,6 +77,39 @@ void customerEmitsCrossModelProjectionsAndForeignKeys() { assertEquals("master-data", customer.get("perspectiveNavId")); } + @Test + void numberFieldEmitsStampMarkers() { + String yaml = + """ + name: billing + entities: + - name: SalesInvoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, number: { series: SalesInvoice, format: "SI-{seq:07}", scope: [year], stampOn: issue } } + - name: Proforma + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, number: { series: Proforma, format: "PF-{seq:05}", stampOn: create } } + """; + Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "billing"); + List> entities = entities(model); + + // stampOn: issue -> a UUID placeholder on create (reusing the uuid auto-fill) + the series markers. + Map siNumber = propertyByName(entityByName(entities, "SalesInvoice"), "Number"); + assertEquals("issue", siNumber.get("numberStampOn")); + assertEquals("SalesInvoice", siNumber.get("numberSeries")); + assertEquals("true", siNumber.get("generatedUuid")); + assertNull(siNumber.get("numberStampOnCreate")); + + // stampOn: create -> the real number is stamped on insert (numberStampOnCreate), no placeholder. + Map pfNumber = propertyByName(entityByName(entities, "Proforma"), "Number"); + assertEquals("create", pfNumber.get("numberStampOn")); + assertEquals("true", pfNumber.get("numberStampOnCreate")); + assertEquals("PF-{seq:05}", pfNumber.get("numberFormat")); + assertNull(pfNumber.get("generatedUuid")); + } + @Test void crossModelProjectionCellIsMarkedProjectionInTheEdmDiagram() { IntentModel parsed = IntentParser.parse(readResource("/billing/customers.intent")); diff --git a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template index b78ed136d8..c1f660d89a 100644 --- a/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template +++ b/components/template/template-application-dao-java/src/main/resources/META-INF/dirigible/template-application-dao-java/data/Repository.java.template @@ -143,6 +143,34 @@ public class ${name}Repository extends JavaRepository<${name}Entity> { #end #end #end +## A uuid field (intent type: uuid) is platform-generated: assign a random UUID on create when empty. +## A number: {} field with stampOn:issue also carries generatedUuid, so its create-time placeholder is +## a UUID (the real number is stamped at the issue step); stampOn:create fields are handled just below. +#foreach ($property in $properties) +#if($property.generatedUuid) + if (entity.${property.name} == null || entity.${property.name}.isBlank()) { + entity.${property.name} = java.util.UUID.randomUUID().toString(); + } +#end +#end +## First-class numbering, stampOn:create: allocate + format the real document number on insert (when +## empty), via the shared per-tenant counter. The scope both partitions the counter and feeds the +## format's tokens; `year` is the current year, any other name reads the entity's own field. +#foreach ($property in $properties) +#if($property.numberStampOnCreate) + if (entity.${property.name} == null || entity.${property.name}.isBlank()) { + java.util.Map ${property.name}Scope = new java.util.LinkedHashMap<>(); +#foreach ($scopeName in $property.numberScope) +#if($scopeName == "year") + ${property.name}Scope.put("year", String.valueOf(java.time.Year.now().getValue())); +#else + ${property.name}Scope.put("${scopeName}", entity.${scopeName} == null ? "" : String.valueOf(entity.${scopeName})); +#end +#end + entity.${property.name} = org.eclipse.dirigible.sdk.numbering.DocumentNumbers.next("${property.numberSeries}", "${property.numberFormat}", ${property.name}Scope); + } +#end +#end #if($documentMaster) recalculate(entity); #end diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Numbering.java.template b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Numbering.java.template new file mode 100644 index 0000000000..4266814ec8 --- /dev/null +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/events/Numbering.java.template @@ -0,0 +1,61 @@ +package gen.events; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.flowable.engine.delegate.DelegateExecution; +import org.flowable.engine.delegate.JavaDelegate; + +import gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Entity; +import gen.${javaGenFolderName}.data.${javaPerspective}.${entity}Repository; + +/** + * Document-number stamp for ${entity} (field ${field}, series ${series}, stampOn: issue). Wired as a + * `delegate:` service task at the issue step: it replaces the create-time UUID placeholder with the + * real formatted number, idempotently - a re-issue after an amend keeps the number. + * + * Generated from the intent numbering glue - do not edit; it is re-generated with the application. The + * number is allocated + formatted via sdk.numbering.DocumentNumbers (the shared per-tenant counter, + * also managed from the application shell's Document Numbering settings) and written with the targeted + * updateProperty - a workflow system write (no "-updated" event re-fire, no full-row merge). + */ +public class ${entity}NumberStamp implements JavaDelegate { + + @Override + public void execute(DelegateExecution execution) { + Object key = execution.getVariable("${masterPk}"); + if (!(key instanceof Number)) { + return; + } + int id = ((Number) key).intValue(); + ${entity}Repository repository = new ${entity}Repository(); + ${entity}Entity entity = repository.findById(id); + if (entity == null) { + return; + } + // Idempotent: only replace the create-time UUID placeholder; skip an already-stamped number. + if (entity.${field} != null && !isPlaceholder(entity.${field})) { + return; + } + Map scope = new LinkedHashMap<>(); +#foreach ($scopeName in $scope) +#if($scopeName == "year") + scope.put("year", String.valueOf(java.time.Year.now().getValue())); +#else + scope.put("${scopeName}", entity.${scopeName} == null ? "" : String.valueOf(entity.${scopeName})); +#end +#end + String number = org.eclipse.dirigible.sdk.numbering.DocumentNumbers.next("${series}", "${format}", scope); + repository.updateProperty(id, "${field}", number); + } + + /** Whether the value is still the create-time UUID placeholder (vs an already-stamped number). */ + private static boolean isPlaceholder(String value) { + try { + java.util.UUID.fromString(value); + return true; + } catch (IllegalArgumentException notAUuid) { + return false; + } + } +} diff --git a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js index b1293acf6d..bf2af5031c 100644 --- a/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js +++ b/components/template/template-application-events-java/src/main/resources/META-INF/dirigible/template-application-events-java/template/template.js @@ -135,6 +135,13 @@ export function getTemplate(parameters) { engine: "velocity", collection: "snapshots" }, + { + location: "/template-application-events-java/events/Numbering.java.template", + action: "generate", + rename: "gen/events/{{entity}}NumberStamp.java", + engine: "velocity", + collection: "numbering" + }, { location: "/template-application-events-java/events/SettlementOnPayment.java.template", action: "generate", diff --git a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js index 34095df68d..6d9996f905 100644 --- a/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js +++ b/components/ui/service-generate/src/main/resources/META-INF/dirigible/service-generate/template/generateUtils.js @@ -1179,6 +1179,34 @@ export function generateFiles(model, parameters, templateSources) { } } break; + case "numbering": + // Document-number stamp delegates (intent layer): one JavaDelegate per number: field + // with stampOn:issue, wired as a delegate: service task at the issue step. It stamps + // the real number via sdk.numbering.DocumentNumbers. Own loop; the entity repo lives + // in this project (javaGenFolderName), its Java package segment is the sanitized + // perspective. + if (model.numbering) { + for (let n = 0; n < model.numbering.length; n++) { + const num = model.numbering[n]; + const numberingParameters = { + ...parameters, + entity: num.entity, + javaPerspective: sanitizeJavaIdentifier(num.perspective), + masterPk: num.masterPk, + field: num.field, + series: num.series, + format: num.format, + scope: num.scope + }; + const cleanNumberingParameters = cleanData(numberingParameters); + generatedFiles.push({ + location: location, + content: getGenerationEngine(template).generate(location, content, cleanNumberingParameters), + path: templateEngines.getMustacheEngine().generate(location, template.rename, cleanNumberingParameters) + }); + } + } + break; default: // No collection parameters.models = model.entities;