Skip to content

Commit bf18ecf

Browse files
delchevclaude
andauthored
feat(numbering): per-tenant document-number counter store + runtime + shell settings (N2) (#6384)
N2 of first-class document numbering (kf-catalog UPSTREAM_PLAN #11a): the runtime the DSL (N1, #6382) will generate against, plus the application-shell settings surface. New module components/engine/engine-numbering: - DocumentNumberStore — a per-tenant DIRIGIBLE_DOCUMENT_NUMBERS table (via the tenant-routed default datasource + SqlFactory, like DIRIGIBLE_CONFIGURATIONS), keyed by (series, scope). allocate() is gap-free: the increment takes a row lock so concurrent allocations of the same counter serialize; the row is created on first use. - DocumentNumberService — allocate + the format grammar ({seq}/{seq:0N} zero-pad, {series}, and scope tokens {year}/{<Field>}) + scope-key derivation; plus list()/setNext() for management. - DocumentNumberEndpoint — /services/core/numbering (ADMINISTRATOR/OPERATOR): GET the tenant's counters, PUT the next value a (series, scope) counter will allocate. Application shell (resources-application) — a built-in "Document Numbering" Settings entry (next to Region & Language / Tenant Configuration): lists the counters and lets an admin set each series' next value (reset/seed a sequence, e.g. start invoices at 1000); read-only on a 403. Verified on a booted instance: GET [] then PUT {series:SalesInvoice,next:1000} (204) then GET shows counter 999 (next allocate = 1000); the settings page + appShell.js serve the new entry. DocumentNumberServiceTest covers the format renderer + scope-key. The SDK entry + generated stamping + idempotency are N3. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1d1a09c commit bf18ecf

10 files changed

Lines changed: 597 additions & 1 deletion

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<project xmlns="http://maven.apache.org/POM/4.0.0"
2+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
4+
5+
<parent>
6+
<artifactId>dirigible-components-parent</artifactId>
7+
<groupId>org.eclipse.dirigible</groupId>
8+
<version>15.0.0-SNAPSHOT</version>
9+
<relativePath>../../pom.xml</relativePath>
10+
</parent>
11+
12+
<name>Components - Engine - Numbering</name>
13+
<artifactId>dirigible-components-engine-numbering</artifactId>
14+
<modelVersion>4.0.0</modelVersion>
15+
16+
<dependencies>
17+
<!-- Components -->
18+
<dependency>
19+
<groupId>org.eclipse.dirigible</groupId>
20+
<artifactId>dirigible-components-core-base</artifactId>
21+
</dependency>
22+
<!-- Per-tenant counter table via the tenant-routed default datasource + SqlFactory -->
23+
<dependency>
24+
<groupId>org.eclipse.dirigible</groupId>
25+
<artifactId>dirigible-components-data-sources</artifactId>
26+
</dependency>
27+
</dependencies>
28+
29+
<properties>
30+
<license.header.location>../../../licensing-header.txt</license.header.location>
31+
<parent.pom.folder>../../../</parent.pom.folder>
32+
</properties>
33+
</project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/*
2+
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
3+
*
4+
* All rights reserved. This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
6+
* http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
9+
*/
10+
package org.eclipse.dirigible.components.engine.numbering;
11+
12+
import java.sql.SQLException;
13+
import java.util.List;
14+
15+
import org.eclipse.dirigible.components.base.endpoint.BaseEndpoint;
16+
import org.springframework.http.HttpStatus;
17+
import org.springframework.http.ResponseEntity;
18+
import org.springframework.web.bind.annotation.GetMapping;
19+
import org.springframework.web.bind.annotation.PutMapping;
20+
import org.springframework.web.bind.annotation.RequestBody;
21+
import org.springframework.web.bind.annotation.RequestMapping;
22+
import org.springframework.web.bind.annotation.RestController;
23+
import org.springframework.web.server.ResponseStatusException;
24+
25+
import jakarta.annotation.security.RolesAllowed;
26+
27+
/**
28+
* Management surface for the current tenant's document-number counters, backing the application
29+
* shell's "Document Numbering" settings page. Lists the per-(series, scope) counters and lets an
30+
* administrator set the next value a counter will allocate (reset / seed a sequence).
31+
*/
32+
@RestController
33+
@RequestMapping(BaseEndpoint.PREFIX_ENDPOINT_CORE + "numbering")
34+
@RolesAllowed({"ADMINISTRATOR", "OPERATOR"})
35+
public class DocumentNumberEndpoint extends BaseEndpoint {
36+
37+
private final DocumentNumberService service;
38+
39+
DocumentNumberEndpoint(DocumentNumberService service) {
40+
this.service = service;
41+
}
42+
43+
/** The current tenant's counters. */
44+
@GetMapping
45+
public ResponseEntity<List<DocumentNumberStore.Counter>> list() {
46+
try {
47+
return ResponseEntity.ok(service.list());
48+
} catch (SQLException ex) {
49+
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to list document-number counters", ex);
50+
}
51+
}
52+
53+
/** Set the next value a (series, scope) counter will allocate. */
54+
@PutMapping
55+
public ResponseEntity<Void> setNext(@RequestBody SetNextRequest request) {
56+
if (request == null || request.series() == null || request.series()
57+
.isBlank()) {
58+
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "series is required");
59+
}
60+
try {
61+
service.setNext(request.series(), request.scope() == null ? "" : request.scope(), request.next());
62+
return ResponseEntity.noContent()
63+
.build();
64+
} catch (SQLException ex) {
65+
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to set the document-number counter", ex);
66+
}
67+
}
68+
69+
/**
70+
* Request body for setting a counter's next value.
71+
*
72+
* @param series the series identity
73+
* @param scope the scope key ({@code ""}/{@code null} for an unscoped series)
74+
* @param next the next value the counter should allocate
75+
*/
76+
record SetNextRequest(String series, String scope, long next) {
77+
}
78+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/*
2+
* Copyright (c) 2010-2026 Eclipse Dirigible contributors
3+
*
4+
* All rights reserved. This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
6+
* http://www.eclipse.org/legal/epl-v20.html
7+
*
8+
* SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
9+
*/
10+
package org.eclipse.dirigible.components.engine.numbering;
11+
12+
import java.sql.SQLException;
13+
import java.util.LinkedHashMap;
14+
import java.util.List;
15+
import java.util.Map;
16+
import java.util.regex.Matcher;
17+
import java.util.regex.Pattern;
18+
19+
import org.springframework.stereotype.Component;
20+
21+
/**
22+
* First-class document numbering runtime: allocates the next value for a series (partitioned by
23+
* scope) and renders it through the authored {@code format} template. The gap-free per-tenant
24+
* counter lives in {@link DocumentNumberStore}; this service adds the scope-key derivation and the
25+
* format grammar ({@code {seq}} / {@code {seq:0N}} zero-pad, {@code {series}}, and scope tokens
26+
* {@code {<name>}} such as {@code {year}}).
27+
*/
28+
@Component
29+
public class DocumentNumberService {
30+
31+
/** Default format when the field declares none: the series then a 6-digit sequence. */
32+
static final String DEFAULT_FORMAT = "{series}-{seq:06}";
33+
34+
private static final Pattern TOKEN = Pattern.compile("\\{([a-zA-Z][a-zA-Z0-9_]*)(?::0(\\d+))?\\}");
35+
36+
private final DocumentNumberStore store;
37+
38+
DocumentNumberService(DocumentNumberStore store) {
39+
this.store = store;
40+
}
41+
42+
/**
43+
* Allocate and format the next number for a series. The scope map (insertion-ordered
44+
* {@code name -> value}) both partitions the counter and feeds the format's scope tokens.
45+
*
46+
* @param series the series identity (documents sharing a sequence pass the same series)
47+
* @param format the format template, or {@code null}/blank for {@link #DEFAULT_FORMAT}
48+
* @param scope the resolved scope values (e.g. {@code {Company=1, year=2026}}); empty for unscoped
49+
* @return the formatted document number
50+
* @throws SQLException if the allocation fails
51+
*/
52+
public String next(String series, String format, Map<String, String> scope) throws SQLException {
53+
Map<String, String> safeScope = scope == null ? Map.of() : scope;
54+
long seq = store.allocate(series, scopeKey(safeScope));
55+
return render(format == null || format.isBlank() ? DEFAULT_FORMAT : format, series, seq, safeScope);
56+
}
57+
58+
/** All counter rows of the current tenant (for the management surface). */
59+
public List<DocumentNumberStore.Counter> list() throws SQLException {
60+
return store.list();
61+
}
62+
63+
/**
64+
* Set the <b>next</b> value a (series, scope) counter will allocate (e.g. start invoices at 1000).
65+
*
66+
* @param series the series identity
67+
* @param scope the scope key ({@code ""} for unscoped)
68+
* @param next the next value to allocate (stored as {@code next - 1})
69+
* @throws SQLException if the write fails
70+
*/
71+
public void setNext(String series, String scope, long next) throws SQLException {
72+
store.setCounter(series, scope, Math.max(0, next - 1));
73+
}
74+
75+
/** The counter partition key: the scope values joined by {@code |}; {@code ""} when unscoped. */
76+
static String scopeKey(Map<String, String> scope) {
77+
return String.join("|", scope.values());
78+
}
79+
80+
/**
81+
* Render a format template. {@code {seq}} / {@code {seq:0N}} expand the sequence (zero-padded to
82+
* N); {@code {series}} the series; any other {@code {name}} the scope value for that name (empty
83+
* when absent).
84+
*/
85+
static String render(String format, String series, long seq, Map<String, String> scope) {
86+
Map<String, String> tokens = new LinkedHashMap<>(scope);
87+
tokens.put("series", series);
88+
Matcher matcher = TOKEN.matcher(format);
89+
StringBuilder out = new StringBuilder();
90+
while (matcher.find()) {
91+
String name = matcher.group(1);
92+
String pad = matcher.group(2);
93+
String value;
94+
if ("seq".equals(name)) {
95+
value = pad == null ? Long.toString(seq) : String.format("%0" + pad + "d", seq);
96+
} else {
97+
value = tokens.getOrDefault(name, "");
98+
}
99+
matcher.appendReplacement(out, Matcher.quoteReplacement(value));
100+
}
101+
matcher.appendTail(out);
102+
return out.toString();
103+
}
104+
}

0 commit comments

Comments
 (0)