Commit 01c71a69128090cab03ae92d8965f48c707ad32c

Authored by zichun
1 parent 57297bcb

feat(i18n): P1.6 — ICU4J translator + per-plug-in locale chain

The eighth cross-cutting platform service is live: plug-ins and PBCs
now have a real Translator and LocaleProvider instead of the
UnsupportedOperationException stubs that have shipped since v0.5.

What landed
-----------
* New Gradle subproject `platform/platform-i18n` (13 modules total).
* `IcuTranslator` — backed by ICU4J's MessageFormat (named placeholders,
  plurals, gender, locale-aware number/date/currency formatting), the
  format every modern translation tool speaks natively. JDK ResourceBundle
  handles per-locale fallback (zh_CN → zh → root).
* The translator takes a list of `BundleLocation(classLoader, baseName)`
  pairs and tries them in order. For the **core** translator the chain
  is just `[(host, "messages")]`; for a **per-plug-in** translator
  constructed by VibeErpPluginManager it's
  `[(pluginClassLoader, "META-INF/vibe-erp/i18n/messages"), (host, "messages")]`
  so plug-in keys override host keys, but plug-ins still inherit
  shared keys like `errors.not_found`.
* Critical detail: the plug-in baseName uses a path the host does NOT
  publish, because PF4J's `PluginClassLoader` is parent-first — a
  `getResource("messages.properties")` against the plug-in classloader
  would find the HOST bundle through the parent chain, defeating the
  per-plug-in override entirely. Naming the plug-in resource somewhere
  the host doesn't claim sidesteps the trap.
* The translator disables `ResourceBundle.Control`'s automatic JVM-default
  locale fallback. The default control walks `requested → root → JVM
  default → root` which would silently serve German strings to a
  Japanese-locale request just because the German bundle exists. The
  fallback chain stops at root within a bundle, then moves to the next
  bundle location, then returns the key string itself.
* `RequestLocaleProvider` reads the active HTTP request's
  Accept-Language via `RequestContextHolder` + the servlet container's
  `getLocale()`. Outside an HTTP request (background jobs, workflow
  tasks, MCP agents) it falls back to the configured default locale
  (`vibeerp.i18n.defaultLocale`, default `en`). Importantly, when an
  HTTP request HAS no Accept-Language header it ALSO falls back to the
  configured default — never to the JVM's locale.
* `I18nConfiguration` exposes `coreTranslator` and `coreLocaleProvider`
  beans. Per-plug-in translators are NOT beans — they're constructed
  imperatively per plug-in start in VibeErpPluginManager because each
  needs its own classloader at the front of the resolution chain.
* `DefaultPluginContext` now wires `translator` and `localeProvider`
  for real instead of throwing `UnsupportedOperationException`.

Bundles
-------
* Core: `platform-i18n/src/main/resources/messages.properties` (English),
  `messages_zh_CN.properties` (Simplified Chinese), `messages_de.properties`
  (German). Six common keys (errors, ok/cancel/save/delete) and an ICU
  plural example for `counts.items`. Java 9+ JEP 226 reads .properties
  files as UTF-8 by default, so Chinese characters are written directly
  rather than as `\\uXXXX` escapes.
* Reference plug-in: moved from the broken `i18n/messages_en-US.properties`
  / `messages_zh-CN.properties` (wrong path, hyphen-locale filenames
  ResourceBundle ignores) to the canonical
  `META-INF/vibe-erp/i18n/messages.properties` /
  `messages_zh_CN.properties` paths with underscore locale tags.
  Added a new `printingshop.plate.created` key with an ICU plural for
  `ink_count` to demonstrate non-trivial argument substitution.

End-to-end smoke test
---------------------
Reset Postgres, booted the app, hit POST /api/v1/plugins/printing-shop/plates
with three different Accept-Language headers:
* (no header)         → "Plate 'PLATE-001' created with no inks." (en-US, plug-in base bundle)
* `Accept-Language: zh-CN` → "已创建印版 'PLATE-002' (无油墨)。" (zh-CN, plug-in zh_CN bundle)
* `Accept-Language: de`    → "Plate 'PLATE-003' created with no inks." (de, but the plug-in
                              ships no German bundle so it falls back to the plug-in base
                              bundle — correct, the key is plug-in-specific)
Regression: identity, catalog, partners, and `GET /plates` all still
HTTP 200 after the i18n wiring change.

Build
-----
* `./gradlew build`: 13 subprojects, 118 unit tests (was 107 / 12),
  all green. The 11 new tests cover ICU plural rendering, named-arg
  substitution, locale fallback (zh_CN → root, ja → root via NO_FALLBACK),
  cross-classloader override (a real JAR built in /tmp at test time),
  and RequestLocaleProvider's three resolution paths
  (no request → default; Accept-Language present → request locale;
  request without Accept-Language → default, NOT JVM locale).
* The architectural rule still enforced: platform-plugins now imports
  platform-i18n, which is a platform-* dependency (allowed), not a
  pbc-* dependency (forbidden).

What was deferred
-----------------
* User-preferred locale from the authenticated user's profile row is
  NOT in the resolution chain yet — the `LocaleProvider` interface
  leaves room for it but the implementation only consults
  Accept-Language and the configured default. Adding it slots in
  between request and default without changing the api.v1 surface.
* The metadata translation overrides table (`metadata__translation`)
  is also deferred — the `Translator` JavaDoc mentions it as the
  first lookup source, but right now keys come from .properties files
  only. Once Tier 1 customisation lands (P3.x), key users will be able
  to override any string from the SPA without touching code.
Showing 24 changed files with 692 additions and 65 deletions
CLAUDE.md
@@ -95,9 +95,9 @@ plugins (incl. ref) depend on: api/api-v1 only @@ -95,9 +95,9 @@ plugins (incl. ref) depend on: api/api-v1 only
95 95
96 **Foundation complete; first business surface in place.** As of the latest commit: 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97
98 -- **12 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`.  
99 -- **107 unit tests across 12 modules**, all green. `./gradlew build` is the canonical full build.  
100 -- **All 7 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader (P1.5), plus the audit/principal context bridge. 98 +- **13 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`.
  99 +- **118 unit tests across 13 modules**, all green. `./gradlew build` is the canonical full build.
  100 +- **All 8 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader (P1.5), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge.
101 - **3 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`) — they validate the recipe every future PBC clones across three different aggregate shapes (single entity, two-entity catalog, parent-with-children partners+addresses+contacts). 101 - **3 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`) — they validate the recipe every future PBC clones across three different aggregate shapes (single entity, two-entity catalog, parent-with-children partners+addresses+contacts).
102 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework. 102 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework.
103 - **Package root** is `org.vibeerp`. 103 - **Package root** is `org.vibeerp`.
PROGRESS.md
@@ -10,19 +10,19 @@ @@ -10,19 +10,19 @@
10 10
11 | | | 11 | | |
12 |---|---| 12 |---|---|
13 -| **Latest version** | v0.7 (post-P5.2) |  
14 -| **Latest commit** | `3e40cae feat(pbc): P5.2 — pbc-partners (customers, suppliers, addresses, contacts)` | 13 +| **Latest version** | v0.8 (post-P1.6) |
  14 +| **Latest commit** | `feat(i18n): P1.6 — ICU4J translator + per-plug-in locale chain` |
15 | **Repo** | https://github.com/reporkey/vibe-erp | 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 -| **Modules** | 12 |  
17 -| **Unit tests** | 107, all green |  
18 -| **End-to-end smoke runs** | All cross-cutting services + all 3 PBCs verified against real Postgres | 16 +| **Modules** | 13 |
  17 +| **Unit tests** | 118, all green |
  18 +| **End-to-end smoke runs** | All cross-cutting services + all 3 PBCs verified against real Postgres; reference plug-in returns localised strings in 3 locales |
