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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions components/engine/engine-numbering/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">

<parent>
<artifactId>dirigible-components-parent</artifactId>
<groupId>org.eclipse.dirigible</groupId>
<version>15.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>

<name>Components - Engine - Numbering</name>
<artifactId>dirigible-components-engine-numbering</artifactId>
<modelVersion>4.0.0</modelVersion>

<dependencies>
<!-- Components -->
<dependency>
<groupId>org.eclipse.dirigible</groupId>
<artifactId>dirigible-components-core-base</artifactId>
</dependency>
<!-- Per-tenant counter table via the tenant-routed default datasource + SqlFactory -->
<dependency>
<groupId>org.eclipse.dirigible</groupId>
<artifactId>dirigible-components-data-sources</artifactId>
</dependency>
</dependencies>

<properties>
<license.header.location>../../../licensing-header.txt</license.header.location>
<parent.pom.folder>../../../</parent.pom.folder>
</properties>
</project>
Original file line number Diff line number Diff line change
@@ -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<DocumentNumberStore.Counter>> 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<Void> 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) {
}
}
Original file line number Diff line number Diff line change
@@ -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 {<name>}} 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<String, String> scope) throws SQLException {
Map<String, String> 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<DocumentNumberStore.Counter> list() throws SQLException {
return store.list();
}

/**
* Set the <b>next</b> 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<String, String> 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<String, String> scope) {
Map<String, String> 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();
}
}
Loading
Loading