diff --git a/CLAUDE.md b/CLAUDE.md index 2589490c469..24804506c26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,6 +187,8 @@ A single `app.intent` YAML file at a project root is the source of truth one alt **Multi-model + layout additions (PRs [#6089](https://github.com/eclipse-dirigible/dirigible/pull/6089)-[#6092](https://github.com/eclipse-dirigible/dirigible/pull/6092)):** the DSL now supports building an app from **several intent models that reference each other cross-model** - a top-level `uses:` block names other models, and a relation gains an optional `model:` alias; a cross-model `manyToOne`/`oneToOne` is emitted as a read-only **PROJECTION** entity + integer FK + dropdown (the codbex cross-project pattern - no local table/DAO/controller for the target), resolved against the owner's already-generated `.model` (leaf-first generation; convention fallback otherwise). **n:m** is an explicit **intermediate entity** (composition to one side + `manyToOne` to the other, which may be cross-model, plus bridge fields like `amount`) - `manyToMany` is parsed but never materialized. New field attributes: `unique`, `precision`/`scale`, `calculatedOnCreate`/`calculatedOnUpdate` (a neutral arithmetic expression for numeric totals, else emitted verbatim into the runtime), `calculatedActionOnCreate`/`calculatedActionOnUpdate` (server-side call-out to a hand-written `@Component implements org.eclipse.dirigible.sdk.db.CalculatedField`, invoked as `Beans.get(.class).calculate(entity)`, taking precedence over the expression — for logic too custom to model, e.g. number generation); field `readOnly: true` (not editable; rendered in the Harmonia form's read-only details block — Label:Value above the buttons — via `isReadOnlyProperty`; `ProcessId`/audit columns/`uuid` are auto-flagged read-only, `status`-style fields opt in); field `major: false` (kept off the entity **list** table — the model's `widgetIsMajor="false"` — still shown in forms + the record details pane; defaults true); entity `imports:` (Java `import` lines injected into the generated repository so a calculated action can be referenced by simple name — Base64-encoded into the `.model`'s `importsCode`, which the Java DAO template emits; the editor's entity-level Imports tab is the model-editor equivalent); entity `audit: true` (the four standard audit columns); entity `group:` (the perspective's nav-group id in the shared application shell). **Depends-On** is exposed as `dependsOn: { relation, valueFrom?, filterBy? }` on a to-one relation (cascading/narrowed dropdown) or a field (auto-populated value) — emitted as the EDM `widgetDependsOn*` attributes (the AngularJS stacks consume them as-is; the Harmonia runtime — form/document watchers + the metadata-driven item-dialog cascade — was added alongside); defaults are the respective primary keys, names are the target's authored property names, cross-model triggers/targets supported. **Multi-language data** (the TS-era `multilingual` port): entity `multilingual: true` → the schema layer generates a sibling `_LANG` table (`GUID, Id, , Language` — the codbex-uoms-data convention) and the generated Java repository overlays translated values on every read for the request's `Accept-Language` (SDK `Translator`, name-based merge); the supported language set is a PLATFORM concern (`DIRIGIBLE_APPLICATION_LANGUAGES`, default `en,bg`) — the Harmonia **Region & Language** Settings entry always offers that set (an Alpine `locale` store, localStorage `codbex.harmonia.language`, sent as `Accept-Language` by the shared fetch client — one flag drives UI, data, and the Print default), while the top-level `languages: [en, bg]` only declares which languages the module PROVIDES translations for; the application shell warns about modules missing a platform language, and untranslated content falls back to the default; translations are authored as seeds with `language: bg`, and large data sets reference an authored CSV via seed `file: data/x.csv` (subfolder mandatory — root `.csv` is scrub-owned) instead of inline rows. A master owning an `*Item` composition child renders as the **document (header-items) layout** (`MANAGE_DOCUMENT` + `documentItemsEntity`, `uiDocumentModels`), with `aggregate: true` fields shown in the totals footer. `IntentNaming.upperSnake` collapses kebab/space/`.`/`/` separators so a hyphenated model name yields a valid SQL identifier (`sales-invoices` -> `SALES_INVOICES`). Worked example: `dirigiblelabs/sample-intent-multi-model` (six interdependent projects + a navigation-groups project). +**First-class document numbering (`number:` + the `.numbers` artefact, `engine-numbering`):** a string field may declare `number: { series: Sales Invoice, per: Company, stampOn: create|issue }` — the intent references a **series by name only**; the number's shape (literal prefix + sequence zero-padded to a total width, no token grammar) lives OUTSIDE the model: declared per module in an authored **`.numbers`** artefact (`{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}`, a requirement declaration like `.roles`) and configured per tenant in the application shell's Document Numbering settings (`/services/core/numbering`). One per-tenant table `DIRIGIBLE_DOCUMENT_NUMBERS` holds shape AND counter, one row per (series, partition); the synchronizer only INSERTs missing rows, Settings writes prefix/size/next, the allocator (`sdk.numbering.DocumentNumbers.next`) increments the counter — sequences are continuous, never auto-reset, and allocating an undeclared series fails loudly. `per:` partitions a series by a to-one relation's value (per company — two legal entities never share a counter). A differing cross-module re-declaration fails that artefact naming both modules; the removed `format`/`scope`/`resetOn` keys are rejected at parse. Details in the engine-intent guide's numbering bullet. + **The general platform line this enshrines:** authoring artifacts (`.edm`, `.model`, `.form`, `.report`, `.intent`) get **workspace editors + an explicit Generate**; only runtime artifacts (`.roles`, `.bpmn`, `.csvim`, `.table`, jobs, listeners, …) get **synchronizers**. Applying the synchronizer hammer to an authoring artifact generates into the registry where no modeler, Projects view, or template can use it — that mistake was made once and reverted; the inventory of synchronizers (grep `extends BaseSynchronizer`) deliberately contains no authoring formats. ## Harmonia runtime UI (`template-application-ui-harmonia-java` + `template-form-builder-harmonia`) diff --git a/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/numbering/DocumentNumbers.java b/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/numbering/DocumentNumbers.java index 728bbcb1062..518eda7226e 100644 --- a/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/numbering/DocumentNumbers.java +++ b/components/api/api-modules-java/src/main/java/org/eclipse/dirigible/sdk/numbering/DocumentNumbers.java @@ -10,52 +10,55 @@ package org.eclipse.dirigible.sdk.numbering; import java.sql.SQLException; -import java.util.Map; import org.eclipse.dirigible.components.engine.numbering.DocumentNumberService; import org.eclipse.dirigible.sdk.component.Beans; /** - * Client SDK for first-class document numbering: allocate the next gap-free number for a series and - * render it through the series' format. Backed by the platform's per-tenant counter store (the same - * store the application shell's Document Numbering settings manage), so hand-written - * {@code custom/} code and the generated stamping share one engine and one sequence. + * Allocates gap-free document numbers from a named series. * *

- * Example: {@code DocumentNumbers.next("SalesInvoice", "SI-{seq:07}", Map.of("year", "2026"))} → - * {@code SI-0000001} (then {@code SI-0000002}, …). The scope map both partitions the counter and - * feeds the format's {@code {year}} / {@code {}} tokens. + * The number's SHAPE is not passed here and is deliberately not knowable from application code: a + * series' prefix and total width are declared once in a module's {@code .numbers} artefact and are + * configurable per tenant afterwards, so one application serves jurisdictions with different + * numbering conventions without being forked or regenerated. + * + *

+ * A series may be PARTITIONED - typically per company, because two legal entities in one tenant + * each owe their own sequential range. Pass the partition value (the {@code per} relation's id) and + * that partition's own sequence is used. + * + *

+ * Example: {@code DocumentNumbers.next("Sales Invoice", String.valueOf(entity.Company))}. */ public final class DocumentNumbers { private DocumentNumbers() {} /** - * Allocate and format the next number for a series. + * Allocate the next number of an unpartitioned series. * - * @param series the series identity (documents sharing a sequence pass the same series) - * @param format the format template ({@code {seq}} / {@code {seq:0N}} / {@code {series}} / scope - * tokens), or {@code null}/blank for the default {@code {series}-{seq:06}} - * @param scope the resolved scope values partitioning the counter (empty for an unscoped series) - * @return the formatted document number + * @param series the series identity + * @return the allocated number */ - public static String next(String series, String format, Map scope) { - try { - return Beans.get(DocumentNumberService.class) - .next(series, format, scope); - } catch (SQLException e) { - throw new IllegalStateException("Failed to allocate a document number for series [" + series + "]", e); - } + public static String next(String series) { + return next(series, null); } /** - * Allocate and format the next number for an unscoped series. + * Allocate the next number of a series within a partition. * * @param series the series identity - * @param format the format template (see {@link #next(String, String, Map)}) - * @return the formatted document number + * @param partition the partition value (the {@code per} relation's id), or null when the series is + * not partitioned + * @return the allocated number */ - public static String next(String series, String format) { - return next(series, format, Map.of()); + public static String next(String series, String partition) { + try { + return Beans.get(DocumentNumberService.class) + .next(series, partition); + } catch (SQLException e) { + throw new IllegalStateException("Failed to allocate a document number for series [" + series + "]", e); + } } } diff --git a/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/synchronizer/SynchronizersOrder.java b/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/synchronizer/SynchronizersOrder.java index 40ec93a6cff..6e4429745b0 100644 --- a/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/synchronizer/SynchronizersOrder.java +++ b/components/core/core-base/src/main/java/org/eclipse/dirigible/components/base/synchronizer/SynchronizersOrder.java @@ -29,6 +29,13 @@ public interface SynchronizersOrder { /** The access. */ int ACCESS = 40; + /** + * The number-series declaration ({@code .numbers}). Deliberately before every artefact type that + * could allocate a document number during synchronization (client Java components, BPMN, CSVIM), so + * a declared series is provisioned before the first allocation can ask for it. + */ + int NUMBER_SERIES = 45; + /** The job. */ int JOB = 50; diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index c9f22706fa5..cdcd1d7d189 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -293,6 +293,7 @@ Semantics worth knowing: - **`composition: true` on a to-one relation makes it a composition.** The owning entity becomes DEPENDENT (managed as details under its parent's perspective) and the FK is NOT NULL. `required: true` *alone* only makes the FK NOT NULL - the entity stays a top-level PRIMARY association (plain dropdown, its own perspective). Composition is **opt-in**, matching the Dirigible convention (where it is an explicit `relationshipType="COMPOSITION"` and most required FKs are plain associations); `composition` already implies NOT NULL, so `required` need not also be set. Only a `manyToOne`/`oneToOne` can be a composition; an entity's *first* `composition` to-one is its composition parent. Declare the inverse `oneToMany` on the master (`Member` with `loans: oneToMany to Loan` + `Loan.member` `composition: true`) so `Loan` is managed as a detail of `Member`; the `oneToMany` itself is navigation-only (the EDM generator ignores `oneToMany`/`manyToMany` since the FK lives on the child). (This replaced the earlier "first required to-one is automatically a composition" heuristic, which made entities like a `Loan` with a required `member` FK silently nest under `Member` instead of staying top-level.) **Every to-one FK property** (composition or association) carries `relationshipType` / `relationshipCardinality` (`1_n` / `n_1` / `1_1`) / `relationshipName` (`_`) / `relationshipEntityName` / `relationshipEntityPerspectiveName` - the last two drive the generated dropdown's data URL, so they are not optional. - **`kind: setting` on an entity marks it as nomenclature / configuration.** `EntityIntent.kind` (default null = a regular managed entity); `kind: setting` makes `EdmIntentGenerator` emit the entity with `type="SETTING"` (and `entityType="SETTING"` in the mxGraph cell) instead of PRIMARY. The template engine keys on `entity.type === "SETTING"` (`service-generate/template/generateUtils.js`) to route it under the dashboard's global **Settings** perspective (it nulls the layout and sets `perspectiveName = "Settings"`), so a setting entity does NOT get its own generated perspective. Crucially the EDM generator also resolves any relation **targeting** a setting entity to the `Settings` perspective (`perspectiveFor(...)`), so an FK dropdown to a setting points at `api/Settings/` rather than a missing per-entity perspective. Settings are still real entities (own table, CSVIM seeds, FK columns) - only their UI placement differs. +- **First-class document numbering (`number:` on a string field) — the intent references a SERIES, the shape lives outside the model.** `number: { series: Sales Invoice, per: Company, stampOn: issue }` on a non-key string field gives it a platform-allocated, gap-free document number. A number series is a **tenant-level business object**: the intent (and the generated code) reference it only by name; its shape — a literal prefix + the sequence zero-padded to a total width, no token grammar — is declared once per module in a **`.numbers` artefact** at the project root (`{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}`, AUTHORED like `.roles`, never generated) and configured per tenant in the application shell's **Document Numbering** settings (`/services/core/numbering`). The `.numbers` synchronizer (`engine-numbering`, `NumberSeriesSynchronizer`, multitenant, `SynchronizersOrder.NUMBER_SERIES` = before anything allocating) INSERTs a missing series row per tenant and never updates one — the counter is live and the shape may be tenant-configured; an identical cross-module re-declaration is a skip (a shared legal range), a DIFFERING one fails that artefact loudly naming both locations; artefact DELETE never touches the series row. Sequences are CONTINUOUS and never auto-reset (BG law; an annual restart is an admin setting prefix + next in January). `per:` names a to-one relation (never an EntityStatus) whose value PARTITIONS the series — one row per (series, partition) in the per-tenant `DIRIGIBLE_DOCUMENT_NUMBERS` table, each partition its own sequence/prefix/width, materialized on first allocation from the series' base row (two legal entities in one tenant must not share a counter; identical numbers across partitions are correct). `stampOn: create` = the generated DAO allocates at insert via `sdk.numbering.DocumentNumbers.next(series[, partition])`; `stampOn: issue` = the field is created with a UUID placeholder (the `generatedUuid` auto-fill) and the generated `gen/events//NumberStamp.java` delegate replaces it at the issue step, idempotently. Allocating an UNDECLARED series fails loudly — never invent a shape. The REMOVED keys `format`/`scope`/`resetOn` are rejected on the raw YAML tree (`IntentParser.rejectRemovedNumberKeys`) because the typed Gson mapping would silently drop them — an intent still carrying `format:` must fail, not quietly lose its shape. `NumberingSupport` builds the `numbering` glue collection; `NumberingSdkIT` covers the SDK + synchronizer end-to-end. - **Calculated-field actions + entity `imports:` — server-side call-out for logic too custom to model.** Besides the neutral arithmetic `calculatedOnCreate`/`calculatedOnUpdate` expression (run by the SDK `Calc` evaluator, previewed live in the UI), a field may declare `calculatedActionOnCreate`/`calculatedActionOnUpdate` naming a Java class — a `@Component implements org.eclipse.dirigible.sdk.db.CalculatedField` (`T calculate(E entity)`). `EdmIntentGenerator.propertyMap` emits these as `calculatedActionOnCreate`/`OnUpdate` on the property (and `isCalculated()` now counts an action, so the property is marked calculated even with no expression); the **Java DAO template** (`template-application-dao-java/data/Repository.java.template`) gives the action **precedence** over the expression per slot and emits `entity. = Beans.get(.class).calculate(entity);`, importing `Beans` only when an action is present and `Calc` only when an expression is. An action runs **server-side only** (no client mirror). To reference the action by simple name, the entity declares `imports:` (a multi-line string of Java `import ...;` lines); `EdmIntentGenerator` Base64-encodes it into the `.model` entity's `importsCode` (matching the EDM editor's serialization), which the DAO template's `parameterUtils` decodes and emits into the repository's import block. The implementation is **hand-written under the project's `custom/` folder** (never `gen/`) — the intent layer emits no Java. The model-editor equivalents are the entity **Imports** tab and the property **Calculation** tab's *Action class* inputs (`editor-entity`). Worked example: `dirigiblelabs/sample-intent-multi-model` `sales-invoices` — `SalesInvoice.number` calls `custom/sales_invoices/SalesInvoiceNumberAction.java` (replacing the old inline `UUID.randomUUID()` expression). The SDK interface ships in `api-modules-java` (`org.eclipse.dirigible.sdk.db.CalculatedField`). - **Decision steps**: `if` + `then` are mandatory; `else` is optional and receives the gateway-default flow (so the conditioned branch can actually be skipped - without `else` the default falls through to the next step in the chain). `then`/`else` must name a declared step or the literal `end`; the parser validates this so a typo fails at parse time instead of producing BPMN Flowable rejects. - **`setField` service task + `next` step routing (declarative field-set glue).** A `serviceTask` with `args: { setField: , value: }` sets a `string`/`text` field of the process's **trigger entity** to a literal value, generated as a `gen/events//.java` `JavaDelegate` (`SetFieldSupport` → the `setters` glue collection → `SetField.java.template`) instead of scaffolding a hand-written `custom.` stub - it persists the set column via the targeted single-column `updateProperty(id, "", value)` (a workflow write, not a user edit, so it must not re-fire `onUpdate` reactions; only the set column is in the UPDATE statement, so a concurrent write to any other column cannot be reverted). The canonical use is an approve/reject outcome: the form completes the task with the chosen `action` as a process variable, a `decision` branches on `action == 'approve'`, and the two branches are `setField` tasks (`status=ACTIVE` / `status=REJECTED`). **`args: { next: }`** on any step overrides its linear successor - needed because the BPMN generator builds a **linear** chain, so without it the first branch (`activate`) would fall through into the second (`reject`); `next: done` makes the branches converge. The `then`/`else` fall-through is deliberately NOT auto-converted to a diamond (LoanApproval's `curatorReview` relies on falling through to `notifyMember`), so convergence is explicit via `next`. Scope: literal string values only (the parser validates `setField` is a string/text field of the trigger entity and that `value` is present; `next` must name a declared step or `end`). Non-string fields and expression values are future work. 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 index 0cde32f71a2..792b648c5a2 100644 --- 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 @@ -50,20 +50,15 @@ static List> buildNumbering(IntentModel model, Map 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); + // The partition FK property the stamp reads off the entity ("" = tenant-wide series). + descriptor.put("per", number.getPer() == null || number.getPer() + .isBlank() ? "" : IntentNaming.pascalCase(number.getPer())); numbering.add(descriptor); } } 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 57d6744a8a0..6db39bb9b62 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 @@ -977,17 +977,10 @@ private static Map propertyMap(String entityName, FieldIntent fi // 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); + // `per` names the to-one whose value partitions the series; the generated code reads that FK + // property off the entity. Empty = one sequence for the whole tenant. + p.put("numberPer", notBlank(number.getPer()) ? IntentNaming.pascalCase(number.getPer()) : ""); p.put("isReadOnlyProperty", "true"); if ("issue".equalsIgnoreCase(number.getStampOn())) { p.put("numberStampOn", "issue"); diff --git a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/NumberIntent.java b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/NumberIntent.java index 1327e701f6c..44ce80a3dc3 100644 --- a/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/NumberIntent.java +++ b/components/engine/engine-intent/src/main/java/org/eclipse/dirigible/components/intent/model/NumberIntent.java @@ -9,47 +9,41 @@ */ package org.eclipse.dirigible.components.intent.model; -import java.util.ArrayList; -import java.util.List; - /** - * First-class document numbering for a string field: the platform maintains a gap-free counter per - * {@link #series} (partitioned by {@link #scope}) and stamps the formatted number onto the field, - * either at insert ({@code stampOn: create}) or at a modeled issue step ({@code stampOn: issue}, a - * placeholder holds the slot until then; the stamp is idempotent so a re-issue keeps the number). - * Replaces the hand-written placeholder action + {@code generateNumber} service-task delegate. + * First-class document numbering on a string field: the platform stamps a gap-free number onto it, + * either at insert ({@code stampOn: create}) or at a modeled issue step ({@code stampOn: issue}, + * where a placeholder holds the slot until then and the stamp is idempotent so a re-issue keeps the + * number). + * + *

+ * The intent declares only a REFERENCE to a series - never how the number looks. The shape (prefix + * + total width) belongs to the module's {@code .numbers} artefact and, per tenant, to the Document + * Numbering settings: baking a format into the model forced a country that wants a different prefix + * to fork the application and regenerate it. Several fields may reference the SAME series - a sales + * invoice, a credit note and a debit note sharing one legal number range reference it once each, + * and the series is defined once. */ public class NumberIntent { /** - * The counter identity. Documents that must share one running sequence (e.g. invoices + credit + - * debit notes) name the same series. Mandatory. + * The series this field draws from (e.g. {@code Sales Invoice}). Its prefix and width are defined + * once per module in the {@code .numbers} artefact and are configurable per tenant afterwards. + * Mandatory. */ private String series; /** - * Format template over {@code {seq}} (zero-pad via {@code {seq:07}}) plus scope tokens - * ({@code {year}}, {@code {}}). Optional; defaults to {@code "{series}-{seq:06}"}. - */ - private String format; - - /** - * The counter is partitioned by these - a sibling field name of the same entity and/or the literal - * {@code year}; each distinct combination has its own running counter. Empty → one counter per - * series. - */ - private List scope = new ArrayList<>(); - - /** - * When set to {@code year}, the counter restarts as the year scope rolls over. Requires - * {@code year} in {@link #scope}. Optional. + * Optional name of a to-one relation of the same entity whose value PARTITIONS the series: each + * distinct value gets its own sequence, prefix and width. The canonical use is {@code per: Company} + * - two legal entities in one tenant each owe their own sequential range, so they must not share a + * counter. The value never appears IN the number; it only selects which sequence to draw from. + * Absent = a single sequence for the whole tenant. */ - private String resetOn; + private String per; /** - * When the number is stamped: {@code create} (numbered immediately on insert) or {@code issue} (a - * placeholder holds the slot; the real number is stamped at the modeled issue transition, and the - * stamp is idempotent). Optional; defaults to {@code create}. + * When the number is stamped: {@code create} (at insert, by the generated repository) or + * {@code issue} (at a modeled issue step, by the generated delegate). */ private String stampOn; @@ -61,28 +55,12 @@ public void setSeries(String series) { this.series = series; } - public String getFormat() { - return format; - } - - public void setFormat(String format) { - this.format = format; - } - - public List getScope() { - return scope; - } - - public void setScope(List scope) { - this.scope = scope; - } - - public String getResetOn() { - return resetOn; + public String getPer() { + return per; } - public void setResetOn(String resetOn) { - this.resetOn = resetOn; + public void setPer(String per) { + this.per = per; } public String getStampOn() { 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 064ada5fdd9..b936af5a061 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 @@ -139,6 +139,7 @@ public static IntentModel parse(String yaml) { if (tree == null) { return new IntentModel(); } + rejectRemovedNumberKeys(tree); String json = GSON.toJson(tree); IntentModel model; try { @@ -2124,10 +2125,57 @@ && toOneRelationByName(target, condition.getKey()) == null) { */ /** * A {@code number:} declaration must sit on a non-key string field, name a {@code series}, - * use a known {@code stampOn} ({@code create}/{@code issue}), and its {@code scope} entries must be - * {@code year} or sibling field/relation names; {@code resetOn} supports only {@code year} and - * needs {@code year} in scope. + * use a known {@code stampOn} ({@code create}/{@code issue}), and an optional {@code per} must name + * a non-status to-one relation of the entity (the series partition, e.g. {@code Company}). The + * removed keys ({@code format}/{@code scope}/{@code resetOn}) are rejected on the raw YAML tree in + * {@code rejectRemovedNumberKeys} - the typed mapping would silently drop them. */ + /** + * Rejects the REMOVED {@code number:} keys ({@code format}, {@code scope}, {@code resetOn}) on the + * raw YAML tree, before the typed Gson mapping silently drops them. An intent still carrying + * {@code format:} would otherwise "parse fine" and quietly lose the author's shape - the exact + * silent failure this feature forbids everywhere else. + * + * @param tree the SnakeYAML-loaded raw tree + * @throws IntentValidationException naming every removed key found, with the migration target + */ + private static void rejectRemovedNumberKeys(Object tree) { + if (!(tree instanceof Map root)) { + return; + } + List issues = new ArrayList<>(); + if (root.get("entities") instanceof List entities) { + for (Object entityNode : entities) { + if (!(entityNode instanceof Map entity) || !(entity.get("fields") instanceof List fields)) { + continue; + } + for (Object fieldNode : fields) { + if (!(fieldNode instanceof Map field) || !(field.get("number") instanceof Map number)) { + continue; + } + String subject = "entity [" + entity.get("name") + "] field [" + field.get("name") + "]"; + if (number.containsKey("format")) { + issues.add(subject + " number declares `format` - removed: a number is prefix + zero-padded sequence, and its" + + " shape (prefix, size) is declared in the module's `.numbers` artefact and configured per tenant in" + + " the Document Numbering settings, never in the model"); + } + if (number.containsKey("scope")) { + issues.add(subject + " number declares `scope` - removed: partition a series with `per: `" + + " (e.g. `per: Company`) instead"); + } + if (number.containsKey("resetOn")) { + issues.add(subject + " number declares `resetOn` - removed: sequences are continuous and never auto-reset;" + + " a jurisdiction that restarts numbering is an administrator setting the prefix and the next value" + + " in the Document Numbering settings"); + } + } + } + } + if (!issues.isEmpty()) { + throw new IntentValidationException(issues); + } + } + private static void validateNumber(EntityIntent entity, String subject, FieldIntent field, List issues) { NumberIntent number = field.getNumber(); if (field.isPrimaryKey()) { @@ -2138,38 +2186,24 @@ private static void validateNumber(EntityIntent entity, String subject, FieldInt issues.add(subject + " declares number but only a string field can carry a document number (got [" + field.getType() + "])"); } if (isBlank(number.getSeries())) { - issues.add(subject + " number requires `series`: the counter identity (documents sharing a sequence name the same series)"); + issues.add(subject + " number requires `series`: the series this field draws from (several fields may reference the same" + + " series to share one running sequence). Its prefix and width are defined in the module's `.numbers` artefact."); } String stampOn = number.getStampOn(); if (!isBlank(stampOn) && !"create".equals(stampOn) && !"issue".equals(stampOn)) { issues.add(subject + " number `stampOn` must be `create` or `issue`, got [" + stampOn + "]"); } - java.util.Set siblings = new java.util.LinkedHashSet<>(); - for (FieldIntent sibling : entity.getFields()) { - if (sibling.getName() != null) { - siblings.add(sibling.getName()); - } - } - for (RelationIntent relation : entity.getRelations()) { - if (relation.getName() != null) { - siblings.add(relation.getName()); - } - } - boolean scopeHasYear = false; - List scope = number.getScope() == null ? List.of() : number.getScope(); - for (String entry : scope) { - if ("year".equalsIgnoreCase(entry)) { - scopeHasYear = true; - } else if (!siblings.contains(entry)) { - issues.add(subject + " number `scope` entry [" + entry + "] must be `year` or a sibling field/relation of [" - + entity.getName() + "]"); - } - } - if (!isBlank(number.getResetOn())) { - if (!"year".equalsIgnoreCase(number.getResetOn())) { - issues.add(subject + " number `resetOn` supports only `year`, got [" + number.getResetOn() + "]"); - } else if (!scopeHasYear) { - issues.add(subject + " number `resetOn: year` requires `year` in `scope`"); + // `per` partitions the series - each value of the named to-one gets its own sequence. It must be a + // relation, not a field: the partition identifies a RECORD (the company that owes the range), and a + // scalar would silently change the partition when someone edits it. + if (!isBlank(number.getPer())) { + RelationIntent partition = toOneRelationByName(entity, number.getPer()); + if (partition == null) { + issues.add(subject + " number `per` [" + number.getPer() + "] is not a to-one relation of [" + entity.getName() + + "] - it names the relation whose value partitions the series (e.g. `per: Company`)"); + } else if (partition.isEntityStatus()) { + issues.add(subject + " number `per` [" + number.getPer() + "] is an EntityStatus - a status must not partition a number" + + " series, or the number would depend on the document's state"); } } } 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 c5b59ee1807..d5b629617a2 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 @@ -316,6 +316,36 @@ composition is opt-in. 3. Use an action only when a neutral expression cannot express it; for sums/totals keep the expression so the value previews in the UI. +**First-class document numbering (`number:` on a string field):** +`- { name: number, type: string, function: DocumentTitle, number: { series: Sales Invoice, per: Company, stampOn: issue } }` +gives the field a platform-allocated, gap-free document number. The intent declares only a +**reference to a series** - never how the number looks: + +- `series` (mandatory) - the series name the field draws from. A number series is a **tenant-level + business object**: its shape (a literal prefix + the sequence zero-padded to a total width, e.g. + `SI00000042`) is declared once per module in a **`.numbers` artefact** at the project root + (authored by hand, not generated - like `.roles`): + `{"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}`. The declaration only + provisions a tenant that has no such series yet; each tenant then configures prefix, width and the + next value in the application shell's **Document Numbering** settings. Sequences are continuous + and never auto-reset - a jurisdiction that restarts numbering each January does it by setting the + prefix and the next value there. Several fields may reference the SAME series (a sales invoice, + credit note and debit note sharing one legal range); two modules may declare the same series only + identically, else that artefact fails at publish. +- `per` (optional) - a to-one relation of the entity whose value PARTITIONS the series (canonically + `per: Company`): each partition value gets its own sequence, so two legal entities in one tenant + never share a counter. Identical numbers across partitions are correct. Never an `EntityStatus` + relation. +- `stampOn` - `create` (the generated repository allocates at insert) or `issue` (the document is + created with a UUID placeholder and a generated delegate replaces it at the modeled issue step, + idempotently - a re-issue after an amend keeps the number). Use `issue` for legal documents whose + number must only exist once issued. + +The removed keys `format`, `scope` and `resetOn` are REJECTED at parse time - shape lives in +`.numbers` + settings, partitioning is `per:`, and there is no auto-reset. Prefer `number:` over a +hand-written `calculatedActionOnCreate` number action or a number-generator `delegate:` step for +document numbers. + **Audit columns:** `audit: true` on an entity adds the four standard audit columns (`CreatedAt`, `CreatedBy`, `UpdatedAt`, `UpdatedBy`), populated by the platform's audit annotations. diff --git a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSendDocumentTest.java b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSendDocumentTest.java index 29c37079d2b..435080deaa9 100644 --- a/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSendDocumentTest.java +++ b/components/engine/engine-intent/src/test/java/org/eclipse/dirigible/components/intent/generator/GlueSendDocumentTest.java @@ -49,7 +49,7 @@ class GlueSendDocumentTest { - name: number type: string documentTitle: true - number: { series: Invoice, format: "INV{seq:07}", stampOn: create } + number: { series: Invoice, stampOn: create } - { name: paid, type: decimal } relations: - { name: Customer, kind: manyToOne, to: Customer } 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 41ef1313f2f..33b9180c7d5 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 @@ -80,19 +80,18 @@ void customerEmitsCrossModelProjectionsAndForeignKeys() { @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 } } - """; + String yaml = """ + name: billing + entities: + - name: SalesInvoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, number: { series: SalesInvoice, stampOn: issue } } + - name: Proforma + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, number: { series: Proforma, stampOn: create } } + """; Map model = EdmIntentGenerator.buildModelJsonForTest(IntentParser.parse(yaml), "billing"); List> entities = entities(model); @@ -107,8 +106,13 @@ void numberFieldEmitsStampMarkers() { 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")); + // The model carries the series REFERENCE and (optionally) the partition - never the shape. The + // prefix and width live in the .numbers artefact and the tenant's settings, so one application + // serves jurisdictions with different conventions without being regenerated. + assertEquals("Proforma", pfNumber.get("numberSeries")); + assertNull(pfNumber.get("numberFormat"), "the model must not carry a number format"); + assertNull(pfNumber.get("numberScope"), "the model must not carry a scope/token list"); } @Test 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 8d240425790..689a2d25ddb 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 @@ -94,30 +94,31 @@ void monthAndWeekAreAcceptedFieldTypesWhileAnUnknownTypeIsRejected() { @Test void firstClassNumberingParsesAndValidates() { - String ok = - """ - name: billing - entities: - - name: SalesInvoice - fields: - - { name: id, type: integer, primaryKey: true, generated: true } - - { name: date, type: date } - - { name: number, type: string, number: { series: SalesInvoice, format: "SI-{seq:07}", scope: [year], resetOn: year, stampOn: issue } } - relations: - - { name: Company, kind: manyToOne, to: SalesInvoice } - """; + String ok = """ + name: billing + entities: + - name: Company + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: name, type: string } + - name: SalesInvoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: date, type: date } + - { name: number, type: string, number: { series: Sales Invoice, per: Company, stampOn: issue } } + relations: + - { name: Company, kind: manyToOne, to: Company } + """; NumberIntent number = IntentParser.parse(ok) .getEntities() - .get(0) + .get(1) .getFields() .get(2) .getNumber(); - assertEquals("SalesInvoice", number.getSeries()); - assertEquals("SI-{seq:07}", number.getFormat()); + // The intent references a series and (optionally) what partitions it - never how it looks. + assertEquals("Sales Invoice", number.getSeries()); + assertEquals("Company", number.getPer()); assertEquals("issue", number.getStampOn()); - assertTrue(number.getScope() - .contains("year")); - assertEquals("year", number.getResetOn()); // number on a non-string field is rejected. String onDate = ok.replace("- { name: number, type: string, number:", "- { name: bad, type: date, number:"); @@ -134,13 +135,53 @@ void firstClassNumberingParsesAndValidates() { .anyMatch(i -> i.contains("stampOn")), "an unknown stampOn must be rejected"); - // resetOn: year without year in scope is rejected. - String badReset = ok.replace("scope: [year], resetOn: year", "scope: [Company], resetOn: year"); - assertTrue(assertThrows(IntentValidationException.class, () -> IntentParser.parse(badReset)).getIssues() - .stream() - .anyMatch(i -> i.contains( - "resetOn: year` requires")), - "resetOn: year without year in scope must be rejected"); + // `per` must name a to-one RELATION: the partition identifies the record that owes the range + // (typically the company), and a scalar would silently change partition when someone edits it. + String badPer = ok.replace("per: Company", "per: date"); + assertTrue(assertThrows(IntentValidationException.class, () -> IntentParser.parse(badPer)).getIssues() + .stream() + .anyMatch(i -> i.contains( + "number `per` [date] is not a to-one relation")), + "a `per` that is not a to-one relation must be rejected"); + } + + /** + * The removed number keys must fail LOUDLY on the raw YAML: the typed Gson mapping has no fields + * for them, so without the raw-tree check an intent still carrying {@code format:} would parse + * "successfully" and silently lose the author's shape. + */ + @Test + void removedNumberKeysAreRejectedLoudlyNotSilentlyDropped() { + String template = """ + name: billing + entities: + - name: SalesInvoice + fields: + - { name: id, type: integer, primaryKey: true, generated: true } + - { name: number, type: string, number: { series: Sales Invoice%s } } + """; + + String withFormat = template.formatted(", format: \"SI{seq:07}\""); + assertTrue(assertThrows(IntentValidationException.class, () -> IntentParser.parse(withFormat)).getIssues() + .stream() + .anyMatch(i -> i.contains("`format`") + && i.contains(".numbers")), + "number `format` must be rejected pointing at the .numbers artefact"); + + String withScope = template.formatted(", scope: { company: Company }"); + assertTrue(assertThrows(IntentValidationException.class, () -> IntentParser.parse(withScope)).getIssues() + .stream() + .anyMatch(i -> i.contains("`scope`") + && i.contains("per:")), + "number `scope` must be rejected pointing at `per:`"); + + String withResetOn = template.formatted(", resetOn: year"); + assertTrue(assertThrows(IntentValidationException.class, () -> IntentParser.parse(withResetOn)).getIssues() + .stream() + .anyMatch(i -> i.contains( + "`resetOn`") + && i.contains("continuous")), + "number `resetOn` must be rejected - sequences are continuous"); } @Test 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 index d527641b3fa..5e2296af754 100644 --- 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 @@ -25,9 +25,15 @@ 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). + * Management surface for the current tenant's document-number series, backing the application + * shell's "Document Numbering" settings page. Lists every series the tenant has - each declared by + * a {@code .numbers} artefact and provisioned per tenant by its synchronizer - and lets an + * administrator set its shape (prefix + total width) and the next value it will allocate. + * + *

+ * Configuring the shape here is what lets one application serve jurisdictions with different + * numbering conventions without forking or regenerating it, and setting prefix + next together is + * how a market that restarts numbering every January does so. */ @RestController @RequestMapping(BaseEndpoint.PREFIX_ENDPOINT_CORE + "numbering") @@ -40,25 +46,27 @@ public class DocumentNumberEndpoint extends BaseEndpoint { this.service = service; } - /** The current tenant's counters. */ + /** The current tenant's series. */ @GetMapping - public ResponseEntity> list() { + public ResponseEntity> list() { try { - return ResponseEntity.ok(service.list()); + return ResponseEntity.ok(service.list() + .stream() + .map(row -> new SeriesView(row.series(), row.partition(), row.prefix(), row.size(), + row.counter(), row.counter() + 1, + DocumentNumberService.render(row.prefix(), row.size(), row.counter() + 1))) + .toList()); } catch (SQLException ex) { - throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to list document-number counters", ex); + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to list document-number series", ex); } } - /** Set the next value a (series, scope) counter will allocate. */ + /** Set the next value a series 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"); - } + requireSeries(request == null ? null : request.series()); try { - service.setNext(request.series(), request.scope() == null ? "" : request.scope(), request.next()); + service.setNext(request.series(), request.partition(), request.next()); return ResponseEntity.noContent() .build(); } catch (SQLException ex) { @@ -66,13 +74,60 @@ public ResponseEntity setNext(@RequestBody SetNextRequest request) { } } + /** Set a series' shape: the literal prefix and the total rendered width. */ + @PutMapping("/shape") + public ResponseEntity setShape(@RequestBody SetShapeRequest request) { + requireSeries(request == null ? null : request.series()); + try { + service.setShape(request.series(), request.partition(), request.prefix(), request.size()); + return ResponseEntity.noContent() + .build(); + } catch (IllegalArgumentException ex) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, ex.getMessage(), ex); + } catch (SQLException ex) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to set the document-number shape", ex); + } + } + + private static void requireSeries(String series) { + if (series == null || series.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "series is required"); + } + } + + /** + * Request body for setting a series' next value. + * + * @param series the series identity + * @param partition the partition value ({@code ""}/{@code null} when unpartitioned) + * @param next the next value the series should allocate + */ + record SetNextRequest(String series, String partition, long next) { + } + + /** + * Request body for setting a series' shape. + * + * @param series the series identity + * @param partition the partition value ({@code ""}/{@code null} when unpartitioned) + * @param prefix the literal prefix - an EMPTY string is meaningful (no prefix at all) + * @param size the total rendered width + */ + record SetShapeRequest(String series, String partition, String prefix, int size) { + } + /** - * Request body for setting a counter's next value. + * One series as the settings page sees it. * * @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 + * @param partition the partition value + * @param prefix the literal prefix + * @param size the total rendered width + * @param counter the last allocated value + * @param next the value the next document will get + * @param example the next number as it will actually render - so an administrator sees the effect + * of a prefix or width change without issuing a document */ - record SetNextRequest(String series, String scope, long next) { + record SeriesView(String series, String partition, String prefix, int size, long counter, long next, String example) { } } 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 index 103d42b47dc..8cea4fb6bdd 100644 --- 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 @@ -10,28 +10,35 @@ 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}}). + * Document numbering runtime: allocates the next value of a series and renders it. + * + *

+ * A series is {@code prefix + sequence zero-padded to size}, and nothing else - no token grammar. + * The sequence is CONTINUOUS and never reset: jurisdictions that require an annual restart get it + * by an administrator setting the prefix and the next value in January, which is visible and + * auditable, rather than by a hidden reset rule that could mint a number twice. + * + *

+ * A series may be PARTITIONED (intent {@code per: Company}): each partition value has its own row, + * so its own sequence, prefix and width. Two legal entities in one tenant each owe their own + * sequential range and must not share a counter. Identical numbers across partitions are correct - + * a number must be unique within a company's book, not across companies. + * + *

+ * A series must be DECLARED (a {@code .numbers} artefact, synchronized per tenant) before it can be + * allocated from. Allocating from an unknown series fails loudly: a document must never carry a + * number in a shape nobody chose. */ @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+))?\\}"); + /** Widest renderable number; the stored column is VARCHAR(100) and no series needs more. */ + static final int MAX_SIZE = 40; private final DocumentNumberStore store; @@ -40,65 +47,113 @@ public class DocumentNumberService { } /** - * 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. + * Allocate and render the next number of an unpartitioned series. * - * @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 + * @param series the series identity + * @return the rendered 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); + public String next(String series) throws SQLException { + return next(series, null); + } + + /** + * Allocate and render the next number of a series, within a partition. + * + * @param series the series identity + * @param partition the value of the {@code per} relation, or null for an unpartitioned series + * @return the rendered number + * @throws SQLException if the allocation fails + * @throws IllegalStateException if the series is not declared for this tenant + */ + public String next(String series, String partition) throws SQLException { + DocumentNumberStore.Allocation allocation = store.allocate(series, partition == null ? "" : partition); + return render(allocation.prefix(), allocation.size(), allocation.value()); + } + + /** + * Renders {@code prefix + value} zero-padded so the whole number is {@code size} characters. A + * value that outgrows the width is NOT truncated - it renders in full, because a wrong number is + * worse than a wide one, and the overflow is visible enough to be corrected. + * + * @param prefix the literal prefix (may be empty) + * @param size the total width + * @param value the allocated sequence value + * @return the rendered number + */ + static String render(String prefix, int size, long value) { + String safePrefix = prefix == null ? "" : prefix; + int digits = Math.max(1, size - safePrefix.length()); + return safePrefix + String.format("%0" + digits + "d", value); } - /** All counter rows of the current tenant (for the management surface). */ - public List list() throws SQLException { + /** + * Every series row of the current tenant, for the management surface. + * + * @return the series rows + * @throws SQLException if the read fails + */ + public List list() throws SQLException { return store.list(); } /** - * Set the next value a (series, scope) counter will allocate (e.g. start invoices at 1000). + * Provisions a declared series for this tenant if it has none yet - the synchronizer's write. An + * existing row is left untouched: its counter is live and its prefix/width may have been configured + * by an administrator, and neither is the artefact's business. * * @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}) + * @param prefix the declared default prefix + * @param size the declared default width * @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)); + public void provision(String series, String prefix, int size) throws SQLException { + store.provision(series, "", prefix, size); } - /** The counter partition key: the scope values joined by {@code |}; {@code ""} when unscoped. */ - static String scopeKey(Map scope) { - return String.join("|", scope.values()); + /** + * Sets the next value a series will allocate (e.g. restart at 1 in January). + * + * @param series the series identity + * @param partition the partition value ({@code ""} for unpartitioned) + * @param next the next value to allocate + * @throws SQLException if the write fails + */ + public void setNext(String series, String partition, long next) throws SQLException { + store.setCounter(series, partition == null ? "" : partition, Math.max(0, next - 1)); } /** - * 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). + * Sets the tenant's prefix and width for a series. + * + * @param series the series identity + * @param partition the partition value ({@code ""} for unpartitioned) + * @param prefix the literal prefix (empty is meaningful - no prefix at all) + * @param size the total width + * @throws SQLException if the write fails + * @throws IllegalArgumentException if the width cannot hold the prefix plus a digit + */ + public void setShape(String series, String partition, String prefix, int size) throws SQLException { + String safePrefix = prefix == null ? "" : prefix; + validateShape(safePrefix, size); + store.setShape(series, partition == null ? "" : partition, safePrefix, size); + } + + /** + * Validates a shape - shared by the management surface and the {@code .numbers} declaration parse, + * so an artefact cannot declare a shape the settings page would refuse. + * + * @param prefix the literal prefix (null reads as none) + * @param size the total width + * @throws IllegalArgumentException if the width cannot hold the prefix plus a digit, or is absurd */ - 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)); + static void validateShape(String prefix, int size) { + String safePrefix = prefix == null ? "" : prefix; + if (size <= safePrefix.length()) { + throw new IllegalArgumentException("Size [" + size + "] leaves no room for a sequence after the prefix [" + safePrefix + "]"); + } + if (size > MAX_SIZE) { + throw new IllegalArgumentException("Size [" + size + "] exceeds the maximum of " + MAX_SIZE); } - 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 index 6112c53668e..0695c5ca0e0 100644 --- 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 @@ -14,7 +14,10 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Locale; +import java.util.Set; import org.eclipse.dirigible.components.data.sources.manager.DataSourcesManager; import org.eclipse.dirigible.database.sql.DataType; @@ -24,10 +27,30 @@ 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. + * Per-tenant document-number series store: {@code DIRIGIBLE_DOCUMENT_NUMBERS} in the tenant-routed + * default datasource, so each tenant owns its series, shapes and counters. + * + *

+ * One row per (series, partition) - the partition being the value of the intent's {@code per} + * relation, {@code ""} when the series is not partitioned. The row holds BOTH the shape (prefix, + * size) and the live counter, because they are one 1:1 fact about one series; every writer touches + * only its own columns: + * + *

    + *
  • the {@code .numbers} synchronizer INSERTS a row that does not exist (never updates one - the + * counter is live and the shape may have been configured);
  • + *
  • the management surface writes {@code PREFIX} / {@code SIZE} / the counter reset;
  • + *
  • allocation writes only {@code COUNTER}, via {@code COUNTER = COUNTER + 1} - with one + * deliberate exception: the first allocation for a NEW PARTITION of a declared series materializes + * that partition's row, copying the shape from the series' base ({@code ""}-partition) row. + * Partition values are data (company ids), so no artefact can pre-provision them; the base row is + * the tenant's configured default they inherit. The copy happens once, at row birth - it never + * overwrites anything.
  • + *
+ * + *

+ * Allocating from an UNDECLARED series (no base row) FAILS - a series must be declared before a + * document can carry a number in its shape. */ @Component class DocumentNumberStore { @@ -38,10 +61,20 @@ class DocumentNumberStore { 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\""; + /** + * The partition key - the value of the intent's {@code per} relation. Keeps its original column + * name: the column's ROLE (the counter partition) is unchanged, only what may feed it narrowed from + * an arbitrary token map to one typed relation, so renaming it would buy nothing and cost a + * migration. + */ + private static final String COLUMN_PARTITION = "DOCUMENT_SCOPE"; + private static final String QUOTED_PARTITION = "\"DOCUMENT_SCOPE\""; private static final String COLUMN_COUNTER = "DOCUMENT_COUNTER"; private static final String QUOTED_COUNTER = "\"DOCUMENT_COUNTER\""; + private static final String COLUMN_PREFIX = "DOCUMENT_PREFIX"; + private static final String QUOTED_PREFIX = "\"DOCUMENT_PREFIX\""; + private static final String COLUMN_SIZE = "DOCUMENT_SIZE"; + private static final String QUOTED_SIZE = "\"DOCUMENT_SIZE\""; private final DataSourcesManager dataSourcesManager; @@ -50,45 +83,64 @@ class DocumentNumberStore { } /** - * A counter row - the current value of one (series, scope) counter in the current tenant. + * One series row of 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 + * @param partition the partition value ({@code ""} when unpartitioned) + * @param prefix the literal prefix + * @param size the total rendered width + * @param counter the last allocated value */ - record Counter(String series, String scope, long counter) { + record Series(String series, String partition, String prefix, int size, 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. + * One allocation: the value with the shape it was allocated under, read in the same transaction so + * a number cannot straddle a shape change. + * + * @param value the allocated value + * @param prefix the prefix in force + * @param size the width in force + */ + record Allocation(long value, String prefix, int size) { + } + + /** + * Allocate the next value of (series, partition) - gap-free per tenant; 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 + * @param partition the partition value ({@code ""} when unpartitioned) + * @return the allocation * @throws SQLException if the allocation fails + * @throws IllegalStateException if the series is not declared for this tenant */ - long allocate(String series, String scope) throws SQLException { + Allocation allocate(String series, String partition) throws SQLException { try (Connection connection = dataSourcesManager.getDefaultDataSource() .getConnection()) { ensureTableExists(connection); + // Still in autocommit: a new partition of a declared series materializes its row here, + // inheriting the tenant's configured shape from the base row. Done OUTSIDE the increment + // transaction so a lost duplicate-insert race cannot poison it (PostgreSQL aborts a + // transaction on any failed statement); rows are never deleted, so exists-then-increment + // cannot un-happen. + if (!partition.isEmpty() && !exists(connection, series, partition)) { + materializePartition(connection, series, partition); + } 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); - } + if (increment(connection, series, partition) == 0) { + // No row: the series was never declared. Refusing is the point - inventing a default + // here would stamp a number in a shape nobody chose. + throw new IllegalStateException( + "Document-number series [" + series + "]" + (partition.isEmpty() ? "" : " partition [" + partition + "]") + + " is not declared for this tenant - declare it in a .numbers artefact"); } - long value = read(connection, series, scope); + Allocation allocation = read(connection, series, partition); connection.commit(); - return value; - } catch (SQLException ex) { + return allocation; + } catch (SQLException | IllegalStateException ex) { connection.rollback(); throw ex; } finally { @@ -97,20 +149,35 @@ long allocate(String series, String scope) throws SQLException { } } - /** All counter rows of the current tenant, insertion-ordered. */ - List list() throws SQLException { + /** + * Every series row of the current tenant. + * + * @return the rows + * @throws SQLException if the read fails + */ + List list() throws SQLException { try (Connection connection = dataSourcesManager.getDefaultDataSource() .getConnection()) { - ensureTableExists(connection); + // Reads never bootstrap (no DDL on a GET path): the table is created and upgraded by the + // WRITERS (the synchronizer's provision, the settings writes, the allocator). Before any + // of them ran there is nothing to list; on a pre-upgrade table the not-yet-added shape + // columns read as defaults until the first write adds them. + if (!SqlFactory.getNative(connection) + .existsTable(connection, TABLE_NAME)) { + return List.of(); + } String sql = SqlFactory.getNative(connection) .select() .column("*") .from(TABLE_NAME) .build(); - List result = new ArrayList<>(); + List result = new ArrayList<>(); try (PreparedStatement statement = connection.prepareStatement(sql); ResultSet resultSet = statement.executeQuery()) { + Set present = presentColumns(resultSet); + boolean hasShape = present.contains(COLUMN_PREFIX) && present.contains(COLUMN_SIZE); while (resultSet.next()) { - result.add(new Counter(resultSet.getString(COLUMN_SERIES), resultSet.getString(COLUMN_SCOPE), + result.add(new Series(resultSet.getString(COLUMN_SERIES), resultSet.getString(COLUMN_PARTITION), + hasShape ? resultSet.getString(COLUMN_PREFIX) : "", hasShape ? resultSet.getInt(COLUMN_SIZE) : 0, resultSet.getLong(COLUMN_COUNTER))); } } @@ -118,82 +185,196 @@ List list() throws SQLException { } } + /** The upper-cased column labels of a result set. */ + private static Set presentColumns(ResultSet resultSet) throws SQLException { + Set present = new HashSet<>(); + for (int i = 1; i <= resultSet.getMetaData() + .getColumnCount(); i++) { + present.add(resultSet.getMetaData() + .getColumnLabel(i) + .toUpperCase(Locale.ROOT)); + } + return present; + } + /** - * 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. + * Insert a declared series when this tenant has none. An existing row is left ALONE - its counter + * is live and its shape may have been configured by an administrator; a re-publish must change + * neither. + * + * @param series the series identity + * @param partition the partition value ({@code ""} when unpartitioned) + * @param prefix the declared default prefix + * @param size the declared default width + * @throws SQLException if the write fails */ - void setCounter(String series, String scope, long value) throws SQLException { + void provision(String series, String partition, String prefix, int size) 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); - } + if (exists(connection, series, partition)) { + return; } + insertRow(connection, series, partition, prefix, size); } } - private int increment(Connection connection, String series, String scope) throws SQLException { - // The table is created with QUOTED (case-sensitive) identifiers, and the builder encapsulates - // the SET-target column and the WHERE-condition identifiers the same way. But the SET VALUE is a - // free expression appended verbatim (NOT encapsulated) - so its column reference must be quoted - // explicitly, otherwise a case-folding dialect (PostgreSQL lower-cases unquoted identifiers) - // fails to resolve it against the quoted-uppercase column (H2 upper-cases unquoted, so it - // happened to match). The WHERE columns stay unquoted: the builder quotes those for us (quoting - // them here would double-encapsulate into a zero-length delimited identifier). - String sql = SqlFactory.getNative(connection) - .update() - .table(TABLE_NAME) - .set(COLUMN_COUNTER, QUOTED_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(); + /** + * Materializes a NEW partition row of a declared series, inheriting the shape of the series' base + * ({@code ""}-partition) row - the tenant's configured default. Partition values are data, so this + * is the only place a partition row can be born. + * + * @param connection the connection (autocommit) + * @param series the series identity + * @param partition the new partition value + * @throws SQLException if the reads or the insert fail + * @throws IllegalStateException if the series has no base row - it was never declared + */ + private void materializePartition(Connection connection, String series, String partition) throws SQLException { + if (!exists(connection, series, "")) { + throw new IllegalStateException("Document-number series [" + series + "] partition [" + partition + + "] is not declared for this tenant - declare it in a .numbers artefact"); } + Allocation base = read(connection, series, ""); + insertRow(connection, series, partition, base.prefix(), base.size()); } - private void insert(Connection connection, String series, String scope) throws SQLException { + /** Inserts one series row with a zero counter, tolerating a concurrent identical insert. */ + private void insertRow(Connection connection, String series, String partition, String prefix, int size) throws SQLException { String sql = SqlFactory.getNative(connection) .insert() .into(TABLE_NAME) .column(COLUMN_SERIES) - .column(COLUMN_SCOPE) + .column(COLUMN_PARTITION) .column(COLUMN_COUNTER) + .column(COLUMN_PREFIX) + .column(COLUMN_SIZE) .build(); try (PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, series); - statement.setString(2, scope); - statement.setLong(3, 1L); + statement.setString(2, partition); + statement.setLong(3, 0L); + statement.setString(4, prefix == null ? "" : prefix); + statement.setInt(5, size); statement.executeUpdate(); + LOGGER.info("Provisioned document-number series [{}] partition [{}] as prefix [{}] size [{}]", series, partition, prefix, size); + } catch (SQLException duplicate) { + LOGGER.debug("Series [{}] partition [{}] was provisioned concurrently", series, partition, duplicate); + } + } + + /** + * Set the last-allocated value (the management surface's "next" minus one). + * + * @param series the series identity + * @param partition the partition value + * @param value the last-allocated value to store + * @throws SQLException if the write fails + */ + void setCounter(String series, String partition, long value) throws SQLException { + update(series, partition, COLUMN_COUNTER, statement -> statement.setLong(1, value)); + } + + /** + * Set the tenant's shape for a series. + * + * @param series the series identity + * @param partition the partition value + * @param prefix the literal prefix + * @param size the total width + * @throws SQLException if the write fails + */ + void setShape(String series, String partition, String prefix, int size) throws SQLException { + try (Connection connection = dataSourcesManager.getDefaultDataSource() + .getConnection()) { + ensureTableExists(connection); + String sql = SqlFactory.getNative(connection) + .update() + .table(TABLE_NAME) + .set(COLUMN_PREFIX, "?") + .set(COLUMN_SIZE, "?") + .where(COLUMN_SERIES + " = ? AND " + COLUMN_PARTITION + " = ?") + .build(); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, prefix); + statement.setInt(2, size); + statement.setString(3, series); + statement.setString(4, partition); + statement.executeUpdate(); + } + } + } + + /** A single-column update of one series row. */ + private void update(String series, String partition, String column, StatementBinder binder) throws SQLException { + try (Connection connection = dataSourcesManager.getDefaultDataSource() + .getConnection()) { + ensureTableExists(connection); + String sql = SqlFactory.getNative(connection) + .update() + .table(TABLE_NAME) + .set(column, "?") + .where(COLUMN_SERIES + " = ? AND " + COLUMN_PARTITION + " = ?") + .build(); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + binder.bind(statement); + statement.setString(2, series); + statement.setString(3, partition); + statement.executeUpdate(); + } + } + } + + /** Binds the first parameter of a single-column update. */ + private interface StatementBinder { + void bind(PreparedStatement statement) throws SQLException; + } + + private boolean exists(Connection connection, String series, String partition) throws SQLException { + String sql = SqlFactory.getNative(connection) + .select() + .column(COLUMN_SERIES) + .from(TABLE_NAME) + .where(COLUMN_SERIES + " = ? AND " + COLUMN_PARTITION + " = ?") + .build(); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, series); + statement.setString(2, partition); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next(); + } } } - private long read(Connection connection, String series, String scope) throws SQLException { + private int increment(Connection connection, String series, String partition) throws SQLException { + String sql = SqlFactory.getNative(connection) + .update() + .table(TABLE_NAME) + .set(COLUMN_COUNTER, QUOTED_COUNTER + " + 1") + .where(COLUMN_SERIES + " = ? AND " + COLUMN_PARTITION + " = ?") + .build(); + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, series); + statement.setString(2, partition); + return statement.executeUpdate(); + } + } + + private Allocation read(Connection connection, String series, String partition) throws SQLException { String sql = SqlFactory.getNative(connection) .select() .column(COLUMN_COUNTER) + .column(COLUMN_PREFIX) + .column(COLUMN_SIZE) .from(TABLE_NAME) - .where(COLUMN_SERIES + " = ? AND " + COLUMN_SCOPE + " = ?") + .where(COLUMN_SERIES + " = ? AND " + COLUMN_PARTITION + " = ?") .build(); try (PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, series); - statement.setString(2, scope); + statement.setString(2, partition); try (ResultSet resultSet = statement.executeQuery()) { - return resultSet.next() ? resultSet.getLong(1) : 0L; + resultSet.next(); + return new Allocation(resultSet.getLong(1), resultSet.getString(2), resultSet.getInt(3)); } } } @@ -201,14 +382,17 @@ private long read(Connection connection, String series, String scope) throws SQL private void ensureTableExists(Connection connection) throws SQLException { if (SqlFactory.getNative(connection) .existsTable(connection, TABLE_NAME)) { + addMissingColumns(connection); 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_PARTITION, DataType.VARCHAR, true, false, false, "(255)") .column(QUOTED_COUNTER, DataType.BIGINT, false, false, false) + .column(QUOTED_PREFIX, DataType.VARCHAR, false, true, false, "(64)") + .column(QUOTED_SIZE, DataType.INTEGER, false, true, false) .build(); try (PreparedStatement statement = connection.prepareStatement(sql)) { statement.executeUpdate(); @@ -222,4 +406,39 @@ private void ensureTableExists(Connection connection) throws SQLException { } } } + + /** + * Brings a table created before the shape columns existed up to date. The table is created + * create-if-absent and never dropped, so an existing deployment has only (series, partition, + * counter); each column is added independently and an "already exists" failure is tolerated, so two + * nodes racing the upgrade is harmless. + * + * @param connection the connection + * @throws SQLException if the table cannot be inspected + */ + private void addMissingColumns(Connection connection) throws SQLException { + Set present = new HashSet<>(); + try (ResultSet columns = connection.getMetaData() + .getColumns(null, null, TABLE_NAME, null)) { + while (columns.next()) { + present.add(columns.getString("COLUMN_NAME") + .toUpperCase(Locale.ROOT)); + } + } + addColumnIfMissing(connection, present, QUOTED_PREFIX, COLUMN_PREFIX, "VARCHAR(64)"); + addColumnIfMissing(connection, present, QUOTED_SIZE, COLUMN_SIZE, "INTEGER"); + } + + private void addColumnIfMissing(Connection connection, Set present, String quotedColumn, String column, String type) { + if (present.contains(column)) { + return; + } + String sql = "ALTER TABLE " + QUOTED_TABLE + " ADD COLUMN " + quotedColumn + " " + type; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.executeUpdate(); + LOGGER.info("Added document-number column [{}] using sql [{}]", column, sql); + } catch (SQLException ex) { + LOGGER.debug("Could not add document-number column [{}]; assuming a concurrent upgrade", column, ex); + } + } } diff --git a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesDeclaration.java b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesDeclaration.java new file mode 100644 index 00000000000..673a3816a6c --- /dev/null +++ b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesDeclaration.java @@ -0,0 +1,139 @@ +/* + * 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 org.eclipse.dirigible.components.base.artefact.Artefact; + +import com.google.gson.annotations.Expose; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +/** + * One series declared by a module's {@code .numbers} artefact. A number series is a TENANT-LEVEL + * business object - a module never owns one; its {@code .numbers} file declares a REQUIREMENT ("I + * need series X; if this tenant has none, provision it with this prefix and width"), exactly as a + * {@code .roles} file declares a role. The artefact's {@code name} is the series identity; the + * declared prefix and size are only the provisioning DEFAULTS - the tenant's live shape and counter + * live in {@code DIRIGIBLE_DOCUMENT_NUMBERS} and are never written back here. + */ +@Entity +@Table(name = "DIRIGIBLE_NUMBER_SERIES_DECLARATIONS") +public class NumberSeriesDeclaration extends Artefact { + + /** The Constant ARTEFACT_TYPE. */ + public static final String ARTEFACT_TYPE = "numbers"; + + /** The id. */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "DECLARATION_ID", nullable = false) + private Long id; + + /** The declared default prefix (may be empty - a prefix-less continuous number). */ + @Column(name = "DECLARATION_PREFIX", columnDefinition = "VARCHAR", nullable = false, length = 64) + @Expose + private String prefix = ""; + + /** The declared default total rendered width. */ + @Column(name = "DECLARATION_SIZE", columnDefinition = "INTEGER", nullable = false) + @Expose + private int size; + + /** + * Instantiates a new number series declaration. + * + * @param location the location + * @param name the series identity + * @param prefix the declared default prefix + * @param size the declared default width + */ + public NumberSeriesDeclaration(String location, String name, String prefix, int size) { + super(location, name, ARTEFACT_TYPE, null, null); + this.prefix = prefix; + this.size = size; + } + + /** + * Instantiates a new number series declaration. + */ + public NumberSeriesDeclaration() { + super(); + } + + /** + * Gets the id. + * + * @return the id + */ + public Long getId() { + return id; + } + + /** + * Sets the id. + * + * @param id the new id + */ + public void setId(Long id) { + this.id = id; + } + + /** + * Gets the declared default prefix. + * + * @return the prefix + */ + public String getPrefix() { + return prefix; + } + + /** + * Sets the declared default prefix. + * + * @param prefix the new prefix + */ + public void setPrefix(String prefix) { + this.prefix = prefix; + } + + /** + * Gets the declared default width. + * + * @return the size + */ + public int getSize() { + return size; + } + + /** + * Sets the declared default width. + * + * @param size the new size + */ + public void setSize(int size) { + this.size = size; + } + + /** + * To string. + * + * @return the string + */ + @Override + public String toString() { + return "NumberSeriesDeclaration {id=" + id + ", location='" + location + '\'' + ", name='" + name + '\'' + ", prefix='" + prefix + + '\'' + ", size=" + size + ", key='" + key + '\'' + '}'; + } +} diff --git a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesDeclarationRepository.java b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesDeclarationRepository.java new file mode 100644 index 00000000000..8df295c1706 --- /dev/null +++ b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesDeclarationRepository.java @@ -0,0 +1,45 @@ +/* + * 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.util.List; + +import org.eclipse.dirigible.components.base.artefact.ArtefactRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * The Interface NumberSeriesDeclarationRepository. + */ +@Repository("numberSeriesDeclarationRepository") +public interface NumberSeriesDeclarationRepository extends ArtefactRepository { + + /** + * Sets the running to all. + * + * @param running the new running to all + */ + @Override + @Modifying + @Transactional + @Query(value = "UPDATE NumberSeriesDeclaration SET running = :running") + void setRunningToAll(@Param("running") boolean running); + + /** + * Every declaration of one series, across all declaring modules. + * + * @param name the series identity + * @return the declarations + */ + List findAllByName(String name); +} diff --git a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesDeclarationService.java b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesDeclarationService.java new file mode 100644 index 00000000000..9250b86c533 --- /dev/null +++ b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesDeclarationService.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 java.util.List; + +import org.eclipse.dirigible.components.base.artefact.BaseArtefactService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * The artefact service for {@code .numbers} series declarations. + */ +@Service +@Transactional +public class NumberSeriesDeclarationService extends BaseArtefactService { + + private final NumberSeriesDeclarationRepository repository; + + NumberSeriesDeclarationService(NumberSeriesDeclarationRepository repository) { + super(repository); + this.repository = repository; + } + + /** + * Every declaration of one series, across all declaring modules - the synchronizer's + * conflict-detection read. + * + * @param name the series identity + * @return the declarations + */ + @Transactional(readOnly = true) + public List findAllByName(String name) { + return repository.findAllByName(name); + } +} diff --git a/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesSynchronizer.java b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesSynchronizer.java new file mode 100644 index 00000000000..4c4d132b814 --- /dev/null +++ b/components/engine/engine-numbering/src/main/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesSynchronizer.java @@ -0,0 +1,327 @@ +/* + * 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.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.eclipse.dirigible.components.base.artefact.ArtefactLifecycle; +import org.eclipse.dirigible.components.base.artefact.ArtefactPhase; +import org.eclipse.dirigible.components.base.artefact.ArtefactService; +import org.eclipse.dirigible.components.base.artefact.topology.TopologyWrapper; +import org.eclipse.dirigible.components.base.synchronizer.MultitenantBaseSynchronizer; +import org.eclipse.dirigible.components.base.synchronizer.SynchronizerCallback; +import org.eclipse.dirigible.components.base.synchronizer.SynchronizersOrder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +/** + * Synchronizes {@code .numbers} artefacts - a module's declared REQUIREMENT for tenant-level number + * series, the way {@code .roles} declares roles. File shape: + * + *

+ * {"series": [{"name": "Sales Invoice", "prefix": "SI", "size": 10}]}
+ * 
+ * + *

+ * Per tenant (multitenant execution), a declared series that the tenant does not have yet is + * PROVISIONED with the declared prefix/size and a zero counter; a series the tenant already has is + * left completely alone - its counter is live and its shape may have been configured by an + * administrator, and neither is the artefact's business. Two modules may declare the same series + * only IDENTICALLY (a shared legal range); a differing re-declaration fails that artefact loudly, + * naming both declaring locations - it must never silently reshape or fork a tenant's counter. + * + *

+ * DELETE removes only the declaration record. It never touches {@code DIRIGIBLE_DOCUMENT_NUMBERS}: + * allocated ranges are business history and survive any module lifecycle. + */ +@Component +@Order(SynchronizersOrder.NUMBER_SERIES) +public class NumberSeriesSynchronizer extends MultitenantBaseSynchronizer { + + /** The Constant FILE_EXTENSION_NUMBERS. */ + public static final String FILE_EXTENSION_NUMBERS = ".numbers"; + + private static final Logger LOGGER = LoggerFactory.getLogger(NumberSeriesSynchronizer.class); + + /** + * A plain Gson: the platform's JsonHelper excludes fields without {@code @Expose}, and the wrapper + * DTO below deliberately stays annotation-free. + */ + private static final Gson GSON = new Gson(); + + private final NumberSeriesDeclarationService declarationService; + private final DocumentNumberService documentNumberService; + + private SynchronizerCallback callback; + + NumberSeriesSynchronizer(NumberSeriesDeclarationService declarationService, DocumentNumberService documentNumberService) { + this.declarationService = declarationService; + this.documentNumberService = documentNumberService; + } + + /** + * Checks if is accepted. + * + * @param type the type + * @return true, if is accepted + */ + @Override + public boolean isAccepted(String type) { + return NumberSeriesDeclaration.ARTEFACT_TYPE.equals(type); + } + + /** + * Parses one {@code .numbers} file into one declaration artefact per series entry. + * + * @param location the location + * @param content the content + * @return the declarations + * @throws ParseException on malformed JSON or an invalid declaration + */ + @Override + protected List parseImpl(String location, byte[] content) throws ParseException { + NumbersFile file; + try { + file = GSON.fromJson(new String(content, StandardCharsets.UTF_8), NumbersFile.class); + } catch (JsonSyntaxException ex) { + LOGGER.error("Malformed .numbers artefact [{}]", location, ex); + throw new ParseException("Malformed .numbers artefact [" + location + "]: " + ex.getMessage(), 0); + } + if (file == null || file.series == null || file.series.isEmpty()) { + throw new ParseException("The .numbers artefact [" + location + "] declares no series - expected {\"series\": [...]}", 0); + } + List declarations = new ArrayList<>(); + Set seen = new HashSet<>(); + for (SeriesEntry entry : file.series) { + validate(location, entry, seen); + NumberSeriesDeclaration declaration = + new NumberSeriesDeclaration(location, entry.name, entry.prefix == null ? "" : entry.prefix, entry.size); + declaration.updateKey(); + try { + NumberSeriesDeclaration existing = getService().findByKey(declaration.getKey()); + if (existing != null) { + declaration.setId(existing.getId()); + } + declarations.add(getService().save(declaration)); + } catch (Exception ex) { + LOGGER.error("Failed to save number-series declaration [{}] from [{}]", entry.name, location, ex); + throw new ParseException(ex.getMessage(), 0); + } + } + return declarations; + } + + private static void validate(String location, SeriesEntry entry, Set seen) throws ParseException { + if (entry == null || entry.name == null || entry.name.isBlank()) { + throw new ParseException("The .numbers artefact [" + location + "] contains a series without a name", 0); + } + if (!seen.add(entry.name)) { + throw new ParseException("The .numbers artefact [" + location + "] declares series [" + entry.name + "] more than once", 0); + } + try { + DocumentNumberService.validateShape(entry.prefix, entry.size); + } catch (IllegalArgumentException ex) { + throw new ParseException("Series [" + entry.name + "] in [" + location + "]: " + ex.getMessage(), 0); + } + } + + /** + * Gets the service. + * + * @return the service + */ + @Override + public ArtefactService getService() { + return declarationService; + } + + /** + * Retrieve. + * + * @param location the location + * @return the list + */ + @Override + public List retrieve(String location) { + return getService().findByLocation(location); + } + + /** + * Sets the status. + * + * @param artefact the artefact + * @param lifecycle the lifecycle + * @param error the error + */ + @Override + public void setStatus(NumberSeriesDeclaration artefact, ArtefactLifecycle lifecycle, String error) { + artefact.setLifecycle(lifecycle); + artefact.setError(error); + getService().save(artefact); + } + + /** + * Complete - runs once per tenant; provisions the declared series into that tenant's + * {@code DIRIGIBLE_DOCUMENT_NUMBERS} when absent. + * + * @param wrapper the wrapper + * @param flow the flow + * @return true, if successful + */ + @Override + protected boolean completeImpl(TopologyWrapper wrapper, ArtefactPhase flow) { + NumberSeriesDeclaration declaration = wrapper.getArtefact(); + + switch (flow) { + case CREATE: + if (ArtefactLifecycle.NEW.equals(declaration.getLifecycle())) { + provision(wrapper, declaration, ArtefactLifecycle.CREATED); + } + break; + case UPDATE: + // A FAILED declaration is re-evaluated too: the conflict it failed on may have been + // resolved by the OTHER module re-declaring. Registering FAILED again (not returning + // false) keeps the artefact depleted, so the processor does not overwrite the + // conflict message with generic "undepleted artefact" noise every cycle. + if (ArtefactLifecycle.MODIFIED.equals(declaration.getLifecycle()) + || ArtefactLifecycle.FAILED.equals(declaration.getLifecycle())) { + provision(wrapper, declaration, ArtefactLifecycle.UPDATED); + } + break; + case DELETE: + if (ArtefactLifecycle.CREATED.equals(declaration.getLifecycle()) + || ArtefactLifecycle.UPDATED.equals(declaration.getLifecycle()) + || ArtefactLifecycle.FAILED.equals(declaration.getLifecycle())) { + // Only the declaration record goes; the tenant's series row - shape, counter, allocated + // history - is a business object and outlives any module. + getService().delete(declaration); + callback.registerState(this, wrapper, ArtefactLifecycle.DELETED); + } + break; + case PREPARE: + case START: + case STOP: + break; + } + + return true; + } + + private void provision(TopologyWrapper wrapper, NumberSeriesDeclaration declaration, + ArtefactLifecycle lifecycle) { + Optional rival = findConflictingDeclaration(declaration); + if (rival.isPresent()) { + NumberSeriesDeclaration other = rival.get(); + String message = "Number series [" + declaration.getName() + "] is declared as prefix [" + declaration.getPrefix() + "] size [" + + declaration.getSize() + "] by [" + declaration.getLocation() + "] but as prefix [" + other.getPrefix() + "] size [" + + other.getSize() + "] by [" + other.getLocation() + + "]. A series is one tenant-level object: align the declarations or use different series names."; + LOGGER.error(message); + callback.addError(message); + callback.registerState(this, wrapper, ArtefactLifecycle.FAILED, message); + return; + } + try { + documentNumberService.provision(declaration.getName(), declaration.getPrefix(), declaration.getSize()); + callback.registerState(this, wrapper, lifecycle); + } catch (Exception ex) { + LOGGER.error("Failed to provision number series [{}] declared by [{}]", declaration.getName(), declaration.getLocation(), ex); + callback.addError(ex.getMessage()); + callback.registerState(this, wrapper, ArtefactLifecycle.FAILED, ex); + } + } + + /** + * Another module's declaration of the same series with a different shape. An identical + * re-declaration is legal (a shared legal range provisions once, idempotently); a differing one is + * a conflict this artefact must fail on. + */ + private Optional findConflictingDeclaration(NumberSeriesDeclaration declaration) { + return declarationService.findAllByName(declaration.getName()) + .stream() + .filter(other -> !other.getLocation() + .equals(declaration.getLocation())) + .filter(other -> !sameShape(other, declaration)) + .findFirst(); + } + + private static boolean sameShape(NumberSeriesDeclaration one, NumberSeriesDeclaration two) { + return one.getSize() == two.getSize() && one.getPrefix() + .equals(two.getPrefix()); + } + + /** + * Cleanup - reaps the orphaned declaration record only; never the tenant's series row. + * + * @param declaration the declaration + */ + @Override + public void cleanupImpl(NumberSeriesDeclaration declaration) { + try { + getService().delete(declaration); + } catch (Exception ex) { + callback.addError(ex.getMessage()); + callback.registerState(this, declaration, ArtefactLifecycle.DELETED, ex); + } + } + + /** + * Sets the callback. + * + * @param callback the new callback + */ + @Override + public void setCallback(SynchronizerCallback callback) { + this.callback = callback; + } + + /** + * Gets the file extension. + * + * @return the file extension + */ + @Override + public String getFileExtension() { + return FILE_EXTENSION_NUMBERS; + } + + /** + * Gets the artefact type. + * + * @return the artefact type + */ + @Override + public String getArtefactType() { + return NumberSeriesDeclaration.ARTEFACT_TYPE; + } + + /** The parsed {@code .numbers} file. */ + private static class NumbersFile { + List series; + } + + /** One declared series. */ + private static class SeriesEntry { + String name; + String prefix; + int size; + } +} 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 index 37b1cb790cc..9f49d53c420 100644 --- 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 @@ -10,34 +10,53 @@ package org.eclipse.dirigible.components.engine.numbering; import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.util.LinkedHashMap; -import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; +/** + * A number is {@code prefix + sequence zero-padded to size}, and nothing else - there is no token + * grammar left to get wrong. + */ 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())); + void rendersPrefixPlusSequencePaddedToTheTotalWidth() { + assertEquals("SI00000042", DocumentNumberService.render("SI", 10, 42)); + assertEquals(10, DocumentNumberService.render("SI", 10, 42) + .length()); + // No prefix: the whole width is sequence - the Bulgarian 10-digit continuous number. + assertEquals("0000000042", DocumentNumberService.render("", 10, 42)); + // A numeric prefix is just a prefix; the sequence shrinks to keep the total width. + assertEquals("0000000042", DocumentNumberService.render("00", 10, 42)); + // An annual restart is a prefix change plus a counter reset - no token, no hidden rule. + assertEquals("2026-000042", DocumentNumberService.render("2026-", 11, 42)); + } + + @Test + void aNullPrefixIsTreatedAsNone() { + assertEquals("000042", DocumentNumberService.render(null, 6, 42)); + } + + /** + * A sequence that outgrows its width renders in FULL rather than truncated: a truncated number is a + * different number, and silently minting a duplicate is far worse than an over-wide one. + */ + @Test + void anOverflowingSequenceIsNeverTruncated() { + assertEquals("SI1234567890", DocumentNumberService.render("SI", 6, 1234567890L)); + } + + @Test + void aWidthThatLeavesNoRoomForASequenceIsRejected() { + DocumentNumberService service = new DocumentNumberService(null); + assertThrows(IllegalArgumentException.class, () -> service.setShape("Sales Invoice", "", "PRE", 3)); + assertThrows(IllegalArgumentException.class, () -> service.setShape("Sales Invoice", "", "", 0)); } @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())); + void anAbsurdWidthIsRejected() { + DocumentNumberService service = new DocumentNumberService(null); + assertThrows(IllegalArgumentException.class, () -> service.setShape("Sales Invoice", "", "SI", DocumentNumberService.MAX_SIZE + 1)); } } diff --git a/components/engine/engine-numbering/src/test/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesSynchronizerTest.java b/components/engine/engine-numbering/src/test/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesSynchronizerTest.java new file mode 100644 index 00000000000..e800a16a8f6 --- /dev/null +++ b/components/engine/engine-numbering/src/test/java/org/eclipse/dirigible/components/engine/numbering/NumberSeriesSynchronizerTest.java @@ -0,0 +1,209 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.util.HashMap; +import java.util.List; + +import org.eclipse.dirigible.components.base.artefact.ArtefactLifecycle; +import org.eclipse.dirigible.components.base.artefact.ArtefactPhase; +import org.eclipse.dirigible.components.base.artefact.topology.TopologyWrapper; +import org.eclipse.dirigible.components.base.synchronizer.SynchronizerCallback; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.AdditionalAnswers; + +/** + * The declaration rules: a valid file provisions per series; a broken file fails the parse; a + * differing cross-module re-declaration fails that artefact loudly; DELETE never reaches the + * counter store. + */ +class NumberSeriesSynchronizerTest { + + private static final String LOCATION = "/sales-invoices/sales-invoices.numbers"; + + private NumberSeriesDeclarationService declarationService; + private DocumentNumberService documentNumberService; + private SynchronizerCallback callback; + private NumberSeriesSynchronizer synchronizer; + + @BeforeEach + void setUp() { + declarationService = mock(NumberSeriesDeclarationService.class); + documentNumberService = mock(DocumentNumberService.class); + callback = mock(SynchronizerCallback.class); + synchronizer = new NumberSeriesSynchronizer(declarationService, documentNumberService); + synchronizer.setCallback(callback); + when(declarationService.save(any())).thenAnswer(AdditionalAnswers.returnsFirstArg()); + } + + @Test + void parsesOneDeclarationPerSeriesEntry() throws ParseException { + String content = "{\"series\": [{\"name\": \"Sales Invoice\", \"prefix\": \"SI\", \"size\": 10}," + + " {\"name\": \"Credit Note\", \"size\": 10}]}"; + + List declarations = synchronizer.parseImpl(LOCATION, content.getBytes(StandardCharsets.UTF_8)); + + assertEquals(2, declarations.size()); + NumberSeriesDeclaration first = declarations.get(0); + assertEquals("Sales Invoice", first.getName()); + assertEquals("SI", first.getPrefix()); + assertEquals(10, first.getSize()); + assertEquals(LOCATION, first.getLocation()); + // An omitted prefix is a prefix-less continuous number, not a null. + assertEquals("", declarations.get(1) + .getPrefix()); + } + + @Test + void rejectsMalformedJson() { + assertParseFails("not json at all"); + } + + @Test + void rejectsAFileWithoutSeries() { + assertParseFails("{}"); + assertParseFails("{\"series\": []}"); + } + + @Test + void rejectsANamelessSeries() { + assertParseFails("{\"series\": [{\"prefix\": \"SI\", \"size\": 10}]}"); + } + + @Test + void rejectsADuplicateSeriesNameWithinOneFile() { + assertParseFails("{\"series\": [{\"name\": \"Sales Invoice\", \"size\": 10}, {\"name\": \"Sales Invoice\", \"size\": 8}]}"); + } + + @Test + void rejectsAShapeTheSettingsPageWouldRefuse() { + // The width leaves no room for a sequence after the prefix. + assertParseFails("{\"series\": [{\"name\": \"Sales Invoice\", \"prefix\": \"INVOICE\", \"size\": 7}]}"); + } + + @Test + void createProvisionsTheDeclaredSeries() throws Exception { + NumberSeriesDeclaration declaration = declaration("Sales Invoice", "SI", 10, LOCATION, ArtefactLifecycle.NEW); + when(declarationService.findAllByName("Sales Invoice")).thenReturn(List.of(declaration)); + + assertTrue(synchronizer.completeImpl(wrap(declaration), ArtefactPhase.CREATE)); + + verify(documentNumberService).provision("Sales Invoice", "SI", 10); + verify(callback).registerState(eq(synchronizer), any(TopologyWrapper.class), eq(ArtefactLifecycle.CREATED)); + } + + @Test + void anIdenticalDeclarationByAnotherModuleProvisionsIdempotently() throws Exception { + NumberSeriesDeclaration declaration = declaration("Sales Invoice", "SI", 10, LOCATION, ArtefactLifecycle.NEW); + NumberSeriesDeclaration twin = declaration("Sales Invoice", "SI", 10, "/other-module/other.numbers", ArtefactLifecycle.CREATED); + when(declarationService.findAllByName("Sales Invoice")).thenReturn(List.of(declaration, twin)); + + assertTrue(synchronizer.completeImpl(wrap(declaration), ArtefactPhase.CREATE)); + + verify(documentNumberService).provision("Sales Invoice", "SI", 10); + } + + @Test + void aDifferingDeclarationByAnotherModuleFailsLoudlyNamingBothModules() throws Exception { + NumberSeriesDeclaration declaration = declaration("Sales Invoice", "SI", 10, LOCATION, ArtefactLifecycle.NEW); + NumberSeriesDeclaration rival = declaration("Sales Invoice", "INV", 8, "/other-module/other.numbers", ArtefactLifecycle.CREATED); + when(declarationService.findAllByName("Sales Invoice")).thenReturn(List.of(declaration, rival)); + + assertTrue(synchronizer.completeImpl(wrap(declaration), ArtefactPhase.CREATE)); + + verify(documentNumberService, never()).provision(anyString(), anyString(), anyInt()); + verify(callback).addError(contains("/other-module/other.numbers")); + verify(callback).registerState(eq(synchronizer), any(TopologyWrapper.class), eq(ArtefactLifecycle.FAILED), contains(LOCATION)); + } + + @Test + void aFailedDeclarationIsReevaluatedOnUpdateAndStaysDepleted() throws Exception { + NumberSeriesDeclaration declaration = declaration("Sales Invoice", "SI", 10, LOCATION, ArtefactLifecycle.FAILED); + NumberSeriesDeclaration rival = declaration("Sales Invoice", "INV", 8, "/other-module/other.numbers", ArtefactLifecycle.CREATED); + when(declarationService.findAllByName("Sales Invoice")).thenReturn(List.of(declaration, rival)); + + // The conflict persists: register FAILED again but STAY DEPLETED (return true) - returning + // false would make the processor overwrite the conflict message with "undepleted" noise. + assertTrue(synchronizer.completeImpl(wrap(declaration), ArtefactPhase.UPDATE)); + verify(documentNumberService, never()).provision(anyString(), anyString(), anyInt()); + verify(callback).registerState(eq(synchronizer), any(TopologyWrapper.class), eq(ArtefactLifecycle.FAILED), contains(LOCATION)); + + // The other module re-declares identically: the next UPDATE pass heals the artefact. + rival.setPrefix("SI"); + rival.setSize(10); + assertTrue(synchronizer.completeImpl(wrap(declaration), ArtefactPhase.UPDATE)); + verify(documentNumberService).provision("Sales Invoice", "SI", 10); + verify(callback).registerState(eq(synchronizer), any(TopologyWrapper.class), eq(ArtefactLifecycle.UPDATED)); + } + + @Test + void deleteRemovesOnlyTheDeclarationNeverTheSeries() throws Exception { + NumberSeriesDeclaration declaration = declaration("Sales Invoice", "SI", 10, LOCATION, ArtefactLifecycle.CREATED); + + assertTrue(synchronizer.completeImpl(wrap(declaration), ArtefactPhase.DELETE)); + + verify(declarationService).delete(declaration); + verify(documentNumberService, never()).provision(anyString(), anyString(), anyInt()); + verify(documentNumberService, never()).setNext(anyString(), anyString(), anyLong()); + verify(documentNumberService, never()).setShape(anyString(), anyString(), anyString(), anyInt()); + } + + @Test + void cleanupReapsOnlyTheDeclaration() { + NumberSeriesDeclaration declaration = declaration("Sales Invoice", "SI", 10, LOCATION, ArtefactLifecycle.CREATED); + + synchronizer.cleanupImpl(declaration); + + verify(declarationService).delete(declaration); + verifyNoCounterWrites(); + } + + private void verifyNoCounterWrites() { + try { + verify(documentNumberService, never()).provision(anyString(), anyString(), anyInt()); + verify(documentNumberService, never()).setNext(anyString(), anyString(), anyLong()); + verify(documentNumberService, never()).setShape(anyString(), anyString(), anyString(), anyInt()); + } catch (Exception unexpected) { + throw new AssertionError(unexpected); + } + } + + private void assertParseFails(String content) { + assertThrows(ParseException.class, () -> synchronizer.parseImpl(LOCATION, content.getBytes(StandardCharsets.UTF_8))); + } + + private static NumberSeriesDeclaration declaration(String name, String prefix, int size, String location, ArtefactLifecycle lifecycle) { + NumberSeriesDeclaration declaration = new NumberSeriesDeclaration(location, name, prefix, size); + declaration.updateKey(); + declaration.setLifecycle(lifecycle); + return declaration; + } + + private TopologyWrapper wrap(NumberSeriesDeclaration declaration) { + return new TopologyWrapper<>(declaration, new HashMap<>(), synchronizer); + } +} 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 55982a2d5d1..7531967c619 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,20 +126,36 @@ document.addEventListener('alpine:init', () => { } }, - // Document Numbering: the current tenant's per-series counters (engine-numbering), read from and + // Document Numbering: the current tenant's number series (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). + // row is { series, partition, prefix, size, next } - a series declared by a published module's + // .numbers artefact, one row per partition (e.g. per company) when the intent partitions it. The + // tenant edits the SHAPE (prefix + total width) and the NEXT value here; only what the user + // actually changed is written, so a counter that advanced since load is never clobbered. An + // annual restart is exactly this page: set the prefix and the next value in January. numbering: [], numberingLoading: false, numberingError: null, - /** A readable label for a counter row: the series, plus the scope in brackets when scoped. */ + /** A readable label for a series row: the series, plus the partition in brackets when partitioned. */ numberingLabel(row) { - return row.scope ? row.series + ' [' + row.scope + ']' : row.series; + return row.partition ? row.series + ' [' + row.partition + ']' : row.series; }, - /** Load the current tenant's document-number counters. */ + /** + * The next number as it will render with the row's CURRENT edits - prefix + sequence zero-padded + * to the total width (mirrors the server's rendering, incl. never truncating an overflow). + */ + numberingExample(row) { + const prefix = row.prefix || ''; + const next = parseInt(row.next, 10); + const size = parseInt(row.size, 10); + if (!isFinite(next) || next < 1 || !isFinite(size)) return ''; + const digits = Math.max(1, size - prefix.length); + return prefix + String(next).padStart(digits, '0'); + }, + + /** Load the current tenant's number series. */ async loadNumbering() { this.numberingLoading = true; this.numberingError = null; @@ -155,7 +171,14 @@ document.addEventListener('alpine:init', () => { } 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 })); + this.numbering = data.map((c) => ({ + series: c.series, + partition: c.partition || '', + prefix: c.prefix || '', + size: c.size, + next: c.next, + orig: { prefix: c.prefix || '', size: c.size, next: c.next } + })); } catch (e) { console.error('document-numbering: failed to load', e); this.numbering = []; @@ -166,20 +189,36 @@ document.addEventListener('alpine:init', () => { } }, - /** Persist each row's edited NEXT value (PUT setNext). */ + /** + * Persist each row's edits: a changed shape via PUT /shape, a changed next via PUT. Untouched + * values are not written - setNext rewinds the live counter, so writing an unchanged "next" + * would silently undo allocations made since the page loaded. + */ async saveNumbering() { this.numberingError = null; try { for (const row of this.numbering) { + const size = parseInt(row.size, 10); + const shapeChanged = (row.prefix || '') !== row.orig.prefix || size !== row.orig.size; + if (shapeChanged && isFinite(size)) { + const res = await fetch('/services/core/numbering/shape', { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ series: row.series, partition: row.partition, prefix: row.prefix || '', size: size }) + }); + if (!res.ok) throw new Error('PUT shape ' + row.series + ' -> HTTP ' + res.status); + } 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); + if (isFinite(next) && next >= 1 && next !== row.orig.next) { + const res = await fetch('/services/core/numbering', { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ series: row.series, partition: row.partition, next: next }) + }); + if (!res.ok) throw new Error('PUT ' + row.series + ' -> HTTP ' + res.status); + } } await this.loadNumbering(); } catch (e) { 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 fe6f926bbd1..6df2ba94b1d 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 @@ -152,14 +152,16 @@ - +

- +