19 | **Real PBCs implemented** | 3 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`) | 19 | **Real PBCs implemented** | 3 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`) |
20 -| **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata) | 20 +| **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) |
21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | 21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. |
22 22
23 ## Current stage 23 ## Current stage
24 24
25 -**Foundation complete; business surface area growing.** All seven cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader). Three real PBCs (identity, catalog, partners) validate the modular-monolith template against three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain through the api.v1 typed-SQL surface, registers its own HTTP endpoints, and would be rejected at install time if it tried to import any internal framework class. 25 +**Foundation complete; business surface area growing; i18n online.** All eight cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader, ICU4J translator). Three real PBCs (identity, catalog, partners) validate the modular-monolith template against three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain through the api.v1 typed-SQL surface, registers its own HTTP endpoints, ships its own message bundles, returns localised strings in three locales, and would be rejected at install time if it tried to import any internal framework class.
26 26
27 The next phase continues **building business surface area**: more PBCs (inventory, sales orders), the workflow engine (Flowable), the metadata-driven custom fields and forms layer (Tier 1 customization), and eventually the React SPA. 27 The next phase continues **building business surface area**: more PBCs (inventory, sales orders), the workflow engine (Flowable), the metadata-driven custom fields and forms layer (Tier 1 customization), and eventually the React SPA.
28 28
@@ -30,7 +30,7 @@ The next phase continues **building business surface area**: more PBCs (inventor @@ -30,7 +30,7 @@ The next phase continues **building business surface area**: more PBCs (inventor
30 30
31 The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar. 31 The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar.
32 32
33 -That target breaks down into roughly 30 work units across 8 phases. About **16 are done** as of today. Below is the full list with status. 33 +That target breaks down into roughly 30 work units across 8 phases. About **17 are done** as of today. Below is the full list with status.
34 34
35 ### Phase 1 — Platform completion (foundation) 35 ### Phase 1 — Platform completion (foundation)
36 36
@@ -41,7 +41,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **16 a @@ -41,7 +41,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **16 a
41 | P1.3 | Plug-in lifecycle: HTTP endpoints, DefaultPluginContext, dispatcher | ✅ DONE — `20d7ddc` | 41 | P1.3 | Plug-in lifecycle: HTTP endpoints, DefaultPluginContext, dispatcher | ✅ DONE — `20d7ddc` |
42 | P1.4 | Plug-in Liquibase application + `PluginJdbc` | ✅ DONE — `7af11f2` | 42 | P1.4 | Plug-in Liquibase application + `PluginJdbc` | ✅ DONE — `7af11f2` |
43 | P1.5 | Metadata store seeding | ✅ DONE — `1ead32d` | 43 | P1.5 | Metadata store seeding | ✅ DONE — `1ead32d` |
44 -| P1.6 | ICU4J `Translator` implementation + locale resolution | ⏳ Next priority | 44 +| P1.6 | ICU4J `Translator` implementation + locale resolution | ✅ DONE — `<this commit>` |
45 | P1.7 | Event bus + transactional outbox | ✅ DONE — `c2f2314` | 45 | P1.7 | Event bus + transactional outbox | ✅ DONE — `c2f2314` |
46 | P1.8 | JasperReports integration | 🔜 Pending | 46 | P1.8 | JasperReports integration | 🔜 Pending |
47 | P1.9 | File store (local + S3) | 🔜 Pending | 47 | P1.9 | File store (local + S3) | 🔜 Pending |
@@ -126,6 +126,7 @@ These are the cross-cutting platform services already wired into the running fra @@ -126,6 +126,7 @@ These are the cross-cutting platform services already wired into the running fra
126 | **Plug-in DB schemas** (P1.4) | `platform-plugins` | Each plug-in ships its own `META-INF/vibe-erp/db/changelog.xml`. The host's `PluginLiquibaseRunner` applies it against the shared host datasource at plug-in start. Plug-ins query their own tables via the api.v1 `PluginJdbc` typed-SQL surface — no Spring or Hibernate types ever leak. | 126 | **Plug-in DB schemas** (P1.4) | `platform-plugins` | Each plug-in ships its own `META-INF/vibe-erp/db/changelog.xml`. The host's `PluginLiquibaseRunner` applies it against the shared host datasource at plug-in start. Plug-ins query their own tables via the api.v1 `PluginJdbc` typed-SQL surface — no Spring or Hibernate types ever leak. |
127 | **Event bus + outbox** (P1.7) | `platform-events` | Synchronous in-process delivery PLUS a transactional outbox row in the same DB transaction. `Propagation.MANDATORY` so the bus refuses to publish outside an active transaction (no publish-and-rollback leaks). `OutboxPoller` flips PENDING → DISPATCHED every 5s. Wildcard `**` topic for the audit subscriber; topic-string and class-based subscribe. | 127 | **Event bus + outbox** (P1.7) | `platform-events` | Synchronous in-process delivery PLUS a transactional outbox row in the same DB transaction. `Propagation.MANDATORY` so the bus refuses to publish outside an active transaction (no publish-and-rollback leaks). `OutboxPoller` flips PENDING → DISPATCHED every 5s. Wildcard `**` topic for the audit subscriber; topic-string and class-based subscribe. |
128 | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. | 128 | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. |
  129 +| **i18n / Translator** (P1.6) | `platform-i18n` | ICU4J-backed `Translator` with named placeholders, plurals, gender, locale-aware number/date formatting. Per-plug-in instance scoped via a `(classLoader, baseName)` chain — plug-in's `META-INF/vibe-erp/i18n/messages_<locale>.properties` resolves before the host's `messages_<locale>.properties` for shared keys. `RequestLocaleProvider` reads `Accept-Language` from the active HTTP request via the servlet container, falling back to `vibeerp.i18n.defaultLocale` outside an HTTP context. JVM-default locale fallback explicitly disabled to prevent silent locale leaks. |
129 | **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog`, `pbc-partners` | Three real PBCs prove the recipe across three aggregate shapes (single-entity user, two-entity catalog, parent-with-children partners+addresses+contacts): domain entity extending `AuditedJpaEntity` → Spring Data JPA repository → application service → REST controller under `/api/v1/<pbc>/<resource>` → cross-PBC facade in `api.v1.ext.<pbc>` → adapter implementation. Architecture rule enforced by the Gradle build: PBCs never import each other, never import `platform-bootstrap`. | 130 | **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog`, `pbc-partners` | Three real PBCs prove the recipe across three aggregate shapes (single-entity user, two-entity catalog, parent-with-children partners+addresses+contacts): domain entity extending `AuditedJpaEntity` → Spring Data JPA repository → application service → REST controller under `/api/v1/<pbc>/<resource>` → cross-PBC facade in `api.v1.ext.<pbc>` → adapter implementation. Architecture rule enforced by the Gradle build: PBCs never import each other, never import `platform-bootstrap`. |
130 131
131 ## What the reference plug-in proves end-to-end 132 ## What the reference plug-in proves end-to-end
@@ -162,7 +163,6 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework, @@ -162,7 +163,6 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework,
162 ## What's not yet live (the deferred list) 163 ## What's not yet live (the deferred list)
163 164
164 - **Workflow engine.** No Flowable yet. The api.v1 `TaskHandler` interface exists; the runtime that calls it doesn't. 165 - **Workflow engine.** No Flowable yet. The api.v1 `TaskHandler` interface exists; the runtime that calls it doesn't.
165 -- **i18n / Translator.** Plug-ins that try to use `context.translator` get an `UnsupportedOperationException` — the stub points at P1.6.  
166 - **Custom fields.** The `ext jsonb` columns exist on every entity; the metadata describing them exists; the validator that enforces them on save does not (P3.4). 166 - **Custom fields.** The `ext jsonb` columns exist on every entity; the metadata describing them exists; the validator that enforces them on save does not (P3.4).
167 - **Forms.** No JSON Schema form renderer (server or client). No form designer. 167 - **Forms.** No JSON Schema form renderer (server or client). No form designer.
168 - **Reports.** No JasperReports. 168 - **Reports.** No JasperReports.
@@ -208,6 +208,7 @@ platform/platform-persistence Audit base, JPA entities, PrincipalContext @@ -208,6 +208,7 @@ platform/platform-persistence Audit base, JPA entities, PrincipalContext
208 platform/platform-security JWT issuer/verifier, Spring Security config, password encoder 208 platform/platform-security JWT issuer/verifier, Spring Security config, password encoder
209 platform/platform-events EventBus impl, transactional outbox, poller 209 platform/platform-events EventBus impl, transactional outbox, poller
210 platform/platform-metadata MetadataLoader, MetadataController 210 platform/platform-metadata MetadataLoader, MetadataController
  211 +platform/platform-i18n IcuTranslator, RequestLocaleProvider (P1.6)
211 platform/platform-plugins PF4J host, linter, Liquibase runner, endpoint dispatcher, PluginJdbc 212 platform/platform-plugins PF4J host, linter, Liquibase runner, endpoint dispatcher, PluginJdbc
212 213
213 pbc/pbc-identity User entity end-to-end + auth + bootstrap admin 214 pbc/pbc-identity User entity end-to-end + auth + bootstrap admin
@@ -221,7 +222,7 @@ reference-customer/plugin-printing-shop @@ -221,7 +222,7 @@ reference-customer/plugin-printing-shop
221 distribution Bootable Spring Boot fat-jar assembly 222 distribution Bootable Spring Boot fat-jar assembly
222 ``` 223 ```
223 224
224 -12 Gradle subprojects. Architectural dependency rule (PBCs never import each other; plug-ins only see api.v1) is enforced by the root `build.gradle.kts` at configuration time. 225 +13 Gradle subprojects. Architectural dependency rule (PBCs never import each other; plug-ins only see api.v1) is enforced by the root `build.gradle.kts` at configuration time.
225 226
226 ## Where to look next 227 ## Where to look next
227 228
README.md
@@ -77,7 +77,7 @@ vibe-erp/ @@ -77,7 +77,7 @@ vibe-erp/
77 ## Building 77 ## Building
78 78
79 ```bash 79 ```bash
80 -# Build everything (compiles 12 modules, runs 107 unit tests) 80 +# Build everything (compiles 13 modules, runs 118 unit tests)
81 ./gradlew build 81 ./gradlew build
82 82
83 # Bring up Postgres + the reference plug-in JAR 83 # Bring up Postgres + the reference plug-in JAR
@@ -92,14 +92,14 @@ The bootstrap admin password is printed to the application logs on first boot. A @@ -92,14 +92,14 @@ The bootstrap admin password is printed to the application logs on first boot. A
92 92
93 ## Status 93 ## Status
94 94
95 -**Current stage: foundation complete; business surface area growing.** All seven cross-cutting platform services are live and verified end-to-end against a real Postgres: auth (JWT + Argon2id + bootstrap admin), plug-in HTTP endpoints, plug-in linter (ASM bytecode scan), plug-in-owned DB schemas + typed SQL, event bus + transactional outbox, and the metadata seeder. Three real PBCs (identity + catalog + partners) validate the modular-monolith template across three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain via REST, and would be rejected at install time if it tried to import any internal framework class. 95 +**Current stage: foundation complete; business surface area growing; i18n online.** All eight cross-cutting platform services are live and verified end-to-end against a real Postgres: auth (JWT + Argon2id + bootstrap admin), plug-in HTTP endpoints, plug-in linter (ASM bytecode scan), plug-in-owned DB schemas + typed SQL, event bus + transactional outbox, the metadata seeder, and the ICU4J translator with per-plug-in locale chain. Three real PBCs (identity + catalog + partners) validate the modular-monolith template across three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain via REST, ships its own message bundles, returns localised strings in three locales, and would be rejected at install time if it tried to import any internal framework class.
96 96
97 | | | 97 | | |
98 |---|---| 98 |---|---|
99 -| Modules | 12 |  
100 -| Unit tests | 107, all green | 99 +| Modules | 13 |
  100 +| Unit tests | 118, all green |
101 | Real PBCs | 3 of 10 | 101 | Real PBCs | 3 of 10 |
102 -| Cross-cutting services live | 7 | 102 +| Cross-cutting services live | 8 |
103 | Plug-ins serving HTTP | 1 (reference printing-shop) | 103 | Plug-ins serving HTTP | 1 (reference printing-shop) |
104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | 104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending |
105 105
distribution/build.gradle.kts
@@ -25,6 +25,7 @@ dependencies { @@ -25,6 +25,7 @@ dependencies {
25 implementation(project(":platform:platform-security")) 25 implementation(project(":platform:platform-security"))
26 implementation(project(":platform:platform-events")) 26 implementation(project(":platform:platform-events"))
27 implementation(project(":platform:platform-metadata")) 27 implementation(project(":platform:platform-metadata"))
  28 + implementation(project(":platform:platform-i18n"))
28 implementation(project(":pbc:pbc-identity")) 29 implementation(project(":pbc:pbc-identity"))
29 implementation(project(":pbc:pbc-catalog")) 30 implementation(project(":pbc:pbc-catalog"))
30 implementation(project(":pbc:pbc-partners")) 31 implementation(project(":pbc:pbc-partners"))
platform/platform-i18n/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.spring.dependency.management)
  5 +}
  6 +
  7 +description = "vibe_erp i18n — ICU4J translator + Accept-Language locale resolution. INTERNAL."
  8 +
  9 +java {
  10 + toolchain {
  11 + languageVersion.set(JavaLanguageVersion.of(21))
  12 + }
  13 +}
  14 +
  15 +kotlin {
  16 + jvmToolchain(21)
  17 + compilerOptions {
  18 + freeCompilerArgs.add("-Xjsr305=strict")
  19 + }
  20 +}
  21 +
  22 +dependencies {
  23 + api(project(":api:api-v1"))
  24 +
  25 + implementation(libs.kotlin.stdlib)
  26 + implementation(libs.kotlin.reflect)
  27 +
  28 + implementation(libs.spring.boot.starter)
  29 + implementation(libs.spring.boot.starter.web) // for RequestContextHolder + Accept-Language parsing
  30 + implementation(libs.icu4j)
  31 +
  32 + testImplementation(libs.spring.boot.starter.test)
  33 + testImplementation(libs.junit.jupiter)
  34 + testImplementation(libs.assertk)
  35 + testImplementation(libs.mockk)
  36 +}
  37 +
  38 +tasks.test {
  39 + useJUnitPlatform()
  40 +}
platform/platform-i18n/src/main/kotlin/org/vibeerp/platform/i18n/I18nConfiguration.kt 0 → 100644
  1 +package org.vibeerp.platform.i18n
  2 +
  3 +import org.springframework.boot.context.properties.ConfigurationProperties
  4 +import org.springframework.boot.context.properties.EnableConfigurationProperties
  5 +import org.springframework.context.annotation.Bean
  6 +import org.springframework.context.annotation.Configuration
  7 +import org.vibeerp.api.v1.i18n.LocaleProvider
  8 +import org.vibeerp.api.v1.i18n.Translator
  9 +import java.util.Locale
  10 +
  11 +/**
  12 + * Spring wiring for the i18n module.
  13 + *
  14 + * Exposes two beans that PBCs and the plug-in host can inject:
  15 + *
  16 + * - **`coreTranslator`** ([Translator]) — looks at the HOST classpath
  17 + * only. Used by core PBCs whose translation keys live in
  18 + * `messages_<locale>.properties` on the host classpath.
  19 + * - **`coreLocaleProvider`** ([LocaleProvider]) — request-scoped
  20 + * locale resolution with a configured default fallback.
  21 + *
  22 + * **Per-plug-in translators are NOT beans.** They are constructed
  23 + * imperatively by `VibeErpPluginManager` when each plug-in starts,
  24 + * because they need the plug-in's own classloader at the front of the
  25 + * resolution chain. There can be many of them at runtime — one per
  26 + * loaded plug-in. The host's [IcuTranslator] still serves the core PBC
  27 + * keys, and per-plug-in instances exist alongside it.
  28 + */
  29 +@Configuration
  30 +@EnableConfigurationProperties(I18nProperties::class)
  31 +class I18nConfiguration {
  32 +
  33 + @Bean
  34 + fun coreTranslator(): Translator =
  35 + IcuTranslator(
  36 + locations = listOf(
  37 + IcuTranslator.BundleLocation(
  38 + classLoader = I18nConfiguration::class.java.classLoader,
  39 + baseName = IcuTranslator.CORE_BASE_NAME,
  40 + ),
  41 + ),
  42 + )
  43 +
  44 + @Bean
  45 + fun coreLocaleProvider(properties: I18nProperties): LocaleProvider =
  46 + RequestLocaleProvider(defaultLocale = Locale.forLanguageTag(properties.defaultLocale))
  47 +}
  48 +
  49 +/**
  50 + * Configuration properties for the i18n layer.
  51 + *
  52 + * The default locale is **English** because the framework is sold
  53 + * worldwide and English is the most-translated source locale (see
  54 + * CLAUDE.md guardrail #6: "the reference docs being in Chinese does
  55 + * NOT mean Chinese is the primary or default locale; it is just one
  56 + * supported locale among many"). Operators who want a different
  57 + * default set `vibeerp.i18n.defaultLocale=zh-CN` in application.yaml.
  58 + *
  59 + * The format is BCP-47 language tags ("en", "zh-CN", "de-DE",
  60 + * "pt-BR"), parsed via [java.util.Locale.forLanguageTag] which is the
  61 + * standard the SPA, the OpenAPI client, and every modern HTTP
  62 + * Accept-Language header speak.
  63 + */
  64 +@ConfigurationProperties(prefix = "vibeerp.i18n")
  65 +data class I18nProperties(
  66 + var defaultLocale: String = "en",
  67 +)
platform/platform-i18n/src/main/kotlin/org/vibeerp/platform/i18n/IcuTranslator.kt 0 → 100644
  1 +package org.vibeerp.platform.i18n
  2 +
  3 +import com.ibm.icu.text.MessageFormat
  4 +import org.slf4j.LoggerFactory
  5 +import org.vibeerp.api.v1.i18n.MessageKey
  6 +import org.vibeerp.api.v1.i18n.Translator
  7 +import java.util.Locale
  8 +import java.util.MissingResourceException
  9 +import java.util.ResourceBundle
  10 +
  11 +/**
  12 + * The framework's [Translator] implementation, backed by:
  13 + * - **JDK [ResourceBundle]** for locating `<baseName>_<locale>.properties`
  14 + * files on a chain of class loaders (locale fallback to base bundle is
  15 + * handled by the JDK).
  16 + * - **ICU4J [com.ibm.icu.text.MessageFormat]** for actual formatting,
  17 + * so plurals (`{count, plural, ...}`), gender, number/date/currency,
  18 + * and language-aware ordering all work without the caller doing
  19 + * anything special.
  20 + *
  21 + * **Why ICU4J's MessageFormat instead of the JDK's `java.text.MessageFormat`:**
  22 + * The JDK class supports positional placeholders only (`{0}`, `{1}`) and
  23 + * has anaemic plural / gender support. ICU4J supports named placeholders
  24 + * (`{user}`, `{count, plural, one {# item} other {# items}}`) which is the
  25 + * format every modern translation tool (Crowdin, Phrase, Lokalise, Tolgee)
  26 + * speaks natively. CLAUDE.md guardrail #6 requires the framework to be
  27 + * sellable worldwide; that means picking a translation format the world's
  28 + * translation tooling already understands.
  29 + *
  30 + * **Bundle locations and the parent-first classloader trap.**
  31 + * The constructor takes a list of [BundleLocation] entries — each one a
  32 + * `(classLoader, baseName)` pair — tried in order. For the **core**
  33 + * translator the chain is just `[(host, "messages")]`. For a
  34 + * **per-plug-in** translator it is
  35 + * `[(pluginClassLoader, "META-INF/vibe-erp/i18n/messages"), (host, "messages")]`.
  36 + *
  37 + * The plug-in entry uses a deliberately host-free path
  38 + * (`META-INF/vibe-erp/i18n/messages`) instead of the host's own
  39 + * `messages` baseName. That matters because PF4J's `PluginClassLoader`
  40 + * is parent-first: a `getResource("messages.properties")` call against
  41 + * a plug-in classloader would find the HOST's bundle through the
  42 + * parent chain, completely defeating the per-plug-in override. By
  43 + * naming the plug-in resource somewhere the host does NOT publish, the
  44 + * lookup is guaranteed to come from inside the plug-in JAR alone.
  45 + *
  46 + * **Locale fallback.** [ResourceBundle.getBundle] walks the locale
  47 + * candidate chain itself (e.g. `zh_CN` → `zh` → root). On top of that,
  48 + * if a key is missing from EVERY bundle on EVERY location for EVERY
  49 + * candidate locale, the translator returns the key string itself —
  50 + * never `null`, never an empty string. The contract is "make the
  51 + * failure visible in the UI rather than silently swallowed", which is
  52 + * the rule on [Translator.translate].
  53 + *
  54 + * Reference: implementation plan unit P1.6.
  55 + */
  56 +class IcuTranslator(
  57 + private val locations: List<BundleLocation>,
  58 +) : Translator {
  59 +
  60 + init {
  61 + require(locations.isNotEmpty()) { "IcuTranslator needs at least one bundle location" }
  62 + }
  63 +
  64 + /**
  65 + * One pair of `(classLoader, baseName)`. The translator tries
  66 + * each in order; the first that contains the requested key wins.
  67 + */
  68 + data class BundleLocation(
  69 + val classLoader: ClassLoader,
  70 + val baseName: String,
  71 + )
  72 +
  73 + private val log = LoggerFactory.getLogger(IcuTranslator::class.java)
  74 +
  75 + override fun translate(key: MessageKey, locale: Locale, args: Map<String, Any>): String {
  76 + val pattern = lookupPattern(key.key, locale)
  77 + ?: run {
  78 + // Visible failure mode: return the key so UI shows
  79 + // "printingshop.plate.created" instead of empty space.
  80 + log.debug("translation miss for key='{}' locale={}", key.key, locale)
  81 + return key.key
  82 + }
  83 + if (args.isEmpty()) return pattern
  84 + return try {
  85 + // ICU4J's MessageFormat is the named-arg variant; if a key
  86 + // doesn't include any placeholders this is still safe.
  87 + MessageFormat(pattern, locale).format(args)
  88 + } catch (ex: IllegalArgumentException) {
  89 + // A malformed pattern (translator typo) shouldn't take down
  90 + // a request — log it and return the raw pattern instead so
  91 + // the UI shows something. The translator's job is to never
  92 + // throw at call sites.
  93 + log.warn("ICU4J MessageFormat failed for key='{}' locale={}: {}", key.key, locale, ex.message)
  94 + pattern
  95 + }
  96 + }
  97 +
  98 + /**
  99 + * Walk the bundle locations and return the first matching pattern.
  100 + * The JDK's [ResourceBundle.getBundle] handles `zh_CN → zh → root`
  101 + * fallback within a single bundle; we add the cross-location
  102 + * dimension on top.
  103 + */
  104 + private fun lookupPattern(key: String, locale: Locale): String? {
  105 + for ((cl, baseName) in locations) {
  106 + val pattern = try {
  107 + val bundle = ResourceBundle.getBundle(baseName, locale, cl, NO_FALLBACK_TO_DEFAULT)
  108 + bundle.takeIf { it.containsKey(key) }?.getString(key)
  109 + } catch (ex: MissingResourceException) {
  110 + // Bundle not found at this location; try the next one.
  111 + null
  112 + }
  113 + if (pattern != null) return pattern
  114 + }
  115 + return null
  116 + }
  117 +
  118 + companion object {
  119 + /**
  120 + * The convention for **core** message bundles: `messages.properties`,
  121 + * `messages_zh_CN.properties`, `messages_de.properties`, etc., at
  122 + * the host classpath root.
  123 + */
  124 + const val CORE_BASE_NAME: String = "messages"
  125 +
  126 + /**
  127 + * The convention for **plug-in** message bundles. Plug-ins ship
  128 + * their `messages*.properties` files inside their JAR at
  129 + * `META-INF/vibe-erp/i18n/`. This path is deliberately one the
  130 + * host does not claim, so the parent-first PF4J classloader
  131 + * cannot return a host bundle by mistake.
  132 + */
  133 + const val PLUGIN_BASE_NAME: String = "META-INF/vibe-erp/i18n/messages"
  134 +
  135 + /**
  136 + * A [ResourceBundle.Control] that disables the JDK's automatic
  137 + * "fall back to the runtime default locale" step.
  138 + *
  139 + * The default control, after walking `requestedLocale → its
  140 + * parents → ROOT`, also tries `defaultLocale → its parents →
  141 + * ROOT`. That means a JVM running on `LANG=de_DE` will silently
  142 + * serve German strings to a request that asked for Japanese,
  143 + * just because the German bundle exists. We never want that —
  144 + * the framework's fallback chain is the explicit one we built,
  145 + * ending in the requested locale's bundle, then ROOT, then
  146 + * (cross-location) the next entry, then the key itself. No
  147 + * implicit JVM-default detour.
  148 + */
  149 + private val NO_FALLBACK_TO_DEFAULT: ResourceBundle.Control = object : ResourceBundle.Control() {
  150 + override fun getFallbackLocale(baseName: String?, locale: Locale?): Locale? = null
  151 + }
  152 + }
  153 +}
platform/platform-i18n/src/main/kotlin/org/vibeerp/platform/i18n/RequestLocaleProvider.kt 0 → 100644
  1 +package org.vibeerp.platform.i18n
  2 +
  3 +import org.springframework.web.context.request.RequestContextHolder
  4 +import org.springframework.web.context.request.ServletRequestAttributes
  5 +import org.vibeerp.api.v1.i18n.LocaleProvider
  6 +import java.util.Locale
  7 +
  8 +/**
  9 + * The framework's [LocaleProvider] implementation.
  10 + *
  11 + * Resolution chain (first match wins):
  12 + *
  13 + * 1. **Active HTTP request's `Accept-Language` header**, parsed via
  14 + * Spring's [jakarta.servlet.http.HttpServletRequest.getLocale]. The
  15 + * servlet container handles q-value sorting and language tag
  16 + * parsing for us — we don't reimplement RFC 4647.
  17 + * 2. **Configured platform default** (`vibeerp.i18n.defaultLocale` in
  18 + * application.yaml, default `en`). This kicks in for background
  19 + * jobs, scheduled tasks, workflow steps, MCP agents, system
  20 + * principals — anywhere there is no active HTTP request.
  21 + *
  22 + * Future precedence steps that are NOT in v1 but the contract leaves
  23 + * room for: (a) authenticated user's preferred locale from their
  24 + * profile row; (b) per-instance "shop locale" override stored in the
  25 + * metadata config table. Both can be added without changing the
  26 + * `LocaleProvider` interface — they slot in between the request and
  27 + * the configured default.
  28 + *
  29 + * **Why not Spring's `LocaleContextHolder`:** that holder is wired by
  30 + * Spring MVC's `LocaleResolver` chain, which has its own opinions about
  31 + * how locales are resolved (cookies, session, fixed). We deliberately
  32 + * sidestep all of that and read the request directly so the chain is
  33 + * the framework's, not Spring MVC's. The choice of resolution rules is
  34 + * a vibe_erp guardrail-level decision (CLAUDE.md #6), not a Spring
  35 + * configuration question.
  36 + */
  37 +class RequestLocaleProvider(
  38 + private val defaultLocale: Locale,
  39 +) : LocaleProvider {
  40 +
  41 + override fun current(): Locale {
  42 + val attrs = RequestContextHolder.getRequestAttributes()
  43 + if (attrs is ServletRequestAttributes) {
  44 + // The servlet container parses Accept-Language for us;
  45 + // when the header is absent it returns the JVM default,
  46 + // which is wrong for us, so we have to detect that case.
  47 + val request = attrs.request
  48 + val header = request.getHeader("Accept-Language")
  49 + if (!header.isNullOrBlank()) {
  50 + return request.locale ?: defaultLocale
  51 + }
  52 + }
  53 + return defaultLocale
  54 + }
  55 +}
platform/platform-i18n/src/main/resources/messages.properties 0 → 100644
  1 +# vibe_erp core message bundle — English (the source locale).
  2 +#
  3 +# This is the FALLBACK bundle. ResourceBundle resolves keys via
  4 +# requested-locale → its parents → ROOT (this file). The framework's
  5 +# default locale (vibeerp.i18n.defaultLocale) is also `en` out of the
  6 +# box, so most operators will see these strings unless they explicitly
  7 +# pick a different locale via Accept-Language or the configuration.
  8 +#
  9 +# Naming convention: `<area>.<resource>.<message>`. Enforced at runtime
  10 +# by org.vibeerp.api.v1.i18n.MessageKey's regex.
  11 +
  12 +# ─── Common ───────────────────────────────────────────────────────────
  13 +common.ok = OK
  14 +common.cancel = Cancel
  15 +common.save = Save
  16 +common.delete = Delete
  17 +
  18 +# ─── Errors ───────────────────────────────────────────────────────────
  19 +errors.not_found = The requested item was not found.
  20 +errors.duplicate_code = The code ''{code}'' is already in use.
  21 +errors.invalid_argument = Invalid input: {detail}
  22 +errors.unauthorised = You are not authorised to perform this action.
  23 +
  24 +# ─── Counts (ICU plural) ─────────────────────────────────────────────
  25 +counts.items = {count, plural, =0 {no items} one {1 item} other {# items}}
platform/platform-i18n/src/main/resources/messages_de.properties 0 → 100644
  1 +# vibe_erp Kernnachrichtenbundle — Deutsch.
  2 +
  3 +common.ok = OK
  4 +common.cancel = Abbrechen
  5 +common.save = Speichern
  6 +common.delete = Löschen
  7 +
  8 +errors.not_found = Das angeforderte Element wurde nicht gefunden.
  9 +errors.duplicate_code = Der Code ''{code}'' ist bereits vergeben.
  10 +errors.invalid_argument = Ungültige Eingabe: {detail}
  11 +errors.unauthorised = Sie sind nicht berechtigt, diese Aktion auszuführen.
  12 +
  13 +counts.items = {count, plural, =0 {keine Einträge} one {1 Eintrag} other {# Einträge}}
platform/platform-i18n/src/main/resources/messages_zh_CN.properties 0 → 100644
  1 +# vibe_erp 核心消息包 — 简体中文。
  2 +#
  3 +# Java 9+ reads .properties files as UTF-8 by default (JEP 226), so
  4 +# Chinese characters are written here directly rather than as \uXXXX
  5 +# escapes. This file is read via java.util.ResourceBundle, which
  6 +# follows the same convention.
  7 +
  8 +common.ok = 确定
  9 +common.cancel = 取消
  10 +common.save = 保存
  11 +common.delete = 删除
  12 +
  13 +errors.not_found = 未找到所请求的项目。
  14 +errors.duplicate_code = 编码 ''{code}'' 已被使用。
  15 +errors.invalid_argument = 输入无效: {detail}
  16 +errors.unauthorised = 您无权执行此操作。
  17 +
  18 +counts.items = {count, plural, =0 {无项目} other {# 个项目}}
platform/platform-i18n/src/test/kotlin/org/vibeerp/platform/i18n/IcuTranslatorTest.kt 0 → 100644
  1 +package org.vibeerp.platform.i18n
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.isEqualTo
  5 +import org.junit.jupiter.api.Test
  6 +import org.vibeerp.api.v1.i18n.MessageKey
  7 +import java.net.URLClassLoader
  8 +import java.nio.file.Files
  9 +import java.util.Locale
  10 +import java.util.jar.Attributes
  11 +import java.util.jar.JarOutputStream
  12 +import java.util.jar.Manifest
  13 +import java.util.zip.ZipEntry
  14 +
  15 +class IcuTranslatorTest {
  16 +
  17 + private val hostCl: ClassLoader = IcuTranslatorTest::class.java.classLoader
  18 +
  19 + /**
  20 + * Two test message bundles ship with `src/test/resources` so the
  21 + * test classloader picks them up automatically:
  22 + * - `test-messages.properties` — base
  23 + * - `test-messages_zh_CN.properties` — zh-CN variant
  24 + */
  25 + private fun coreOnly() = IcuTranslator(
  26 + locations = listOf(IcuTranslator.BundleLocation(hostCl, "test-messages")),
  27 + )
  28 +
  29 + @Test
  30 + fun `plain key lookup returns the base bundle string`() {
  31 + val t = coreOnly()
  32 + val out = t.translate(MessageKey("test.simple.greeting"), Locale.ENGLISH)
  33 + assertThat(out).isEqualTo("Hello, world")
  34 + }
  35 +
  36 + @Test
  37 + fun `ICU named-arg substitution works`() {
  38 + val t = coreOnly()
  39 + val out = t.translate(
  40 + key = MessageKey("test.greeting.named"),
  41 + locale = Locale.ENGLISH,
  42 + args = mapOf("user" to "Marie"),
  43 + )
  44 + assertThat(out).isEqualTo("Hello, Marie!")
  45 + }
  46 +
  47 + @Test
  48 + fun `ICU plural one variant`() {
  49 + val t = coreOnly()
  50 + val out = t.translate(
  51 + key = MessageKey("test.counts.items"),
  52 + locale = Locale.ENGLISH,
  53 + args = mapOf("count" to 1),
  54 + )
  55 + assertThat(out).isEqualTo("1 item")
  56 + }
  57 +
  58 + @Test
  59 + fun `ICU plural other variant`() {
  60 + val t = coreOnly()
  61 + val out = t.translate(
  62 + key = MessageKey("test.counts.items"),
  63 + locale = Locale.ENGLISH,
  64 + args = mapOf("count" to 5),
  65 + )
  66 + assertThat(out).isEqualTo("5 items")
  67 + }
  68 +
  69 + @Test
  70 + fun `zh_CN locale picks the localised bundle`() {
  71 + val t = coreOnly()
  72 + val out = t.translate(MessageKey("test.simple.greeting"), Locale.SIMPLIFIED_CHINESE)
  73 + assertThat(out).isEqualTo("你好,世界")
  74 + }
  75 +
  76 + @Test
  77 + fun `key missing from every bundle returns the key itself`() {
  78 + val t = coreOnly()
  79 + val out = t.translate(MessageKey("test.does_not.exist"), Locale.ENGLISH)
  80 + assertThat(out).isEqualTo("test.does_not.exist")
  81 + }
  82 +
  83 + @Test
  84 + fun `unknown locale falls back to base bundle, not JVM default`() {
  85 + // The framework's NO_FALLBACK_TO_DEFAULT control means a
  86 + // request for Japanese (which has no test-messages_ja.properties)
  87 + // resolves via the explicit chain: ja → ROOT (the base
  88 + // test-messages.properties), NOT via the JVM default locale.
  89 + val t = coreOnly()
  90 + val out = t.translate(MessageKey("test.simple.greeting"), Locale.JAPANESE)
  91 + assertThat(out).isEqualTo("Hello, world")
  92 + }
  93 +
  94 + @Test
  95 + fun `plug-in bundle in JAR overrides core bundle for matching key`() {
  96 + // Build a tiny JAR with a META-INF/vibe-erp/i18n/messages.properties
  97 + // entry that overrides 'test.simple.greeting'. The plug-in
  98 + // location is tried first, so the override wins.
  99 + val jarPath = Files.createTempFile("plugin-i18n-test-", ".jar")
  100 + JarOutputStream(Files.newOutputStream(jarPath), Manifest().also {
  101 + it.mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0"
  102 + }).use { jos ->
  103 + jos.putNextEntry(ZipEntry("META-INF/vibe-erp/i18n/messages.properties"))
  104 + jos.write("test.simple.greeting=Plug-in override\n".toByteArray(Charsets.UTF_8))
  105 + jos.closeEntry()
  106 + }
  107 +
  108 + URLClassLoader(arrayOf(jarPath.toUri().toURL()), hostCl).use { pluginCl ->
  109 + val t = IcuTranslator(
  110 + locations = listOf(
  111 + IcuTranslator.BundleLocation(pluginCl, IcuTranslator.PLUGIN_BASE_NAME),
  112 + IcuTranslator.BundleLocation(hostCl, "test-messages"),
  113 + ),
  114 + )
  115 +
  116 + assertThat(t.translate(MessageKey("test.simple.greeting"), Locale.ENGLISH))
  117 + .isEqualTo("Plug-in override")
  118 +
  119 + // Keys NOT in the plug-in bundle still fall back to the
  120 + // host bundle — verifying the chain works in both directions.
  121 + assertThat(t.translate(MessageKey("test.greeting.named"), Locale.ENGLISH, mapOf("user" to "Bob")))
  122 + .isEqualTo("Hello, Bob!")
  123 + }
  124 +
  125 + Files.deleteIfExists(jarPath)
  126 + }
  127 +}
platform/platform-i18n/src/test/kotlin/org/vibeerp/platform/i18n/RequestLocaleProviderTest.kt 0 → 100644
  1 +package org.vibeerp.platform.i18n
  2 +
  3 +import assertk.assertThat
  4 +import assertk.assertions.isEqualTo
  5 +import jakarta.servlet.http.HttpServletRequest
  6 +import io.mockk.every
  7 +import io.mockk.mockk
  8 +import org.junit.jupiter.api.AfterEach
  9 +import org.junit.jupiter.api.Test
  10 +import org.springframework.web.context.request.RequestContextHolder
  11 +import org.springframework.web.context.request.ServletRequestAttributes
  12 +import java.util.Locale
  13 +
  14 +class RequestLocaleProviderTest {
  15 +
  16 + @AfterEach
  17 + fun clear() {
  18 + RequestContextHolder.resetRequestAttributes()
  19 + }
  20 +
  21 + @Test
  22 + fun `outside an HTTP request returns the configured default`() {
  23 + val provider = RequestLocaleProvider(defaultLocale = Locale.GERMAN)
  24 + assertThat(provider.current()).isEqualTo(Locale.GERMAN)
  25 + }
  26 +
  27 + @Test
  28 + fun `Accept-Language header parsed via servlet container takes precedence`() {
  29 + val request = mockk<HttpServletRequest>(relaxed = true)
  30 + every { request.getHeader("Accept-Language") } returns "zh-CN,en;q=0.5"
  31 + every { request.locale } returns Locale.SIMPLIFIED_CHINESE
  32 + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
  33 +
  34 + val provider = RequestLocaleProvider(defaultLocale = Locale.ENGLISH)
  35 + assertThat(provider.current()).isEqualTo(Locale.SIMPLIFIED_CHINESE)
  36 + }
  37 +
  38 + @Test
  39 + fun `request without Accept-Language falls back to default, not the JVM locale`() {
  40 + // The servlet container's getLocale() defaults to the JVM
  41 + // locale when no Accept-Language is present, which is wrong
  42 + // for us. The provider must check the header first and
  43 + // fall back to the configured default — never to the JVM
  44 + // default. This test makes that contract explicit.
  45 + val request = mockk<HttpServletRequest>(relaxed = true)
  46 + every { request.getHeader("Accept-Language") } returns null
  47 + // request.locale would be Locale.getDefault() here; the
  48 + // provider must NOT consult it.
  49 + RequestContextHolder.setRequestAttributes(ServletRequestAttributes(request))
  50 +
  51 + val provider = RequestLocaleProvider(defaultLocale = Locale.JAPANESE)
  52 + assertThat(provider.current()).isEqualTo(Locale.JAPANESE)
  53 + }
  54 +}
platform/platform-i18n/src/test/resources/test-messages.properties 0 → 100644
  1 +# Test bundle for IcuTranslatorTest. Lives in platform-i18n test
  2 +# resources only — never shipped in any production JAR.
  3 +test.simple.greeting = Hello, world
  4 +test.greeting.named = Hello, {user}!
  5 +test.counts.items = {count, plural, one {1 item} other {# items}}
platform/platform-i18n/src/test/resources/test-messages_zh_CN.properties 0 → 100644
  1 +test.simple.greeting = 你好,世界
platform/platform-plugins/build.gradle.kts
@@ -26,6 +26,7 @@ dependencies { @@ -26,6 +26,7 @@ dependencies {
26 implementation(libs.jackson.module.kotlin) 26 implementation(libs.jackson.module.kotlin)
27 implementation(project(":platform:platform-events")) // for the real EventBus injected into PluginContext 27 implementation(project(":platform:platform-events")) // for the real EventBus injected into PluginContext
28 implementation(project(":platform:platform-metadata")) // for per-plug-in MetadataLoader.load(...) 28 implementation(project(":platform:platform-metadata")) // for per-plug-in MetadataLoader.load(...)
  29 + implementation(project(":platform:platform-i18n")) // for per-plug-in IcuTranslator + shared LocaleProvider
29 30
30 implementation(libs.spring.boot.starter) 31 implementation(libs.spring.boot.starter)
31 implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher 32 implementation(libs.spring.boot.starter.web) // for @RestController on the dispatcher
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/DefaultPluginContext.kt
@@ -15,24 +15,29 @@ import org.vibeerp.api.v1.security.PermissionCheck @@ -15,24 +15,29 @@ import org.vibeerp.api.v1.security.PermissionCheck
15 /** 15 /**
16 * The host's [PluginContext] implementation. 16 * The host's [PluginContext] implementation.
17 * 17 *
18 - * v0.5 wires three of the eight services for real:  
19 - * • [logger] — backed by SLF4J, every line tagged with the plug-in id  
20 - * • [endpoints] — backed by the per-plug-in scoped registrar 18 + * Wired services as of v0.7:
  19 + * • [logger] — backed by SLF4J, every line tagged with the plug-in id (P1.3)
  20 + * • [endpoints] — backed by the per-plug-in scoped registrar (P1.3)
  21 + * • [eventBus] — real in-process bus + transactional outbox (P1.7)
  22 + * • [jdbc] — typed SQL against the host datasource (P1.4)
  23 + * • [translator] — ICU4J-backed, plug-in-classloader-first (P1.6)
  24 + * • [localeProvider] — request Accept-Language with platform default fallback (P1.6)
21 * • everything else — throws `UnsupportedOperationException` with a 25 * • everything else — throws `UnsupportedOperationException` with a
22 * message pointing at the implementation plan unit that will land it 26 * message pointing at the implementation plan unit that will land it
23 * 27 *
24 - * Why throw rather than provide stub no-ops: a plug-in that calls  
25 - * `context.translator.translate(...)` and silently gets back an empty  
26 - * string, or `context.eventBus.publish(...)` that silently drops the  
27 - * event, would create extremely subtle bugs that only surface much  
28 - * later. A loud `UnsupportedOperationException` at first call surfaces  
29 - * the gap immediately, in dev, on a live request, with a clear message  
30 - * saying "this lands in P1.x". 28 + * Why throw rather than provide stub no-ops on the remaining gaps: a
  29 + * plug-in that calls `context.permissionCheck.has(...)` and silently
  30 + * gets `true`, or `context.transaction.run(...)` that silently no-ops,
  31 + * would create subtle bugs that only surface much later. A loud
  32 + * `UnsupportedOperationException` at first call surfaces the gap
  33 + * immediately, in dev, on a live request, with a clear message saying
  34 + * "this lands in P1.x".
31 * 35 *
32 * Per-plug-in: every loaded plug-in gets its own [DefaultPluginContext] 36 * Per-plug-in: every loaded plug-in gets its own [DefaultPluginContext]
33 - * instance because the [logger] and [endpoints] are scoped to the  
34 - * plug-in id. Sharing would let one plug-in's logs masquerade as  
35 - * another's. 37 + * instance because the [logger], [endpoints], and [translator] are
  38 + * scoped to the plug-in id (so the translator can resolve the plug-in's
  39 + * own `messages_<locale>.properties` ahead of the host's). Sharing
  40 + * would let one plug-in's keys collide with another's.
36 */ 41 */
37 internal class DefaultPluginContext( 42 internal class DefaultPluginContext(
38 pluginId: String, 43 pluginId: String,
@@ -40,6 +45,8 @@ internal class DefaultPluginContext( @@ -40,6 +45,8 @@ internal class DefaultPluginContext(
40 delegateLogger: Logger, 45 delegateLogger: Logger,
41 private val sharedEventBus: EventBus, 46 private val sharedEventBus: EventBus,
42 private val sharedJdbc: PluginJdbc, 47 private val sharedJdbc: PluginJdbc,
  48 + private val pluginTranslator: Translator,
  49 + private val sharedLocaleProvider: LocaleProvider,
43 ) : PluginContext { 50 ) : PluginContext {
44 51
45 override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger) 52 override val logger: PluginLogger = Slf4jPluginLogger(pluginId, delegateLogger)
@@ -70,6 +77,25 @@ internal class DefaultPluginContext( @@ -70,6 +77,25 @@ internal class DefaultPluginContext(
70 */ 77 */
71 override val jdbc: PluginJdbc = sharedJdbc 78 override val jdbc: PluginJdbc = sharedJdbc
72 79
  80 + /**
  81 + * Per-plug-in ICU4J translator, wired in P1.6. The classloader
  82 + * chain is `[pluginClassLoader, hostClassLoader]` so a key defined
  83 + * in the plug-in's own `messages_<locale>.properties` resolves
  84 + * before the host's version. Falls back to the host bundle for
  85 + * shared keys (e.g. `errors.notFound`).
  86 + */
  87 + override val translator: Translator = pluginTranslator
  88 +
  89 + /**
  90 + * Shared host [LocaleProvider]. The "current locale" decision is
  91 + * the host's responsibility — plug-ins should never reach into HTTP
  92 + * headers themselves because that breaks for non-HTTP callers
  93 + * (workflow tasks, scheduled jobs, MCP agents). The provider
  94 + * resolves the active request's `Accept-Language` and falls back
  95 + * to `vibeerp.i18n.defaultLocale` outside an HTTP request.
  96 + */
  97 + override val localeProvider: LocaleProvider = sharedLocaleProvider
  98 +
73 // ─── Not yet implemented ─────────────────────────────────────── 99 // ─── Not yet implemented ───────────────────────────────────────
74 100
75 override val transaction: Transaction 101 override val transaction: Transaction
@@ -77,16 +103,6 @@ internal class DefaultPluginContext( @@ -77,16 +103,6 @@ internal class DefaultPluginContext(
77 "PluginContext.transaction is not yet implemented; lands alongside the plug-in JPA story" 103 "PluginContext.transaction is not yet implemented; lands alongside the plug-in JPA story"
78 ) 104 )
79 105
80 - override val translator: Translator  
81 - get() = throw UnsupportedOperationException(  
82 - "PluginContext.translator is not yet implemented; lands in P1.6 (ICU4J translator)"  
83 - )  
84 -  
85 - override val localeProvider: LocaleProvider  
86 - get() = throw UnsupportedOperationException(  
87 - "PluginContext.localeProvider is not yet implemented; lands in P1.6"  
88 - )  
89 -  
90 override val permissionCheck: PermissionCheck 106 override val permissionCheck: PermissionCheck
91 get() = throw UnsupportedOperationException( 107 get() = throw UnsupportedOperationException(
92 "PluginContext.permissionCheck is not yet implemented; lands in P4.3 (real permissions)" 108 "PluginContext.permissionCheck is not yet implemented; lands in P4.3 (real permissions)"
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt
@@ -8,7 +8,9 @@ import org.springframework.beans.factory.InitializingBean @@ -8,7 +8,9 @@ import org.springframework.beans.factory.InitializingBean
8 import org.springframework.boot.context.properties.ConfigurationProperties 8 import org.springframework.boot.context.properties.ConfigurationProperties
9 import org.springframework.stereotype.Component 9 import org.springframework.stereotype.Component
10 import org.vibeerp.api.v1.event.EventBus 10 import org.vibeerp.api.v1.event.EventBus
  11 +import org.vibeerp.api.v1.i18n.LocaleProvider
11 import org.vibeerp.api.v1.plugin.PluginJdbc 12 import org.vibeerp.api.v1.plugin.PluginJdbc
  13 +import org.vibeerp.platform.i18n.IcuTranslator
12 import org.vibeerp.platform.metadata.MetadataLoader 14 import org.vibeerp.platform.metadata.MetadataLoader
13 import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry 15 import org.vibeerp.platform.plugins.endpoints.PluginEndpointRegistry
14 import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar 16 import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar
@@ -58,6 +60,7 @@ class VibeErpPluginManager( @@ -58,6 +60,7 @@ class VibeErpPluginManager(
58 private val linter: PluginLinter, 60 private val linter: PluginLinter,
59 private val liquibaseRunner: PluginLiquibaseRunner, 61 private val liquibaseRunner: PluginLiquibaseRunner,
60 private val metadataLoader: MetadataLoader, 62 private val metadataLoader: MetadataLoader,
  63 + private val localeProvider: LocaleProvider,
61 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean { 64 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean {
62 65
63 private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java) 66 private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java)
@@ -195,12 +198,32 @@ class VibeErpPluginManager( @@ -195,12 +198,32 @@ class VibeErpPluginManager(
195 ) 198 )
196 return 199 return
197 } 200 }
  201 + // Per-plug-in translator: bundle location chain puts the plug-in's
  202 + // own META-INF/vibe-erp/i18n/messages_<locale>.properties ahead
  203 + // of the host's messages.properties, so a key like
  204 + // 'printingshop.plate.created' resolves from inside the plug-in
  205 + // JAR while shared keys (errors.not_found, etc.) still fall
  206 + // through to the host bundles.
  207 + //
  208 + // The plug-in baseName deliberately uses a path the host does
  209 + // NOT publish (META-INF/vibe-erp/i18n/messages) — see the
  210 + // "parent-first classloader trap" rationale on IcuTranslator.
  211 + val pluginClassLoader: ClassLoader = wrapper.pluginClassLoader
  212 + val hostClassLoader: ClassLoader = VibeErpPluginManager::class.java.classLoader
  213 + val pluginTranslator = IcuTranslator(
  214 + locations = listOf(
  215 + IcuTranslator.BundleLocation(pluginClassLoader, IcuTranslator.PLUGIN_BASE_NAME),
  216 + IcuTranslator.BundleLocation(hostClassLoader, IcuTranslator.CORE_BASE_NAME),
  217 + ),
  218 + )
198 val context = DefaultPluginContext( 219 val context = DefaultPluginContext(
199 pluginId = pluginId, 220 pluginId = pluginId,
200 sharedRegistrar = ScopedPluginEndpointRegistrar(endpointRegistry, pluginId), 221 sharedRegistrar = ScopedPluginEndpointRegistrar(endpointRegistry, pluginId),
201 delegateLogger = LoggerFactory.getLogger("plugin.$pluginId"), 222 delegateLogger = LoggerFactory.getLogger("plugin.$pluginId"),
202 sharedEventBus = eventBus, 223 sharedEventBus = eventBus,
203 sharedJdbc = jdbc, 224 sharedJdbc = jdbc,
  225 + pluginTranslator = pluginTranslator,
  226 + sharedLocaleProvider = localeProvider,
204 ) 227 )
205 try { 228 try {
206 vibeErpPlugin.start(context) 229 vibeErpPlugin.start(context)
reference-customer/plugin-printing-shop/src/main/kotlin/org/vibeerp/reference/printingshop/PrintingShopPlugin.kt
1 package org.vibeerp.reference.printingshop 1 package org.vibeerp.reference.printingshop
2 2
3 import org.pf4j.PluginWrapper 3 import org.pf4j.PluginWrapper
  4 +import org.vibeerp.api.v1.i18n.MessageKey
4 import org.vibeerp.api.v1.plugin.HttpMethod 5 import org.vibeerp.api.v1.plugin.HttpMethod
5 import org.vibeerp.api.v1.plugin.PluginContext 6 import org.vibeerp.api.v1.plugin.PluginContext
6 import org.vibeerp.api.v1.plugin.PluginRequest 7 import org.vibeerp.api.v1.plugin.PluginRequest
@@ -172,6 +173,21 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP @@ -172,6 +173,21 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP
172 "now" to java.sql.Timestamp.from(Instant.now()), 173 "now" to java.sql.Timestamp.from(Instant.now()),
173 ), 174 ),
174 ) 175 )
  176 + // Demonstrate the i18n surface (P1.6). The message is
  177 + // looked up via the per-plug-in IcuTranslator wired in
  178 + // DefaultPluginContext: the plug-in's own
  179 + // META-INF/vibe-erp/i18n/messages_<locale>.properties
  180 + // takes precedence, falling back to the host bundles for
  181 + // shared keys. The current locale comes from the request's
  182 + // Accept-Language header via the framework's
  183 + // RequestLocaleProvider — the plug-in never reads HTTP
  184 + // headers itself, which would break for non-HTTP callers.
  185 + val locale = context.localeProvider.current()
  186 + val message = context.translator.translate(
  187 + key = MessageKey("printingshop.plate.created"),
  188 + locale = locale,
  189 + args = mapOf("number" to code, "ink_count" to 0),
  190 + )
175 PluginResponse( 191 PluginResponse(
176 status = 201, 192 status = 201,
177 body = mapOf( 193 body = mapOf(
@@ -181,6 +197,8 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP @@ -181,6 +197,8 @@ class PrintingShopPlugin(wrapper: PluginWrapper) : Pf4jPlugin(wrapper), VibeErpP
181 "widthMm" to widthMm, 197 "widthMm" to widthMm,
182 "heightMm" to heightMm, 198 "heightMm" to heightMm,
183 "status" to "DRAFT", 199 "status" to "DRAFT",
  200 + "message" to message,
  201 + "locale" to locale.toLanguageTag(),
184 ), 202 ),
185 ) 203 )
186 } 204 }
reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/i18n/messages.properties 0 → 100644
  1 +# Reference printing-shop plug-in — base (English) message bundle.
  2 +#
  3 +# Loaded by the per-plug-in IcuTranslator constructed in
  4 +# VibeErpPluginManager. The base name is META-INF/vibe-erp/i18n/messages
  5 +# (a path the host does NOT publish) so PF4J's parent-first
  6 +# PluginClassLoader cannot return the host bundle by mistake.
  7 +#
  8 +# Naming convention: <plugin-id>.<area>.<message>. The plug-in id
  9 +# prefix stops two plug-ins from accidentally colliding on a generic
  10 +# key like "form.title".
  11 +
  12 +printingshop.plate.title = Plate
  13 +printingshop.plate.create = Create plate
  14 +printingshop.plate.created = Plate ''{number}'' created with {ink_count, plural, =0 {no inks} one {1 ink} other {# inks}}.
  15 +printingshop.plate.approve = Approve plate
  16 +printingshop.job.title = Job card
  17 +printingshop.job.dispatch = Dispatch to press
  18 +printingshop.workflow.quote_to_job_card.name = Quote to job card
reference-customer/plugin-printing-shop/src/main/resources/META-INF/vibe-erp/i18n/messages_zh_CN.properties 0 → 100644
  1 +# 参考印刷厂插件 — 简体中文消息包。
  2 +# UTF-8 encoded; Java 9+ ResourceBundle reads .properties files as
  3 +# UTF-8 by default (JEP 226).
  4 +
  5 +printingshop.plate.title = 印版
  6 +printingshop.plate.create = 新建印版
  7 +printingshop.plate.created = 已创建印版 ''{number}'' ({ink_count, plural, =0 {无油墨} other {# 种油墨}})。
  8 +printingshop.plate.approve = 审批印版
  9 +printingshop.job.title = 工单
  10 +printingshop.job.dispatch = 派工至印刷机
  11 +printingshop.workflow.quote_to_job_card.name = 报价转工单
reference-customer/plugin-printing-shop/src/main/resources/i18n/messages_en-US.properties deleted
1 -# Reference printing-shop plug-in — English (US) message bundle.  
2 -#  
3 -# Keys follow the convention <plugin-id>.<area>.<message> declared in  
4 -# CLAUDE.md guardrail #6 / architecture spec section 7. The plug-in id  
5 -# prefix is critical: it stops two plug-ins from accidentally colliding  
6 -# on a generic key like "form.title".  
7 -  
8 -printingshop.plate.title=Plate  
9 -printingshop.plate.create=Create plate  
10 -printingshop.plate.approve=Approve plate  
11 -printingshop.job.title=Job card  
12 -printingshop.job.dispatch=Dispatch to press  
13 -printingshop.workflow.quoteToJobCard.name=Quote to job card  
reference-customer/plugin-printing-shop/src/main/resources/i18n/messages_zh-CN.properties deleted
1 -# Reference printing-shop plug-in — Simplified Chinese message bundle.  
2 -# UTF-8 encoded; Spring's MessageSource is configured to read .properties  
3 -# files as UTF-8 in api.v1 / platform-i18n.  
4 -  
5 -printingshop.plate.title=印版  
6 -printingshop.plate.create=新建印版  
7 -printingshop.plate.approve=审批印版  
8 -printingshop.job.title=工单  
9 -printingshop.job.dispatch=派工至印刷机  
10 -printingshop.workflow.quoteToJobCard.name=报价转工单  
settings.gradle.kts
@@ -39,6 +39,9 @@ project(&quot;:platform:platform-events&quot;).projectDir = file(&quot;platform/platform-events @@ -39,6 +39,9 @@ project(&quot;:platform:platform-events&quot;).projectDir = file(&quot;platform/platform-events
39 include(":platform:platform-metadata") 39 include(":platform:platform-metadata")
40 project(":platform:platform-metadata").projectDir = file("platform/platform-metadata") 40 project(":platform:platform-metadata").projectDir = file("platform/platform-metadata")
41 41
  42 +include(":platform:platform-i18n")
  43 +project(":platform:platform-i18n").projectDir = file("platform/platform-i18n")
  44 +
42 // ─── Packaged Business Capabilities (core PBCs) ───────────────────── 45 // ─── Packaged Business Capabilities (core PBCs) ─────────────────────
43 include(":pbc:pbc-identity") 46 include(":pbc:pbc-identity")
44 project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") 47 project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity")