diff --git a/components/engine/engine-numbering/pom.xml b/components/engine/engine-numbering/pom.xml new file mode 100644 index 00000000000..965a969f4ee --- /dev/null +++ b/components/engine/engine-numbering/pom.xml @@ -0,0 +1,33 @@ + + + + dirigible-components-parent + org.eclipse.dirigible + 15.0.0-SNAPSHOT + ../../pom.xml + + + Components - Engine - Numbering + dirigible-components-engine-numbering + 4.0.0 + + + + + org.eclipse.dirigible + dirigible-components-core-base + + + + org.eclipse.dirigible + dirigible-components-data-sources + + + + + ../../../licensing-header.txt + ../../../ + + diff --git a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberEndpoint.java b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberEndpoint.java new file mode 100644 index 00000000000..d527641b3fa --- /dev/null +++ b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberEndpoint.java @@ -0,0 +1,78 @@ +/* + * 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.engine.numbering; + +import java.sql.SQLException; +import java.util.List; + +import org.eclipse.dirigible.components.base.endpoint.BaseEndpoint; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import jakarta.annotation.security.RolesAllowed; + +/** + * Management surface for the current tenant's document-number counters, backing the application + * shell's "Document Numbering" settings page. Lists the per-(series, scope) counters and lets an + * administrator set the next value a counter will allocate (reset / seed a sequence). + */ +@RestController +@RequestMapping(BaseEndpoint.PREFIX_ENDPOINT_CORE + "numbering") +@RolesAllowed({"ADMINISTRATOR", "OPERATOR"}) +public class DocumentNumberEndpoint extends BaseEndpoint { + + private final DocumentNumberService service; + + DocumentNumberEndpoint(DocumentNumberService service) { + this.service = service; + } + + /** The current tenant's counters. */ + @GetMapping + public ResponseEntity> list() { + try { + return ResponseEntity.ok(service.list()); + } catch (SQLException ex) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to list document-number counters", ex); + } + } + + /** Set the next value a (series, scope) counter will allocate. */ + @PutMapping + public ResponseEntity setNext(@RequestBody SetNextRequest request) { + if (request == null || request.series() == null || request.series() + .isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "series is required"); + } + try { + service.setNext(request.series(), request.scope() == null ? "" : request.scope(), request.next()); + return ResponseEntity.noContent() + .build(); + } catch (SQLException ex) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to set the document-number counter", ex); + } + } + + /** + * Request body for setting a counter's next value. + * + * @param series the series identity + * @param scope the scope key ({@code ""}/{@code null} for an unscoped series) + * @param next the next value the counter should allocate + */ + record SetNextRequest(String series, String scope, long next) { + } +} diff --git a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberService.java b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberService.java new file mode 100644 index 00000000000..103d42b47dc --- /dev/null +++ b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberService.java @@ -0,0 +1,104 @@ +/* + * 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.engine.numbering; + +import java.sql.SQLException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Component; + +/** + * First-class document numbering runtime: allocates the next value for a series (partitioned by + * scope) and renders it through the authored {@code format} template. The gap-free per-tenant + * counter lives in {@link DocumentNumberStore}; this service adds the scope-key derivation and the + * format grammar ({@code {seq}} / {@code {seq:0N}} zero-pad, {@code {series}}, and scope tokens + * {@code {}} such as {@code {year}}). + */ +@Component +public class DocumentNumberService { + + /** Default format when the field declares none: the series then a 6-digit sequence. */ + static final String DEFAULT_FORMAT = "{series}-{seq:06}"; + + private static final Pattern TOKEN = Pattern.compile("\\{([a-zA-Z][a-zA-Z0-9_]*)(?::0(\\d+))?\\}"); + + private final DocumentNumberStore store; + + DocumentNumberService(DocumentNumberStore store) { + this.store = store; + } + + /** + * Allocate and format the next number for a series. The scope map (insertion-ordered + * {@code name -> value}) both partitions the counter and feeds the format's scope tokens. + * + * @param series the series identity (documents sharing a sequence pass the same series) + * @param format the format template, or {@code null}/blank for {@link #DEFAULT_FORMAT} + * @param scope the resolved scope values (e.g. {@code {Company=1, year=2026}}); empty for unscoped + * @return the formatted document number + * @throws SQLException if the allocation fails + */ + public String next(String series, String format, Map scope) throws SQLException { + Map safeScope = scope == null ? Map.of() : scope; + long seq = store.allocate(series, scopeKey(safeScope)); + return render(format == null || format.isBlank() ? DEFAULT_FORMAT : format, series, seq, safeScope); + } + + /** All counter rows of the current tenant (for the management surface). */ + public List list() throws SQLException { + return store.list(); + } + + /** + * Set the next value a (series, scope) counter will allocate (e.g. start invoices at 1000). + * + * @param series the series identity + * @param scope the scope key ({@code ""} for unscoped) + * @param next the next value to allocate (stored as {@code next - 1}) + * @throws SQLException if the write fails + */ + public void setNext(String series, String scope, long next) throws SQLException { + store.setCounter(series, scope, Math.max(0, next - 1)); + } + + /** The counter partition key: the scope values joined by {@code |}; {@code ""} when unscoped. */ + static String scopeKey(Map scope) { + return String.join("|", scope.values()); + } + + /** + * Render a format template. {@code {seq}} / {@code {seq:0N}} expand the sequence (zero-padded to + * N); {@code {series}} the series; any other {@code {name}} the scope value for that name (empty + * when absent). + */ + static String render(String format, String series, long seq, Map scope) { + Map tokens = new LinkedHashMap<>(scope); + tokens.put("series", series); + Matcher matcher = TOKEN.matcher(format); + StringBuilder out = new StringBuilder(); + while (matcher.find()) { + String name = matcher.group(1); + String pad = matcher.group(2); + String value; + if ("seq".equals(name)) { + value = pad == null ? Long.toString(seq) : String.format("%0" + pad + "d", seq); + } else { + value = tokens.getOrDefault(name, ""); + } + matcher.appendReplacement(out, Matcher.quoteReplacement(value)); + } + matcher.appendTail(out); + return out.toString(); + } +} diff --git a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberStore.java b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberStore.java new file mode 100644 index 00000000000..be66527482f --- /dev/null +++ b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberStore.java @@ -0,0 +1,218 @@ +/* + * 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.engine.numbering; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.dirigible.components.data.sources.manager.DataSourcesManager; +import org.eclipse.dirigible.database.sql.DataType; +import org.eclipse.dirigible.database.sql.SqlFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * Per-tenant document-number counter store. Backed by {@code DIRIGIBLE_DOCUMENT_NUMBERS} in the + * tenant-routed default datasource (so each tenant gets its own counters), keyed by (series, + * scope). {@link #allocate(String, String)} returns the next value gap-free; a concurrent + * allocation of the same (series, scope) serializes on the row lock the increment takes. + */ +@Component +class DocumentNumberStore { + + private static final Logger LOGGER = LoggerFactory.getLogger(DocumentNumberStore.class); + + private static final String TABLE_NAME = "DIRIGIBLE_DOCUMENT_NUMBERS"; + private static final String QUOTED_TABLE = "\"DIRIGIBLE_DOCUMENT_NUMBERS\""; + private static final String COLUMN_SERIES = "DOCUMENT_SERIES"; + private static final String QUOTED_SERIES = "\"DOCUMENT_SERIES\""; + private static final String COLUMN_SCOPE = "DOCUMENT_SCOPE"; + private static final String QUOTED_SCOPE = "\"DOCUMENT_SCOPE\""; + private static final String COLUMN_COUNTER = "DOCUMENT_COUNTER"; + private static final String QUOTED_COUNTER = "\"DOCUMENT_COUNTER\""; + + private final DataSourcesManager dataSourcesManager; + + DocumentNumberStore(DataSourcesManager dataSourcesManager) { + this.dataSourcesManager = dataSourcesManager; + } + + /** + * A counter row - the current value of one (series, scope) counter in the current tenant. + * + * @param series the series identity + * @param scope the scope key ({@code ""} for an unscoped series) + * @param counter the current (last allocated) value + */ + record Counter(String series, String scope, long counter) { + } + + /** + * Allocate the next value for (series, scope) - gap-free per tenant. Creates the counter row on + * first use (starting at 1); a concurrent allocation blocks on the increment's row lock. + * + * @param series the series identity + * @param scope the scope key ({@code ""} for unscoped) + * @return the newly allocated value + * @throws SQLException if the allocation fails + */ + long allocate(String series, String scope) throws SQLException { + try (Connection connection = dataSourcesManager.getDefaultDataSource() + .getConnection()) { + ensureTableExists(connection); + boolean autoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + if (increment(connection, series, scope) == 0) { + try { + insert(connection, series, scope); + } catch (SQLException duplicate) { + // A concurrent first allocation created the row; increment the now-present row. + LOGGER.debug("Concurrent counter creation for series [{}] scope [{}]; retrying increment", series, scope, + duplicate); + increment(connection, series, scope); + } + } + long value = read(connection, series, scope); + connection.commit(); + return value; + } catch (SQLException ex) { + connection.rollback(); + throw ex; + } finally { + connection.setAutoCommit(autoCommit); + } + } + } + + /** All counter rows of the current tenant, insertion-ordered. */ + List list() throws SQLException { + try (Connection connection = dataSourcesManager.getDefaultDataSource() + .getConnection()) { + ensureTableExists(connection); + String sql = SqlFactory.getNative(connection) + .select() + .column("*") + .from(TABLE_NAME) + .build(); + List result = new ArrayList<>(); + try (PreparedStatement statement = connection.prepareStatement(sql); ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + result.add(new Counter(resultSet.getString(COLUMN_SERIES), resultSet.getString(COLUMN_SCOPE), + resultSet.getLong(COLUMN_COUNTER))); + } + } + return result; + } + } + + /** + * Set the current value of a (series, scope) counter (the next allocation returns + * {@code value + 1}). Creates the row if absent. Used by the management surface to reset/seed a + * counter. + */ + void setCounter(String series, String scope, long value) throws SQLException { + try (Connection connection = dataSourcesManager.getDefaultDataSource() + .getConnection()) { + ensureTableExists(connection); + String update = SqlFactory.getNative(connection) + .update() + .table(TABLE_NAME) + .set(COLUMN_COUNTER, "?") + .where(COLUMN_SERIES + " = ? AND " + COLUMN_SCOPE + " = ?") + .build(); + try (PreparedStatement statement = connection.prepareStatement(update)) { + statement.setLong(1, value); + statement.setString(2, series); + statement.setString(3, scope); + if (statement.executeUpdate() == 0) { + insert(connection, series, scope); + setCounter(series, scope, value); + } + } + } + } + + private int increment(Connection connection, String series, String scope) throws SQLException { + String sql = SqlFactory.getNative(connection) + .update() + .table(TABLE_NAME) + .set(COLUMN_COUNTER, COLUMN_COUNTER + " + 1") + .where(COLUMN_SERIES + " = ? AND " + COLUMN_SCOPE + " = ?") + .build(); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, series); + statement.setString(2, scope); + return statement.executeUpdate(); + } + } + + private void insert(Connection connection, String series, String scope) throws SQLException { + String sql = SqlFactory.getNative(connection) + .insert() + .into(TABLE_NAME) + .column(COLUMN_SERIES) + .column(COLUMN_SCOPE) + .column(COLUMN_COUNTER) + .build(); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, series); + statement.setString(2, scope); + statement.setLong(3, 1L); + statement.executeUpdate(); + } + } + + private long read(Connection connection, String series, String scope) throws SQLException { + String sql = SqlFactory.getNative(connection) + .select() + .column(COLUMN_COUNTER) + .from(TABLE_NAME) + .where(COLUMN_SERIES + " = ? AND " + COLUMN_SCOPE + " = ?") + .build(); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, series); + statement.setString(2, scope); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? resultSet.getLong(1) : 0L; + } + } + } + + private void ensureTableExists(Connection connection) throws SQLException { + if (SqlFactory.getNative(connection) + .existsTable(connection, TABLE_NAME)) { + return; + } + String sql = SqlFactory.getNative(connection) + .create() + .table(QUOTED_TABLE) + .column(QUOTED_SERIES, DataType.VARCHAR, true, false, false, "(255)") + .column(QUOTED_SCOPE, DataType.VARCHAR, true, false, false, "(255)") + .column(QUOTED_COUNTER, DataType.BIGINT, false, false, false) + .build(); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.executeUpdate(); + LOGGER.info("Created per-tenant document-number table using sql [{}]", sql); + } catch (SQLException ex) { + if (SqlFactory.getNative(connection) + .existsTable(connection, TABLE_NAME)) { + LOGGER.debug("Document-number table already created concurrently", ex); + } else { + throw ex; + } + } + } +} diff --git a/components/engine/engine-numbering/src/test/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberServiceTest.java b/components/engine/engine-numbering/src/test/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberServiceTest.java new file mode 100644 index 00000000000..37b1cb790cc --- /dev/null +++ b/components/engine/engine-numbering/src/test/java/org/eclipse/dirigible/components/engine/numbering/DocumentNumberServiceTest.java @@ -0,0 +1,43 @@ +/* + * 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.engine.numbering; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +class DocumentNumberServiceTest { + + @Test + void rendersSeqPaddingSeriesAndScopeTokens() { + Map scope = new LinkedHashMap<>(); + scope.put("Company", "1"); + scope.put("year", "2026"); + + assertEquals("SI-0000042", DocumentNumberService.render("SI-{seq:07}", "SalesInvoice", 42, scope)); + assertEquals("SalesInvoice/2026/42", DocumentNumberService.render("{series}/{year}/{seq}", "SalesInvoice", 42, scope)); + assertEquals("INV-1-2026-000042", DocumentNumberService.render("INV-{Company}-{year}-{seq:06}", "SalesInvoice", 42, scope)); + // An unknown token renders empty; the default format uses the series. + assertEquals("SalesInvoice-000001", + DocumentNumberService.render(DocumentNumberService.DEFAULT_FORMAT, "SalesInvoice", 1, Map.of())); + } + + @Test + void scopeKeyJoinsValuesAndIsEmptyWhenUnscoped() { + Map scope = new LinkedHashMap<>(); + scope.put("Company", "1"); + scope.put("year", "2026"); + assertEquals("1|2026", DocumentNumberService.scopeKey(scope)); + assertEquals("", DocumentNumberService.scopeKey(Map.of())); + } +} diff --git a/components/group/group-engines/pom.xml b/components/group/group-engines/pom.xml index 8ef73f34f32..e481b280d1a 100644 --- a/components/group/group-engines/pom.xml +++ b/components/group/group-engines/pom.xml @@ -105,6 +105,10 @@ org.eclipse.dirigible dirigible-components-engine-document + + org.eclipse.dirigible + dirigible-components-engine-numbering + org.eclipse.dirigible dirigible-components-engine-websockets diff --git a/components/pom.xml b/components/pom.xml index bdb35f61220..d03fc03a566 100644 --- a/components/pom.xml +++ b/components/pom.xml @@ -82,6 +82,7 @@ engine/engine-listeners engine/engine-intent engine/engine-document + engine/engine-numbering engine/engine-odata engine/engine-open-telemetry engine/engine-camel diff --git a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js index a2fcd6e3ca1..55982a2d5d1 100644 --- a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js +++ b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/js/appShell.js @@ -126,6 +126,68 @@ document.addEventListener('alpine:init', () => { } }, + // Document Numbering: the current tenant's per-series counters (engine-numbering), read from and + // written to /services/core/numbering (ADMINISTRATOR/OPERATOR - a 403 is surfaced read-only). Each + // row is { series, scope, counter }; the editable field is the NEXT value (counter + 1) so an admin + // can reset/seed a sequence (e.g. start invoices at 1000). + numbering: [], + numberingLoading: false, + numberingError: null, + + /** A readable label for a counter row: the series, plus the scope in brackets when scoped. */ + numberingLabel(row) { + return row.scope ? row.series + ' [' + row.scope + ']' : row.series; + }, + + /** Load the current tenant's document-number counters. */ + async loadNumbering() { + this.numberingLoading = true; + this.numberingError = null; + try { + const res = await fetch('/services/core/numbering', { + credentials: 'same-origin', + headers: { 'Accept': 'application/json' } + }); + if (res.status === 403) { + this.numbering = []; + this.numberingError = 'forbidden'; + return; + } + if (!res.ok) throw new Error('HTTP ' + res.status); + const data = await res.json(); + this.numbering = data.map((c) => ({ series: c.series, scope: c.scope || '', counter: c.counter, next: (c.counter || 0) + 1 })); + } catch (e) { + console.error('document-numbering: failed to load', e); + this.numbering = []; + this.numberingError = 'load'; + } finally { + this.numberingLoading = false; + this.refreshIcons(); + } + }, + + /** Persist each row's edited NEXT value (PUT setNext). */ + async saveNumbering() { + this.numberingError = null; + try { + for (const row of this.numbering) { + const next = parseInt(row.next, 10); + if (!isFinite(next) || next < 1) continue; + const res = await fetch('/services/core/numbering', { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ series: row.series, scope: row.scope, next: next }) + }); + if (!res.ok) throw new Error('PUT ' + row.series + ' -> HTTP ' + res.status); + } + await this.loadNumbering(); + } catch (e) { + console.error('document-numbering: failed to save', e); + this.numberingError = 'save'; + } + }, + // Language coverage of the embedded apps: which languages each generated app PROVIDES // translations for (its js/config.js carries `languages: [...]` from the intent; the config is // a JS file, so the array is read with a targeted match rather than executed). One entry per @@ -287,6 +349,7 @@ document.addEventListener('alpine:init', () => { this.settingsSelected = item.id; this.settingsUrl = item.path || ''; if (item.id === 'tenant-configuration') this.loadTenantConfig(); + if (item.id === 'document-numbering') this.loadNumbering(); } } } else { @@ -439,6 +502,7 @@ document.addEventListener('alpine:init', () => { window.history.replaceState(window.history.state, '', url); } if (item.id === 'tenant-configuration') this.loadTenantConfig(); + if (item.id === 'document-numbering') this.loadNumbering(); this.refreshIcons(); } }, @@ -450,6 +514,7 @@ document.addEventListener('alpine:init', () => { findSettingItem(id) { if (id === 'region-language') return { id: 'region-language' }; if (id === 'tenant-configuration') return { id: 'tenant-configuration' }; + if (id === 'document-numbering') return { id: 'document-numbering' }; return (this.settingsItems || []).find(i => i.id === id) || null; }, diff --git a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/views/_settings.html b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/views/_settings.html index 32d5e0d1335..5399da2ac3c 100644 --- a/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/views/_settings.html +++ b/components/resources/resources-application/src/main/resources/META-INF/dirigible/application/views/_settings.html @@ -26,6 +26,14 @@ + + + +
+
+
+ + +
+ + + + +
+
diff --git a/modules/pom.xml b/modules/pom.xml index 8a295908102..a4b71917231 100644 --- a/modules/pom.xml +++ b/modules/pom.xml @@ -184,6 +184,11 @@ dirigible-components-engine-document ${project.version}
+ + org.eclipse.dirigible + dirigible-components-engine-numbering + ${project.version} + org.eclipse.dirigible dirigible-components-engine-template