Commit 3e40cae90164dc1355e280831555a8bc889aea4c
1 parent
0090e3af
feat(pbc): P5.2 — pbc-partners (customers, suppliers, addresses, contacts)
The third real PBC. Validates the modular-monolith template against a
parent-with-children aggregate (Partner → Addresses → Contacts), where
the previous two PBCs only had single-table or two-independent-table
shapes.
What landed
-----------
* New Gradle subproject `pbc/pbc-partners` (12 modules total now).
* Three JPA entities, all extending `AuditedJpaEntity`:
- `Partner` — code, name, type (CUSTOMER/SUPPLIER/BOTH), tax_id,
website, email, phone, active, ext jsonb. Single-table for both
customers and suppliers because the role flag is a property of
the relationship, not the organisation.
- `Address` — partner_id FK, address_type (BILLING/SHIPPING/OTHER),
line1/line2/city/region/postal_code/country_code (ISO 3166-1),
is_primary. Two free address lines + structured city/region/code
is the smallest set that round-trips through every postal system.
- `Contact` — partner_id FK, full_name, role, email, phone, active.
PII-tagged in metadata YAML for the future audit/export tooling.
* Spring Data JPA repos, application services with full CRUD and the
invariants below, REST controllers under
`/api/v1/partners/partners` (+ nested addresses, contacts).
* `partners-init.xml` Liquibase changelog with the three tables, FKs,
GIN index on `partner.ext`, indexes on type/active/country.
* New api.v1 facade `org.vibeerp.api.v1.ext.partners` with
`PartnersApi` + `PartnerRef`. Third `ext.<pbc>` after identity and
catalog. Inactive partners hidden at the facade boundary.
* `PartnersApiAdapter` runtime implementation in pbc-partners, never
leaking JPA entity types.
* `partners.yml` metadata declaring all 3 entities, 12 permission
keys, 1 menu entry. Picked up automatically by `MetadataLoader`.
* 15 new unit tests across `PartnerServiceTest`, `AddressServiceTest`
and `ContactServiceTest` (mockk-based, mirroring catalog tests).
Invariants enforced in code (not blindly delegated to the DB)
-------------------------------------------------------------
* Partner code uniqueness — explicit check produces a 400 with a real
message instead of a 500 from the unique-index violation.
* Partner code is NOT updatable — every external reference uses code,
so renaming is a data-migration concern, not an API call.
* Partner deactivate cascades to contacts (also flipped to inactive).
Addresses are NOT touched (no `active` column — they exist or they
don't). Verified end-to-end against Postgres.
* "Primary" flag is at most one per (partner, address_type). When a
new/updated address is marked primary, all OTHER primaries of the
same type for the same partner are demoted in the same transaction.
* Addresses and contacts reject operations on unknown partners
up-front to give better errors than the FK-violation.
End-to-end smoke test
---------------------
Reset Postgres, booted the app, hit:
* POST /api/v1/auth/login (admin) → JWT
* POST /api/v1/partners/partners (CUSTOMER, SUPPLIER) → 201
* GET /api/v1/partners/partners → lists both
* GET /api/v1/partners/partners/by-code/CUST-ACME → resolves
* POST /api/v1/partners/partners (dup code) → 400 with real message
* POST .../{id}/addresses (BILLING, primary) → 201
* POST .../{id}/contacts → 201
* DELETE /api/v1/partners/partners/{id} → 204; partner active=false
* GET .../contacts → contact ALSO active=false (cascade verified)
* GET /api/v1/_meta/metadata/entities → 3 partners entities present
* GET /api/v1/_meta/metadata/permissions → 12 partners permissions
* Regression: catalog UoMs/items, identity users, printing-shop
plug-in plates all still HTTP 200.
Build
-----
* `./gradlew build`: 12 subprojects, 107 unit tests, all green
(was 11 / 92 before this commit).
* The architectural rule still enforced: pbc-partners depends on
api-v1 + platform-persistence + platform-security only — no
cross-PBC dep, no platform-bootstrap dep.
What was deferred
-----------------
* Permission enforcement on contact endpoints (P4.3). Currently plain
authenticated; the metadata declares the planned `partners.contact.*`
keys for when @RequirePermission lands.
* Per-country address structure layered on top via metadata forms
(P3.x). The current schema is the smallest universal subset.
* `deletePartnerCompletely` — out of scope for v1; should be a
separate "data scrub" admin tool, not a routine API call.
Showing
26 changed files
with
1904 additions
and
22 deletions
CLAUDE.md
| ... | ... | @@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only |
| 95 | 95 | |
| 96 | 96 | **Foundation complete; first business surface in place.** As of the latest commit: |
| 97 | 97 | |
| 98 | -- **11 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 | -- **92 unit tests across 11 modules**, all green. `./gradlew build` is the canonical full build. | |
| 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 | 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. |
| 101 | -- **2 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`) — they validate the recipe every future PBC clones. | |
| 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 | 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 | 103 | - **Package root** is `org.vibeerp`. |
| 104 | 104 | - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot. | ... | ... |
PROGRESS.md
| ... | ... | @@ -10,27 +10,27 @@ |
| 10 | 10 | |
| 11 | 11 | | | | |
| 12 | 12 | |---|---| |
| 13 | -| **Latest version** | v0.6 (post-P1.5) | | |
| 14 | -| **Latest commit** | `1ead32d feat(metadata): P1.5 — metadata store seeding` | | |
| 13 | +| **Latest version** | v0.7 (post-P5.2) | | |
| 14 | +| **Latest commit** | `feat(pbc): P5.2 — pbc-partners (customers, suppliers, addresses, contacts)` | | |
| 15 | 15 | | **Repo** | https://github.com/reporkey/vibe-erp | |
| 16 | -| **Modules** | 11 | | |
| 17 | -| **Unit tests** | 92, all green | | |
| 18 | -| **End-to-end smoke runs** | All cross-cutting services verified against real Postgres | | |
| 19 | -| **Real PBCs implemented** | 2 of 10 (`pbc-identity`, `pbc-catalog`) | | |
| 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 | | |
| 19 | +| **Real PBCs implemented** | 3 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`) | | |
| 20 | 20 | | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata) | |
| 21 | 21 | | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | |
| 22 | 22 | |
| 23 | 23 | ## Current stage |
| 24 | 24 | |
| 25 | -**Foundation complete; first business surface in place.** All seven cross-cutting platform services that PBCs and plug-ins depend on are now live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader). Two real PBCs validate the modular-monolith template. The reference printing-shop plug-in has graduated from "hello world" to a real customer demonstration: 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.** 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. | |
| 26 | 26 | |
| 27 | -The next phase is **building business surface area**: more PBCs (partners, 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 | |
| 29 | 29 | ## Total scope (the v1.0 cut line) |
| 30 | 30 | |
| 31 | 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 **15 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 **16 are done** as of today. Below is the full list with status. | |
| 34 | 34 | |
| 35 | 35 | ### Phase 1 — Platform completion (foundation) |
| 36 | 36 | |
| ... | ... | @@ -79,7 +79,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **15 a |
| 79 | 79 | | # | Unit | Status | |
| 80 | 80 | |---|---|---| |
| 81 | 81 | | P5.1 | `pbc-catalog` — items, units of measure | ✅ DONE — `69f3daa` | |
| 82 | -| P5.2 | `pbc-partners` — customers, suppliers, contacts | ⏳ Next priority candidate | | |
| 82 | +| P5.2 | `pbc-partners` — customers, suppliers, contacts, addresses | ✅ DONE — `<this commit>` | | |
| 83 | 83 | | P5.3 | `pbc-inventory` — stock items, lots, locations, movements | 🔜 Pending | |
| 84 | 84 | | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending | |
| 85 | 85 | | P5.5 | `pbc-orders-sales` — quotes, sales orders, deliveries | 🔜 Pending | |
| ... | ... | @@ -126,7 +126,7 @@ These are the cross-cutting platform services already wired into the running fra |
| 126 | 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 | 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 | 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 | -| **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog` | Two real PBCs prove the recipe: 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`. | | |
| 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 | 130 | |
| 131 | 131 | ## What the reference plug-in proves end-to-end |
| 132 | 132 | |
| ... | ... | @@ -170,7 +170,7 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework, |
| 170 | 170 | - **Job scheduler.** No Quartz. Periodic jobs don't have a home. |
| 171 | 171 | - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2. |
| 172 | 172 | - **Permission checks.** `Principal` is bound; `metadata__permission` is seeded; the actual `@RequirePermission` enforcement layer is P4.3. |
| 173 | -- **More PBCs.** Only identity and catalog exist. Partners, inventory, warehousing, orders, production, quality, finance are all pending. | |
| 173 | +- **More PBCs.** Identity, catalog and partners exist. Inventory, warehousing, orders, production, quality, finance are all pending. | |
| 174 | 174 | - **Web SPA.** No React app. The framework is API-only today. |
| 175 | 175 | - **MCP server.** The architecture leaves room for it; the implementation is v1.1. |
| 176 | 176 | - **Mobile.** v2. |
| ... | ... | @@ -212,6 +212,7 @@ platform/platform-plugins PF4J host, linter, Liquibase runner, endpoint disp |
| 212 | 212 | |
| 213 | 213 | pbc/pbc-identity User entity end-to-end + auth + bootstrap admin |
| 214 | 214 | pbc/pbc-catalog Item + Uom entities + cross-PBC CatalogApi facade |
| 215 | +pbc/pbc-partners Partner + Address + Contact entities + cross-PBC PartnersApi facade | |
| 215 | 216 | |
| 216 | 217 | reference-customer/plugin-printing-shop |
| 217 | 218 | Reference plug-in: own DB schema (plate, ink_recipe), |
| ... | ... | @@ -220,7 +221,7 @@ reference-customer/plugin-printing-shop |
| 220 | 221 | distribution Bootable Spring Boot fat-jar assembly |
| 221 | 222 | ``` |
| 222 | 223 | |
| 223 | -11 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. | |
| 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. | |
| 224 | 225 | |
| 225 | 226 | ## Where to look next |
| 226 | 227 | ... | ... |
README.md
| ... | ... | @@ -77,7 +77,7 @@ vibe-erp/ |
| 77 | 77 | ## Building |
| 78 | 78 | |
| 79 | 79 | ```bash |
| 80 | -# Build everything (compiles 11 modules, runs 92 unit tests) | |
| 80 | +# Build everything (compiles 12 modules, runs 107 unit tests) | |
| 81 | 81 | ./gradlew build |
| 82 | 82 | |
| 83 | 83 | # Bring up Postgres + the reference plug-in JAR |
| ... | ... | @@ -88,17 +88,17 @@ docker compose up -d db |
| 88 | 88 | ./gradlew :distribution:bootRun |
| 89 | 89 | ``` |
| 90 | 90 | |
| 91 | -The bootstrap admin password is printed to the application logs on first boot. After login, REST endpoints are live under `/api/v1/identity/users`, `/api/v1/catalog/uoms`, `/api/v1/catalog/items`, `/api/v1/plugins/printing-shop/*`, and the public introspection endpoint at `/api/v1/_meta/metadata`. | |
| 91 | +The bootstrap admin password is printed to the application logs on first boot. After login, REST endpoints are live under `/api/v1/identity/users`, `/api/v1/catalog/uoms`, `/api/v1/catalog/items`, `/api/v1/partners/partners`, `/api/v1/plugins/printing-shop/*`, and the public introspection endpoint at `/api/v1/_meta/metadata`. | |
| 92 | 92 | |
| 93 | 93 | ## Status |
| 94 | 94 | |
| 95 | -**Current stage: foundation complete; first business surface in place.** 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. Two real PBCs (identity + catalog) validate the modular-monolith template. The reference printing-shop plug-in has graduated from "hello world" to a real customer demonstration: 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.** 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. | |
| 96 | 96 | |
| 97 | 97 | | | | |
| 98 | 98 | |---|---| |
| 99 | -| Modules | 11 | | |
| 100 | -| Unit tests | 92, all green | | |
| 101 | -| Real PBCs | 2 of 10 | | |
| 99 | +| Modules | 12 | | |
| 100 | +| Unit tests | 107, all green | | |
| 101 | +| Real PBCs | 3 of 10 | | |
| 102 | 102 | | Cross-cutting services live | 7 | |
| 103 | 103 | | Plug-ins serving HTTP | 1 (reference printing-shop) | |
| 104 | 104 | | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | | ... | ... |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/partners/PartnersApi.kt
0 → 100644
| 1 | +package org.vibeerp.api.v1.ext.partners | |
| 2 | + | |
| 3 | +import org.vibeerp.api.v1.core.Id | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Cross-PBC facade for the partners bounded context. | |
| 7 | + * | |
| 8 | + * The third `api.v1.ext.*` package after `ext.identity` and `ext.catalog`. | |
| 9 | + * Together they establish the pattern the rest of the framework will | |
| 10 | + * follow: every PBC that other code needs to refer to gets an | |
| 11 | + * `ext.<pbc>` facade here in api.v1, and its runtime implementation | |
| 12 | + * lives next to the PBC's domain code. Guardrail #9 ("PBC boundaries | |
| 13 | + * are sacred") is enforced by the Gradle build — `pbc-orders-sales` | |
| 14 | + * cannot declare `pbc-partners` as a dependency. The only way | |
| 15 | + * `pbc-orders-sales` can ask "is this customer code real" or "what | |
| 16 | + * country does this supplier ship to" is by injecting [PartnersApi] — | |
| 17 | + * an interface declared here, in api.v1, and implemented by | |
| 18 | + * `pbc-partners` at runtime. | |
| 19 | + * | |
| 20 | + * **Lookups are by code**, not by id. Codes are stable, human-readable, | |
| 21 | + * and the natural key plug-ins use to refer to partners from their own | |
| 22 | + * metadata files and from sales/purchase order templates. Adding | |
| 23 | + * id-based lookups later is non-breaking; removing the code-based ones | |
| 24 | + * would be. | |
| 25 | + * | |
| 26 | + * **Inactive partners are hidden at the facade boundary.** If a caller | |
| 27 | + * resolves `findPartnerByCode("CUST-0042")` and that partner is marked | |
| 28 | + * inactive, the facade returns `null`. Orders that were placed BEFORE | |
| 29 | + * the partner was deactivated still resolve the id — the facade cannot | |
| 30 | + * retroactively hide historical data — but new work refuses to | |
| 31 | + * reference an inactive partner. This matches the [CatalogApi] pattern | |
| 32 | + * for inactive items. | |
| 33 | + * | |
| 34 | + * **What this facade does NOT expose:** | |
| 35 | + * • Contacts (those are PII; fetching them crosses the | |
| 36 | + * `partners.contact.read` permission boundary, which the facade | |
| 37 | + * has no way to check. Callers that need a contact must use the | |
| 38 | + * REST endpoint with the user's token.) | |
| 39 | + * • Addresses (orders should SNAPSHOT address fields at order time | |
| 40 | + * rather than reference them by id — see the rationale on Address | |
| 41 | + * in pbc-partners). | |
| 42 | + * • JSONB custom fields on Partner (the api.v1 contract is typed; | |
| 43 | + * callers that want custom fields must read them through the | |
| 44 | + * metadata API with schema awareness). | |
| 45 | + * | |
| 46 | + * If a future caller needs a field that isn't on [PartnerRef], growing | |
| 47 | + * the DTO is an api.v1 change with a major-version cost. Not an | |
| 48 | + * accident. | |
| 49 | + */ | |
| 50 | +interface PartnersApi { | |
| 51 | + | |
| 52 | + /** | |
| 53 | + * Look up a partner by its unique partner code (e.g. "CUST-0042", | |
| 54 | + * "SUP-PAPER-MERIDIAN"). Returns `null` when no such partner exists | |
| 55 | + * or when the partner is inactive — the framework treats inactive | |
| 56 | + * partners as "do not use" for downstream callers. | |
| 57 | + */ | |
| 58 | + fun findPartnerByCode(code: String): PartnerRef? | |
| 59 | + | |
| 60 | + /** | |
| 61 | + * Look up a partner by its primary-key id. Returns `null` when no | |
| 62 | + * such partner exists or when the partner is inactive. Id-based | |
| 63 | + * lookups exist alongside code-based lookups because historical | |
| 64 | + * rows (sales orders, invoices) store the id — they need to | |
| 65 | + * resolve it even after the code has been kept stable. | |
| 66 | + */ | |
| 67 | + fun findPartnerById(id: Id<PartnerRef>): PartnerRef? | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * Minimal, safe-to-publish view of a partner. | |
| 72 | + * | |
| 73 | + * Notably absent: contacts, addresses, `ext` JSONB, credit limits, | |
| 74 | + * payment terms, anything from future PBC-specific columns. Anything | |
| 75 | + * that does not appear here cannot be observed cross-PBC, and that is | |
| 76 | + * intentional — adding a field is a deliberate api.v1 change with a | |
| 77 | + * major-version cost. | |
| 78 | + * | |
| 79 | + * The `type` field is a free-form string ("CUSTOMER", "SUPPLIER", | |
| 80 | + * "BOTH") rather than a typed enum because plug-ins should not have | |
| 81 | + * to recompile when the framework adds a new partner type later. A | |
| 82 | + * plug-in that only understands the CUSTOMER/SUPPLIER set can still | |
| 83 | + * read "BOTH" as a value it ignores, rather than failing to | |
| 84 | + * deserialise. | |
| 85 | + */ | |
| 86 | +data class PartnerRef( | |
| 87 | + val id: Id<PartnerRef>, | |
| 88 | + val code: String, | |
| 89 | + val name: String, | |
| 90 | + val type: String, // CUSTOMER | SUPPLIER | BOTH — string for plug-in compatibility | |
| 91 | + val taxId: String?, | |
| 92 | + val active: Boolean, | |
| 93 | +) | ... | ... |
distribution/build.gradle.kts
| ... | ... | @@ -27,6 +27,7 @@ dependencies { |
| 27 | 27 | implementation(project(":platform:platform-metadata")) |
| 28 | 28 | implementation(project(":pbc:pbc-identity")) |
| 29 | 29 | implementation(project(":pbc:pbc-catalog")) |
| 30 | + implementation(project(":pbc:pbc-partners")) | |
| 30 | 31 | |
| 31 | 32 | implementation(libs.spring.boot.starter) |
| 32 | 33 | implementation(libs.spring.boot.starter.web) | ... | ... |
distribution/src/main/resources/db/changelog/master.xml
| ... | ... | @@ -15,4 +15,5 @@ |
| 15 | 15 | <include file="classpath:db/changelog/pbc-identity/001-identity-init.xml"/> |
| 16 | 16 | <include file="classpath:db/changelog/pbc-identity/002-identity-credential.xml"/> |
| 17 | 17 | <include file="classpath:db/changelog/pbc-catalog/001-catalog-init.xml"/> |
| 18 | + <include file="classpath:db/changelog/pbc-partners/001-partners-init.xml"/> | |
| 18 | 19 | </databaseChangeLog> | ... | ... |
distribution/src/main/resources/db/changelog/pbc-partners/001-partners-init.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" | |
| 3 | + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
| 4 | + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog | |
| 5 | + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd"> | |
| 6 | + | |
| 7 | + <!-- | |
| 8 | + pbc-partners initial schema. | |
| 9 | + | |
| 10 | + Owns: partners__partner, partners__address, partners__contact. | |
| 11 | + | |
| 12 | + vibe_erp is single-tenant per instance — there are no tenant_id | |
| 13 | + columns and no Row-Level Security policies on these tables. | |
| 14 | + | |
| 15 | + Conventions enforced for every business table in vibe_erp: | |
| 16 | + • UUID primary key | |
| 17 | + • Audit columns: created_at, created_by, updated_at, updated_by | |
| 18 | + • Optimistic-locking version column | |
| 19 | + • ext jsonb NOT NULL DEFAULT '{}' for key-user custom fields | |
| 20 | + (on the aggregate root only — Partner — not on children) | |
| 21 | + • GIN index on ext for fast custom-field queries | |
| 22 | + --> | |
| 23 | + | |
| 24 | + <changeSet id="partners-init-001" author="vibe_erp"> | |
| 25 | + <comment>Create partners__partner table</comment> | |
| 26 | + <sql> | |
| 27 | + CREATE TABLE partners__partner ( | |
| 28 | + id uuid PRIMARY KEY, | |
| 29 | + code varchar(64) NOT NULL, | |
| 30 | + name varchar(256) NOT NULL, | |
| 31 | + type varchar(16) NOT NULL, | |
| 32 | + tax_id varchar(64), | |
| 33 | + website varchar(256), | |
| 34 | + email varchar(256), | |
| 35 | + phone varchar(64), | |
| 36 | + active boolean NOT NULL DEFAULT true, | |
| 37 | + ext jsonb NOT NULL DEFAULT '{}'::jsonb, | |
| 38 | + created_at timestamptz NOT NULL, | |
| 39 | + created_by varchar(128) NOT NULL, | |
| 40 | + updated_at timestamptz NOT NULL, | |
| 41 | + updated_by varchar(128) NOT NULL, | |
| 42 | + version bigint NOT NULL DEFAULT 0 | |
| 43 | + ); | |
| 44 | + CREATE UNIQUE INDEX partners__partner_code_uk ON partners__partner (code); | |
| 45 | + CREATE INDEX partners__partner_type_idx ON partners__partner (type); | |
| 46 | + CREATE INDEX partners__partner_active_idx ON partners__partner (active); | |
| 47 | + CREATE INDEX partners__partner_ext_gin ON partners__partner USING GIN (ext jsonb_path_ops); | |
| 48 | + </sql> | |
| 49 | + <rollback> | |
| 50 | + DROP TABLE partners__partner; | |
| 51 | + </rollback> | |
| 52 | + </changeSet> | |
| 53 | + | |
| 54 | + <changeSet id="partners-init-002" author="vibe_erp"> | |
| 55 | + <comment>Create partners__address table (FK to partners__partner)</comment> | |
| 56 | + <sql> | |
| 57 | + CREATE TABLE partners__address ( | |
| 58 | + id uuid PRIMARY KEY, | |
| 59 | + partner_id uuid NOT NULL REFERENCES partners__partner(id), | |
| 60 | + address_type varchar(16) NOT NULL, | |
| 61 | + line1 varchar(256) NOT NULL, | |
| 62 | + line2 varchar(256), | |
| 63 | + city varchar(128) NOT NULL, | |
| 64 | + region varchar(128), | |
| 65 | + postal_code varchar(32), | |
| 66 | + country_code varchar(2) NOT NULL, | |
| 67 | + is_primary boolean NOT NULL DEFAULT false, | |
| 68 | + created_at timestamptz NOT NULL, | |
| 69 | + created_by varchar(128) NOT NULL, | |
| 70 | + updated_at timestamptz NOT NULL, | |
| 71 | + updated_by varchar(128) NOT NULL, | |
| 72 | + version bigint NOT NULL DEFAULT 0 | |
| 73 | + ); | |
| 74 | + CREATE INDEX partners__address_partner_idx ON partners__address (partner_id); | |
| 75 | + CREATE INDEX partners__address_country_idx ON partners__address (country_code); | |
| 76 | + </sql> | |
| 77 | + <rollback> | |
| 78 | + DROP TABLE partners__address; | |
| 79 | + </rollback> | |
| 80 | + </changeSet> | |
| 81 | + | |
| 82 | + <changeSet id="partners-init-003" author="vibe_erp"> | |
| 83 | + <comment>Create partners__contact table (FK to partners__partner, PII-tagged)</comment> | |
| 84 | + <sql> | |
| 85 | + CREATE TABLE partners__contact ( | |
| 86 | + id uuid PRIMARY KEY, | |
| 87 | + partner_id uuid NOT NULL REFERENCES partners__partner(id), | |
| 88 | + full_name varchar(256) NOT NULL, | |
| 89 | + role varchar(128), | |
| 90 | + email varchar(256), | |
| 91 | + phone varchar(64), | |
| 92 | + active boolean NOT NULL DEFAULT true, | |
| 93 | + created_at timestamptz NOT NULL, | |
| 94 | + created_by varchar(128) NOT NULL, | |
| 95 | + updated_at timestamptz NOT NULL, | |
| 96 | + updated_by varchar(128) NOT NULL, | |
| 97 | + version bigint NOT NULL DEFAULT 0 | |
| 98 | + ); | |
| 99 | + CREATE INDEX partners__contact_partner_idx ON partners__contact (partner_id); | |
| 100 | + CREATE INDEX partners__contact_active_idx ON partners__contact (active); | |
| 101 | + </sql> | |
| 102 | + <rollback> | |
| 103 | + DROP TABLE partners__contact; | |
| 104 | + </rollback> | |
| 105 | + </changeSet> | |
| 106 | + | |
| 107 | +</databaseChangeLog> | ... | ... |
pbc/pbc-partners/build.gradle.kts
0 → 100644
| 1 | +plugins { | |
| 2 | + alias(libs.plugins.kotlin.jvm) | |
| 3 | + alias(libs.plugins.kotlin.spring) | |
| 4 | + alias(libs.plugins.kotlin.jpa) | |
| 5 | + alias(libs.plugins.spring.dependency.management) | |
| 6 | +} | |
| 7 | + | |
| 8 | +description = "vibe_erp pbc-partners — customers, suppliers, contacts, addresses. INTERNAL Packaged Business Capability." | |
| 9 | + | |
| 10 | +java { | |
| 11 | + toolchain { | |
| 12 | + languageVersion.set(JavaLanguageVersion.of(21)) | |
| 13 | + } | |
| 14 | +} | |
| 15 | + | |
| 16 | +kotlin { | |
| 17 | + jvmToolchain(21) | |
| 18 | + compilerOptions { | |
| 19 | + freeCompilerArgs.add("-Xjsr305=strict") | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +allOpen { | |
| 24 | + annotation("jakarta.persistence.Entity") | |
| 25 | + annotation("jakarta.persistence.MappedSuperclass") | |
| 26 | + annotation("jakarta.persistence.Embeddable") | |
| 27 | +} | |
| 28 | + | |
| 29 | +// CRITICAL: pbc-partners may depend on api-v1, platform-persistence and | |
| 30 | +// platform-security but NEVER on platform-bootstrap, NEVER on another | |
| 31 | +// pbc-*. The root build.gradle.kts enforces this; if you add a | |
| 32 | +// `:pbc:pbc-foo` or `:platform:platform-bootstrap` dependency below, | |
| 33 | +// the build will refuse to load with an architectural-violation error. | |
| 34 | +dependencies { | |
| 35 | + api(project(":api:api-v1")) | |
| 36 | + implementation(project(":platform:platform-persistence")) | |
| 37 | + implementation(project(":platform:platform-security")) | |
| 38 | + | |
| 39 | + implementation(libs.kotlin.stdlib) | |
| 40 | + implementation(libs.kotlin.reflect) | |
| 41 | + | |
| 42 | + implementation(libs.spring.boot.starter) | |
| 43 | + implementation(libs.spring.boot.starter.web) | |
| 44 | + implementation(libs.spring.boot.starter.data.jpa) | |
| 45 | + implementation(libs.spring.boot.starter.validation) | |
| 46 | + implementation(libs.jackson.module.kotlin) | |
| 47 | + | |
| 48 | + testImplementation(libs.spring.boot.starter.test) | |
| 49 | + testImplementation(libs.junit.jupiter) | |
| 50 | + testImplementation(libs.assertk) | |
| 51 | + testImplementation(libs.mockk) | |
| 52 | +} | |
| 53 | + | |
| 54 | +tasks.test { | |
| 55 | + useJUnitPlatform() | |
| 56 | +} | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/AddressService.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.application | |
| 2 | + | |
| 3 | +import org.springframework.stereotype.Service | |
| 4 | +import org.springframework.transaction.annotation.Transactional | |
| 5 | +import org.vibeerp.pbc.partners.domain.Address | |
| 6 | +import org.vibeerp.pbc.partners.domain.AddressType | |
| 7 | +import org.vibeerp.pbc.partners.infrastructure.AddressJpaRepository | |
| 8 | +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository | |
| 9 | +import java.util.UUID | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Application service for address CRUD scoped to a parent partner. | |
| 13 | + * | |
| 14 | + * Address operations always carry a `partnerId` because addresses don't | |
| 15 | + * exist outside their owning partner. The service rejects operations on | |
| 16 | + * unknown partners up-front to give a better error than the database | |
| 17 | + * FK violation would. | |
| 18 | + * | |
| 19 | + * **The "primary" flag is at most one per (partner, address type).** | |
| 20 | + * That invariant is enforced here in the service: when an address is | |
| 21 | + * marked primary on create or update, all OTHER addresses of the same | |
| 22 | + * type for the same partner are demoted in the same transaction. The | |
| 23 | + * database has no unique constraint for it because the simple form | |
| 24 | + * (`UNIQUE (partner_id, address_type) WHERE is_primary`) is a partial | |
| 25 | + * index, and partial unique indexes don't compose well with the JPA | |
| 26 | + * change-detection flow that briefly has two rows in the primary state | |
| 27 | + * before the next flush. Doing it in code is simpler and survives the | |
| 28 | + * obvious race in single-instance deployments. | |
| 29 | + */ | |
| 30 | +@Service | |
| 31 | +@Transactional | |
| 32 | +class AddressService( | |
| 33 | + private val addresses: AddressJpaRepository, | |
| 34 | + private val partners: PartnerJpaRepository, | |
| 35 | +) { | |
| 36 | + | |
| 37 | + @Transactional(readOnly = true) | |
| 38 | + fun listFor(partnerId: UUID): List<Address> = addresses.findByPartnerId(partnerId) | |
| 39 | + | |
| 40 | + @Transactional(readOnly = true) | |
| 41 | + fun findById(id: UUID): Address? = addresses.findById(id).orElse(null) | |
| 42 | + | |
| 43 | + fun create(partnerId: UUID, command: CreateAddressCommand): Address { | |
| 44 | + require(partners.existsById(partnerId)) { | |
| 45 | + "partner not found: $partnerId" | |
| 46 | + } | |
| 47 | + val saved = addresses.save( | |
| 48 | + Address( | |
| 49 | + partnerId = partnerId, | |
| 50 | + addressType = command.addressType, | |
| 51 | + line1 = command.line1, | |
| 52 | + line2 = command.line2, | |
| 53 | + city = command.city, | |
| 54 | + region = command.region, | |
| 55 | + postalCode = command.postalCode, | |
| 56 | + countryCode = command.countryCode, | |
| 57 | + isPrimary = command.isPrimary, | |
| 58 | + ), | |
| 59 | + ) | |
| 60 | + if (command.isPrimary) { | |
| 61 | + demoteOtherPrimaries(partnerId, command.addressType, exceptId = saved.id) | |
| 62 | + } | |
| 63 | + return saved | |
| 64 | + } | |
| 65 | + | |
| 66 | + fun update(id: UUID, command: UpdateAddressCommand): Address { | |
| 67 | + val address = addresses.findById(id).orElseThrow { | |
| 68 | + NoSuchElementException("address not found: $id") | |
| 69 | + } | |
| 70 | + command.line1?.let { address.line1 = it } | |
| 71 | + command.line2?.let { address.line2 = it } | |
| 72 | + command.city?.let { address.city = it } | |
| 73 | + command.region?.let { address.region = it } | |
| 74 | + command.postalCode?.let { address.postalCode = it } | |
| 75 | + command.countryCode?.let { address.countryCode = it } | |
| 76 | + command.addressType?.let { address.addressType = it } | |
| 77 | + if (command.isPrimary == true) { | |
| 78 | + address.isPrimary = true | |
| 79 | + demoteOtherPrimaries(address.partnerId, address.addressType, exceptId = address.id) | |
| 80 | + } else if (command.isPrimary == false) { | |
| 81 | + address.isPrimary = false | |
| 82 | + } | |
| 83 | + return address | |
| 84 | + } | |
| 85 | + | |
| 86 | + fun delete(id: UUID) { | |
| 87 | + val address = addresses.findById(id).orElseThrow { | |
| 88 | + NoSuchElementException("address not found: $id") | |
| 89 | + } | |
| 90 | + addresses.delete(address) | |
| 91 | + } | |
| 92 | + | |
| 93 | + private fun demoteOtherPrimaries(partnerId: UUID, addressType: AddressType, exceptId: UUID) { | |
| 94 | + addresses.findByPartnerId(partnerId) | |
| 95 | + .filter { it.id != exceptId && it.addressType == addressType && it.isPrimary } | |
| 96 | + .forEach { it.isPrimary = false } | |
| 97 | + } | |
| 98 | +} | |
| 99 | + | |
| 100 | +data class CreateAddressCommand( | |
| 101 | + val addressType: AddressType, | |
| 102 | + val line1: String, | |
| 103 | + val line2: String? = null, | |
| 104 | + val city: String, | |
| 105 | + val region: String? = null, | |
| 106 | + val postalCode: String? = null, | |
| 107 | + val countryCode: String, | |
| 108 | + val isPrimary: Boolean = false, | |
| 109 | +) | |
| 110 | + | |
| 111 | +data class UpdateAddressCommand( | |
| 112 | + val addressType: AddressType? = null, | |
| 113 | + val line1: String? = null, | |
| 114 | + val line2: String? = null, | |
| 115 | + val city: String? = null, | |
| 116 | + val region: String? = null, | |
| 117 | + val postalCode: String? = null, | |
| 118 | + val countryCode: String? = null, | |
| 119 | + val isPrimary: Boolean? = null, | |
| 120 | +) | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/ContactService.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.application | |
| 2 | + | |
| 3 | +import org.springframework.stereotype.Service | |
| 4 | +import org.springframework.transaction.annotation.Transactional | |
| 5 | +import org.vibeerp.pbc.partners.domain.Contact | |
| 6 | +import org.vibeerp.pbc.partners.infrastructure.ContactJpaRepository | |
| 7 | +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository | |
| 8 | +import java.util.UUID | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Application service for partner contact CRUD. | |
| 12 | + * | |
| 13 | + * Same shape as [AddressService]: contacts are scoped to a parent | |
| 14 | + * partner, the service rejects unknown partners up-front, and | |
| 15 | + * deactivation (not deletion) is the supported way to retire someone. | |
| 16 | + * | |
| 17 | + * **PII boundary.** Contact rows hold personal data. Reads of this | |
| 18 | + * service should eventually be permission-gated (`partners.contact.read`) | |
| 19 | + * and audited at a higher level than ordinary partner reads. Until the | |
| 20 | + * permission system lands (P4.3), the controller is plain authenticated | |
| 21 | + * — that's a known temporary state, NOT the long-term posture. | |
| 22 | + */ | |
| 23 | +@Service | |
| 24 | +@Transactional | |
| 25 | +class ContactService( | |
| 26 | + private val contacts: ContactJpaRepository, | |
| 27 | + private val partners: PartnerJpaRepository, | |
| 28 | +) { | |
| 29 | + | |
| 30 | + @Transactional(readOnly = true) | |
| 31 | + fun listFor(partnerId: UUID): List<Contact> = contacts.findByPartnerId(partnerId) | |
| 32 | + | |
| 33 | + @Transactional(readOnly = true) | |
| 34 | + fun findById(id: UUID): Contact? = contacts.findById(id).orElse(null) | |
| 35 | + | |
| 36 | + fun create(partnerId: UUID, command: CreateContactCommand): Contact { | |
| 37 | + require(partners.existsById(partnerId)) { | |
| 38 | + "partner not found: $partnerId" | |
| 39 | + } | |
| 40 | + return contacts.save( | |
| 41 | + Contact( | |
| 42 | + partnerId = partnerId, | |
| 43 | + fullName = command.fullName, | |
| 44 | + role = command.role, | |
| 45 | + email = command.email, | |
| 46 | + phone = command.phone, | |
| 47 | + active = command.active, | |
| 48 | + ), | |
| 49 | + ) | |
| 50 | + } | |
| 51 | + | |
| 52 | + fun update(id: UUID, command: UpdateContactCommand): Contact { | |
| 53 | + val contact = contacts.findById(id).orElseThrow { | |
| 54 | + NoSuchElementException("contact not found: $id") | |
| 55 | + } | |
| 56 | + command.fullName?.let { contact.fullName = it } | |
| 57 | + command.role?.let { contact.role = it } | |
| 58 | + command.email?.let { contact.email = it } | |
| 59 | + command.phone?.let { contact.phone = it } | |
| 60 | + command.active?.let { contact.active = it } | |
| 61 | + return contact | |
| 62 | + } | |
| 63 | + | |
| 64 | + fun deactivate(id: UUID) { | |
| 65 | + val contact = contacts.findById(id).orElseThrow { | |
| 66 | + NoSuchElementException("contact not found: $id") | |
| 67 | + } | |
| 68 | + contact.active = false | |
| 69 | + } | |
| 70 | +} | |
| 71 | + | |
| 72 | +data class CreateContactCommand( | |
| 73 | + val fullName: String, | |
| 74 | + val role: String? = null, | |
| 75 | + val email: String? = null, | |
| 76 | + val phone: String? = null, | |
| 77 | + val active: Boolean = true, | |
| 78 | +) | |
| 79 | + | |
| 80 | +data class UpdateContactCommand( | |
| 81 | + val fullName: String? = null, | |
| 82 | + val role: String? = null, | |
| 83 | + val email: String? = null, | |
| 84 | + val phone: String? = null, | |
| 85 | + val active: Boolean? = null, | |
| 86 | +) | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/PartnerService.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.application | |
| 2 | + | |
| 3 | +import org.springframework.stereotype.Service | |
| 4 | +import org.springframework.transaction.annotation.Transactional | |
| 5 | +import org.vibeerp.pbc.partners.domain.Partner | |
| 6 | +import org.vibeerp.pbc.partners.domain.PartnerType | |
| 7 | +import org.vibeerp.pbc.partners.infrastructure.AddressJpaRepository | |
| 8 | +import org.vibeerp.pbc.partners.infrastructure.ContactJpaRepository | |
| 9 | +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository | |
| 10 | +import java.util.UUID | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * Application service for partner CRUD. | |
| 14 | + * | |
| 15 | + * Like the catalog services, this lives behind a thin REST controller | |
| 16 | + * and a thin api.v1 facade — the entity itself never leaves this layer. | |
| 17 | + * | |
| 18 | + * **Why `code` is not updatable** after create: every external system | |
| 19 | + * that references a partner uses the code. Allowing rename means every | |
| 20 | + * downstream system (sales orders, purchase orders, invoices, the | |
| 21 | + * customer's accounting export) needs a coordinated update — a degree | |
| 22 | + * of work that doesn't belong in a vanilla update endpoint. If a | |
| 23 | + * customer truly needs to rename a partner code, that's a data | |
| 24 | + * migration ticket, not an API call. | |
| 25 | + * | |
| 26 | + * **Deactivation, not deletion.** Like every business entity in | |
| 27 | + * vibe_erp, partners are deactivated rather than deleted so historical | |
| 28 | + * orders, invoices, and audit rows continue to resolve their | |
| 29 | + * `partner_id` references. The api.v1 facade hides inactive partners | |
| 30 | + * from cross-PBC callers — see `PartnersApiAdapter`. | |
| 31 | + * | |
| 32 | + * **Cascade on deactivate.** When a partner is deactivated, its | |
| 33 | + * contacts and addresses stay in the database (FK references survive) | |
| 34 | + * but the contacts are also marked inactive. Addresses are not flipped | |
| 35 | + * because they have no `active` flag — they're either there or they're | |
| 36 | + * not. Hard-deletion of related rows happens only via the dedicated | |
| 37 | + * `deletePartnerCompletely` operation, which is **not** in v1 and is | |
| 38 | + * deliberately omitted: it's the kind of thing that should require a | |
| 39 | + * separate "data scrub" admin tool, not a routine API call. | |
| 40 | + */ | |
| 41 | +@Service | |
| 42 | +@Transactional | |
| 43 | +class PartnerService( | |
| 44 | + private val partners: PartnerJpaRepository, | |
| 45 | + private val addresses: AddressJpaRepository, | |
| 46 | + private val contacts: ContactJpaRepository, | |
| 47 | +) { | |
| 48 | + | |
| 49 | + @Transactional(readOnly = true) | |
| 50 | + fun list(): List<Partner> = partners.findAll() | |
| 51 | + | |
| 52 | + @Transactional(readOnly = true) | |
| 53 | + fun findById(id: UUID): Partner? = partners.findById(id).orElse(null) | |
| 54 | + | |
| 55 | + @Transactional(readOnly = true) | |
| 56 | + fun findByCode(code: String): Partner? = partners.findByCode(code) | |
| 57 | + | |
| 58 | + fun create(command: CreatePartnerCommand): Partner { | |
| 59 | + require(!partners.existsByCode(command.code)) { | |
| 60 | + "partner code '${command.code}' is already taken" | |
| 61 | + } | |
| 62 | + return partners.save( | |
| 63 | + Partner( | |
| 64 | + code = command.code, | |
| 65 | + name = command.name, | |
| 66 | + type = command.type, | |
| 67 | + taxId = command.taxId, | |
| 68 | + website = command.website, | |
| 69 | + email = command.email, | |
| 70 | + phone = command.phone, | |
| 71 | + active = command.active, | |
| 72 | + ), | |
| 73 | + ) | |
| 74 | + } | |
| 75 | + | |
| 76 | + fun update(id: UUID, command: UpdatePartnerCommand): Partner { | |
| 77 | + val partner = partners.findById(id).orElseThrow { | |
| 78 | + NoSuchElementException("partner not found: $id") | |
| 79 | + } | |
| 80 | + // `code` is deliberately not updatable here — every external | |
| 81 | + // reference uses the code, so renaming is a migration concern. | |
| 82 | + command.name?.let { partner.name = it } | |
| 83 | + command.type?.let { partner.type = it } | |
| 84 | + command.taxId?.let { partner.taxId = it } | |
| 85 | + command.website?.let { partner.website = it } | |
| 86 | + command.email?.let { partner.email = it } | |
| 87 | + command.phone?.let { partner.phone = it } | |
| 88 | + command.active?.let { partner.active = it } | |
| 89 | + return partner | |
| 90 | + } | |
| 91 | + | |
| 92 | + /** | |
| 93 | + * Deactivate the partner and all its contacts. Addresses are left | |
| 94 | + * alone (they have no `active` flag — see [Address]). | |
| 95 | + */ | |
| 96 | + fun deactivate(id: UUID) { | |
| 97 | + val partner = partners.findById(id).orElseThrow { | |
| 98 | + NoSuchElementException("partner not found: $id") | |
| 99 | + } | |
| 100 | + partner.active = false | |
| 101 | + contacts.findByPartnerId(id).forEach { it.active = false } | |
| 102 | + } | |
| 103 | +} | |
| 104 | + | |
| 105 | +data class CreatePartnerCommand( | |
| 106 | + val code: String, | |
| 107 | + val name: String, | |
| 108 | + val type: PartnerType, | |
| 109 | + val taxId: String? = null, | |
| 110 | + val website: String? = null, | |
| 111 | + val email: String? = null, | |
| 112 | + val phone: String? = null, | |
| 113 | + val active: Boolean = true, | |
| 114 | +) | |
| 115 | + | |
| 116 | +data class UpdatePartnerCommand( | |
| 117 | + val name: String? = null, | |
| 118 | + val type: PartnerType? = null, | |
| 119 | + val taxId: String? = null, | |
| 120 | + val website: String? = null, | |
| 121 | + val email: String? = null, | |
| 122 | + val phone: String? = null, | |
| 123 | + val active: Boolean? = null, | |
| 124 | +) | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/domain/Address.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.domain | |
| 2 | + | |
| 3 | +import jakarta.persistence.Column | |
| 4 | +import jakarta.persistence.Entity | |
| 5 | +import jakarta.persistence.EnumType | |
| 6 | +import jakarta.persistence.Enumerated | |
| 7 | +import jakarta.persistence.Table | |
| 8 | +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity | |
| 9 | +import java.util.UUID | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * A postal address attached to a [Partner]. | |
| 13 | + * | |
| 14 | + * Stored in its own table so that one partner can have many addresses | |
| 15 | + * — billing, shipping, head office, branch — and so each can be tagged | |
| 16 | + * by purpose. Sales orders pick a shipping address and a billing | |
| 17 | + * address by id; if there were a single denormalised "address" field on | |
| 18 | + * Partner, every order would either need its own snapshot or every | |
| 19 | + * partner would be limited to one address. Both are wrong. | |
| 20 | + * | |
| 21 | + * **Why country_code is varchar(2) and not a free-form `country` | |
| 22 | + * string:** an ISO 3166-1 alpha-2 code is unambiguous, fits in a single | |
| 23 | + * column, and is the contract every other PBC and every plug-in can | |
| 24 | + * rely on for tax, shipping cost, and currency lookups. The display | |
| 25 | + * name ("United States", "United Kingdom") is a render-time concern, | |
| 26 | + * not a stored field. | |
| 27 | + * | |
| 28 | + * **Why no `street_number` / `street_name` split:** address line | |
| 29 | + * structure varies wildly across countries (Japan has no street names, | |
| 30 | + * the UK puts the number first, France has named buildings). Two free | |
| 31 | + * lines + city + region + postal code + country is the smallest set | |
| 32 | + * that round-trips through every postal system in the world. The form | |
| 33 | + * editor (P3.x) can layer per-country structure on top via metadata. | |
| 34 | + * | |
| 35 | + * No `ext` column on this table — addresses are simple enough that | |
| 36 | + * customers needing extra fields ("delivery instructions", "gate code") | |
| 37 | + * can add them via the `Partner.ext` JSONB instead. Keeping address | |
| 38 | + * minimal lets us index it cleanly and keeps copies-into-sales-orders | |
| 39 | + * cheap. | |
| 40 | + */ | |
| 41 | +@Entity | |
| 42 | +@Table(name = "partners__address") | |
| 43 | +class Address( | |
| 44 | + partnerId: UUID, | |
| 45 | + addressType: AddressType, | |
| 46 | + line1: String, | |
| 47 | + line2: String? = null, | |
| 48 | + city: String, | |
| 49 | + region: String? = null, | |
| 50 | + postalCode: String? = null, | |
| 51 | + countryCode: String, | |
| 52 | + isPrimary: Boolean = false, | |
| 53 | +) : AuditedJpaEntity() { | |
| 54 | + | |
| 55 | + @Column(name = "partner_id", nullable = false) | |
| 56 | + var partnerId: UUID = partnerId | |
| 57 | + | |
| 58 | + @Enumerated(EnumType.STRING) | |
| 59 | + @Column(name = "address_type", nullable = false, length = 16) | |
| 60 | + var addressType: AddressType = addressType | |
| 61 | + | |
| 62 | + @Column(name = "line1", nullable = false, length = 256) | |
| 63 | + var line1: String = line1 | |
| 64 | + | |
| 65 | + @Column(name = "line2", nullable = true, length = 256) | |
| 66 | + var line2: String? = line2 | |
| 67 | + | |
| 68 | + @Column(name = "city", nullable = false, length = 128) | |
| 69 | + var city: String = city | |
| 70 | + | |
| 71 | + @Column(name = "region", nullable = true, length = 128) | |
| 72 | + var region: String? = region | |
| 73 | + | |
| 74 | + @Column(name = "postal_code", nullable = true, length = 32) | |
| 75 | + var postalCode: String? = postalCode | |
| 76 | + | |
| 77 | + @Column(name = "country_code", nullable = false, length = 2) | |
| 78 | + var countryCode: String = countryCode | |
| 79 | + | |
| 80 | + @Column(name = "is_primary", nullable = false) | |
| 81 | + var isPrimary: Boolean = isPrimary | |
| 82 | + | |
| 83 | + override fun toString(): String = | |
| 84 | + "Address(id=$id, partnerId=$partnerId, type=$addressType, city='$city', country='$countryCode')" | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** | |
| 88 | + * The purpose an [Address] serves for its partner. | |
| 89 | + * | |
| 90 | + * - **BILLING** — invoices and statements go here | |
| 91 | + * - **SHIPPING** — physical goods go here | |
| 92 | + * - **OTHER** — any other use (head office, registered seat, etc.) | |
| 93 | + * | |
| 94 | + * Stored as string for the same reason every other vibe_erp enum is: | |
| 95 | + * adding a value later is a non-breaking schema change. | |
| 96 | + */ | |
| 97 | +enum class AddressType { | |
| 98 | + BILLING, | |
| 99 | + SHIPPING, | |
| 100 | + OTHER, | |
| 101 | +} | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/domain/Contact.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.domain | |
| 2 | + | |
| 3 | +import jakarta.persistence.Column | |
| 4 | +import jakarta.persistence.Entity | |
| 5 | +import jakarta.persistence.Table | |
| 6 | +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity | |
| 7 | +import java.util.UUID | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * A named human at a [Partner] organisation. | |
| 11 | + * | |
| 12 | + * Distinct from [org.vibeerp.pbc.identity.domain.User] (which lives in | |
| 13 | + * pbc-identity and represents people who LOG IN to vibe_erp): a contact | |
| 14 | + * is just a person at a customer or supplier you might want to call, | |
| 15 | + * email, or address an invoice to. They typically have no account. | |
| 16 | + * | |
| 17 | + * **Why a separate table from Partner.email/Partner.phone:** the | |
| 18 | + * `Partner` columns hold the organisation-level switchboard. Real life | |
| 19 | + * is "speak to Marie in accounts about invoices, Jack in production | |
| 20 | + * about job changes". The contact list is the per-organisation rolodex. | |
| 21 | + * | |
| 22 | + * **PII tagging.** Contact fields are personal data under GDPR and most | |
| 23 | + * regional privacy laws. The metadata YAML for this table flags it as | |
| 24 | + * `pii: true` so the future audit/export tooling (R) and the consent UI | |
| 25 | + * know which rows to redact. The flag is in metadata, not in code, so | |
| 26 | + * customers can extend it (or relax it) without forking. | |
| 27 | + * | |
| 28 | + * No `ext` column on this table for the same reason as [Address]: | |
| 29 | + * customers wanting extra contact attributes ("birthday", "preferred | |
| 30 | + * language") can add them via Partner.ext or via metadata custom fields | |
| 31 | + * once P3.4 lands. | |
| 32 | + */ | |
| 33 | +@Entity | |
| 34 | +@Table(name = "partners__contact") | |
| 35 | +class Contact( | |
| 36 | + partnerId: UUID, | |
| 37 | + fullName: String, | |
| 38 | + role: String? = null, | |
| 39 | + email: String? = null, | |
| 40 | + phone: String? = null, | |
| 41 | + active: Boolean = true, | |
| 42 | +) : AuditedJpaEntity() { | |
| 43 | + | |
| 44 | + @Column(name = "partner_id", nullable = false) | |
| 45 | + var partnerId: UUID = partnerId | |
| 46 | + | |
| 47 | + @Column(name = "full_name", nullable = false, length = 256) | |
| 48 | + var fullName: String = fullName | |
| 49 | + | |
| 50 | + @Column(name = "role", nullable = true, length = 128) | |
| 51 | + var role: String? = role | |
| 52 | + | |
| 53 | + @Column(name = "email", nullable = true, length = 256) | |
| 54 | + var email: String? = email | |
| 55 | + | |
| 56 | + @Column(name = "phone", nullable = true, length = 64) | |
| 57 | + var phone: String? = phone | |
| 58 | + | |
| 59 | + @Column(name = "active", nullable = false) | |
| 60 | + var active: Boolean = active | |
| 61 | + | |
| 62 | + override fun toString(): String = | |
| 63 | + "Contact(id=$id, partnerId=$partnerId, fullName='$fullName', active=$active)" | |
| 64 | +} | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/domain/Partner.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.domain | |
| 2 | + | |
| 3 | +import jakarta.persistence.Column | |
| 4 | +import jakarta.persistence.Entity | |
| 5 | +import jakarta.persistence.EnumType | |
| 6 | +import jakarta.persistence.Enumerated | |
| 7 | +import jakarta.persistence.Table | |
| 8 | +import org.hibernate.annotations.JdbcTypeCode | |
| 9 | +import org.hibernate.type.SqlTypes | |
| 10 | +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * The canonical Partner entity for the partners PBC. | |
| 14 | + * | |
| 15 | + * A "partner" is any external organisation the running vibe_erp instance | |
| 16 | + * does business with: a customer the company sells to, a supplier the | |
| 17 | + * company buys from, or both at once. The deliberately generic word | |
| 18 | + * "partner" (instead of separate `Customer` and `Supplier` tables) means | |
| 19 | + * one row can play either role over time without a data migration when a | |
| 20 | + * supplier becomes a customer or vice versa, which happens routinely in | |
| 21 | + * the printing industry (a paper merchant who also buys finished proofs). | |
| 22 | + * | |
| 23 | + * **Why a single table with a [PartnerType] discriminator** instead of | |
| 24 | + * `partners__customer` and `partners__supplier`: | |
| 25 | + * - 80%+ of fields (code, name, tax_id, contact methods, addresses) are | |
| 26 | + * identical between customers and suppliers. Splitting the table would | |
| 27 | + * duplicate every column and double every cross-PBC join. | |
| 28 | + * - The role flag (CUSTOMER/SUPPLIER/BOTH) is a property of the | |
| 29 | + * relationship, not the organisation. Modeling it as an enum on the | |
| 30 | + * row keeps the truth on one side. | |
| 31 | + * - Future PBCs (orders-sales, orders-purchase) join against the same | |
| 32 | + * `partners__partner` table; they filter by `type` themselves rather | |
| 33 | + * than picking the right table at query time. | |
| 34 | + * | |
| 35 | + * **Why `code` instead of just the UUID `id`:** | |
| 36 | + * Partner codes (e.g. `CUST-0042`, `SUP-PAPER-MERIDIAN`) are stable, | |
| 37 | + * human-readable, and how every other PBC and every plug-in addresses a | |
| 38 | + * partner. The UUID is the database primary key; the code is the natural | |
| 39 | + * key external systems use. Both are unique; the code is the one humans | |
| 40 | + * type into invoices. | |
| 41 | + * | |
| 42 | + * **Why `ext` JSONB instead of typed columns** for things like | |
| 43 | + * "credit limit", "preferred currency", "delivery instructions": | |
| 44 | + * Most of those vary per customer of vibe_erp. The metadata-driven | |
| 45 | + * custom field machinery (P3.4) writes into `ext`, so a key user can add | |
| 46 | + * a "Tax exempt status" field to Partner without a code change or | |
| 47 | + * migration. Only the fields EVERY printing-shop customer will need go | |
| 48 | + * in typed columns. | |
| 49 | + */ | |
| 50 | +@Entity | |
| 51 | +@Table(name = "partners__partner") | |
| 52 | +class Partner( | |
| 53 | + code: String, | |
| 54 | + name: String, | |
| 55 | + type: PartnerType, | |
| 56 | + taxId: String? = null, | |
| 57 | + website: String? = null, | |
| 58 | + email: String? = null, | |
| 59 | + phone: String? = null, | |
| 60 | + active: Boolean = true, | |
| 61 | +) : AuditedJpaEntity() { | |
| 62 | + | |
| 63 | + @Column(name = "code", nullable = false, length = 64) | |
| 64 | + var code: String = code | |
| 65 | + | |
| 66 | + @Column(name = "name", nullable = false, length = 256) | |
| 67 | + var name: String = name | |
| 68 | + | |
| 69 | + @Enumerated(EnumType.STRING) | |
| 70 | + @Column(name = "type", nullable = false, length = 16) | |
| 71 | + var type: PartnerType = type | |
| 72 | + | |
| 73 | + @Column(name = "tax_id", nullable = true, length = 64) | |
| 74 | + var taxId: String? = taxId | |
| 75 | + | |
| 76 | + @Column(name = "website", nullable = true, length = 256) | |
| 77 | + var website: String? = website | |
| 78 | + | |
| 79 | + @Column(name = "email", nullable = true, length = 256) | |
| 80 | + var email: String? = email | |
| 81 | + | |
| 82 | + @Column(name = "phone", nullable = true, length = 64) | |
| 83 | + var phone: String? = phone | |
| 84 | + | |
| 85 | + @Column(name = "active", nullable = false) | |
| 86 | + var active: Boolean = active | |
| 87 | + | |
| 88 | + @Column(name = "ext", nullable = false, columnDefinition = "jsonb") | |
| 89 | + @JdbcTypeCode(SqlTypes.JSON) | |
| 90 | + var ext: String = "{}" | |
| 91 | + | |
| 92 | + override fun toString(): String = | |
| 93 | + "Partner(id=$id, code='$code', type=$type, active=$active)" | |
| 94 | +} | |
| 95 | + | |
| 96 | +/** | |
| 97 | + * The role a [Partner] plays relative to the company running vibe_erp. | |
| 98 | + * | |
| 99 | + * Stored as a string in the DB so adding values later is non-breaking | |
| 100 | + * for clients reading the column with raw SQL. The enum is small on | |
| 101 | + * purpose: anything more granular ("preferred supplier", "VIP customer") | |
| 102 | + * belongs in metadata custom fields, not in this discriminator. | |
| 103 | + * | |
| 104 | + * - **CUSTOMER** — the company sells to this partner | |
| 105 | + * - **SUPPLIER** — the company buys from this partner | |
| 106 | + * - **BOTH** — the partner appears on both sides; both order PBCs | |
| 107 | + * may reference this row | |
| 108 | + */ | |
| 109 | +enum class PartnerType { | |
| 110 | + CUSTOMER, | |
| 111 | + SUPPLIER, | |
| 112 | + BOTH, | |
| 113 | +} | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/ext/PartnersApiAdapter.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.ext | |
| 2 | + | |
| 3 | +import org.springframework.stereotype.Component | |
| 4 | +import org.springframework.transaction.annotation.Transactional | |
| 5 | +import org.vibeerp.api.v1.core.Id | |
| 6 | +import org.vibeerp.api.v1.ext.partners.PartnerRef | |
| 7 | +import org.vibeerp.api.v1.ext.partners.PartnersApi | |
| 8 | +import org.vibeerp.pbc.partners.domain.Partner | |
| 9 | +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * Concrete [PartnersApi] implementation. The ONLY thing other PBCs and | |
| 13 | + * plug-ins are allowed to inject for "give me a partner reference". | |
| 14 | + * | |
| 15 | + * Why this file exists: see the rationale on [PartnersApi]. The Gradle | |
| 16 | + * build forbids cross-PBC dependencies; this adapter is the runtime | |
| 17 | + * fulfillment of the api.v1 contract that lets the rest of the world | |
| 18 | + * ask partner questions without importing partner types. | |
| 19 | + * | |
| 20 | + * **What this adapter MUST NOT do:** | |
| 21 | + * • leak `org.vibeerp.pbc.partners.domain.Partner` (the JPA entity) | |
| 22 | + * to callers | |
| 23 | + * • return inactive partners (the contract says inactive → null) | |
| 24 | + * • expose contacts, addresses, or the `ext` JSONB | |
| 25 | + * | |
| 26 | + * If a caller needs more fields, the answer is to grow `PartnerRef` | |
| 27 | + * deliberately (an api.v1 change with a major-version cost), not to | |
| 28 | + * hand them the entity. | |
| 29 | + */ | |
| 30 | +@Component | |
| 31 | +@Transactional(readOnly = true) | |
| 32 | +class PartnersApiAdapter( | |
| 33 | + private val partners: PartnerJpaRepository, | |
| 34 | +) : PartnersApi { | |
| 35 | + | |
| 36 | + override fun findPartnerByCode(code: String): PartnerRef? = | |
| 37 | + partners.findByCode(code) | |
| 38 | + ?.takeIf { it.active } | |
| 39 | + ?.toRef() | |
| 40 | + | |
| 41 | + override fun findPartnerById(id: Id<PartnerRef>): PartnerRef? = | |
| 42 | + partners.findById(id.value).orElse(null) | |
| 43 | + ?.takeIf { it.active } | |
| 44 | + ?.toRef() | |
| 45 | + | |
| 46 | + private fun Partner.toRef(): PartnerRef = PartnerRef( | |
| 47 | + id = Id<PartnerRef>(this.id), | |
| 48 | + code = this.code, | |
| 49 | + name = this.name, | |
| 50 | + type = this.type.name, | |
| 51 | + taxId = this.taxId, | |
| 52 | + active = this.active, | |
| 53 | + ) | |
| 54 | +} | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/AddressController.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.http | |
| 2 | + | |
| 3 | +import jakarta.validation.Valid | |
| 4 | +import jakarta.validation.constraints.NotBlank | |
| 5 | +import jakarta.validation.constraints.Size | |
| 6 | +import org.springframework.http.HttpStatus | |
| 7 | +import org.springframework.http.ResponseEntity | |
| 8 | +import org.springframework.web.bind.annotation.DeleteMapping | |
| 9 | +import org.springframework.web.bind.annotation.GetMapping | |
| 10 | +import org.springframework.web.bind.annotation.PatchMapping | |
| 11 | +import org.springframework.web.bind.annotation.PathVariable | |
| 12 | +import org.springframework.web.bind.annotation.PostMapping | |
| 13 | +import org.springframework.web.bind.annotation.RequestBody | |
| 14 | +import org.springframework.web.bind.annotation.RequestMapping | |
| 15 | +import org.springframework.web.bind.annotation.ResponseStatus | |
| 16 | +import org.springframework.web.bind.annotation.RestController | |
| 17 | +import org.vibeerp.pbc.partners.application.AddressService | |
| 18 | +import org.vibeerp.pbc.partners.application.CreateAddressCommand | |
| 19 | +import org.vibeerp.pbc.partners.application.UpdateAddressCommand | |
| 20 | +import org.vibeerp.pbc.partners.domain.Address | |
| 21 | +import org.vibeerp.pbc.partners.domain.AddressType | |
| 22 | +import java.util.UUID | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * REST API for partner addresses. | |
| 26 | + * | |
| 27 | + * Mounted at `/api/v1/partners/partners/{partnerId}/addresses` so that | |
| 28 | + * the URL itself encodes the parent-child relationship — every | |
| 29 | + * operation is implicitly scoped to a specific partner. Address ids | |
| 30 | + * remain unique across partners (UUIDs are global) so direct | |
| 31 | + * `GET .../addresses/{id}` lookups are also supported under the same | |
| 32 | + * tree for symmetry. | |
| 33 | + */ | |
| 34 | +@RestController | |
| 35 | +@RequestMapping("/api/v1/partners/partners/{partnerId}/addresses") | |
| 36 | +class AddressController( | |
| 37 | + private val addressService: AddressService, | |
| 38 | +) { | |
| 39 | + | |
| 40 | + @GetMapping | |
| 41 | + fun list(@PathVariable partnerId: UUID): List<AddressResponse> = | |
| 42 | + addressService.listFor(partnerId).map { it.toResponse() } | |
| 43 | + | |
| 44 | + @GetMapping("/{id}") | |
| 45 | + fun get( | |
| 46 | + @PathVariable partnerId: UUID, | |
| 47 | + @PathVariable id: UUID, | |
| 48 | + ): ResponseEntity<AddressResponse> { | |
| 49 | + val address = addressService.findById(id) ?: return ResponseEntity.notFound().build() | |
| 50 | + if (address.partnerId != partnerId) return ResponseEntity.notFound().build() | |
| 51 | + return ResponseEntity.ok(address.toResponse()) | |
| 52 | + } | |
| 53 | + | |
| 54 | + @PostMapping | |
| 55 | + @ResponseStatus(HttpStatus.CREATED) | |
| 56 | + fun create( | |
| 57 | + @PathVariable partnerId: UUID, | |
| 58 | + @RequestBody @Valid request: CreateAddressRequest, | |
| 59 | + ): AddressResponse = | |
| 60 | + addressService.create( | |
| 61 | + partnerId, | |
| 62 | + CreateAddressCommand( | |
| 63 | + addressType = request.addressType, | |
| 64 | + line1 = request.line1, | |
| 65 | + line2 = request.line2, | |
| 66 | + city = request.city, | |
| 67 | + region = request.region, | |
| 68 | + postalCode = request.postalCode, | |
| 69 | + countryCode = request.countryCode, | |
| 70 | + isPrimary = request.isPrimary ?: false, | |
| 71 | + ), | |
| 72 | + ).toResponse() | |
| 73 | + | |
| 74 | + @PatchMapping("/{id}") | |
| 75 | + fun update( | |
| 76 | + @PathVariable partnerId: UUID, | |
| 77 | + @PathVariable id: UUID, | |
| 78 | + @RequestBody @Valid request: UpdateAddressRequest, | |
| 79 | + ): AddressResponse = | |
| 80 | + addressService.update( | |
| 81 | + id, | |
| 82 | + UpdateAddressCommand( | |
| 83 | + addressType = request.addressType, | |
| 84 | + line1 = request.line1, | |
| 85 | + line2 = request.line2, | |
| 86 | + city = request.city, | |
| 87 | + region = request.region, | |
| 88 | + postalCode = request.postalCode, | |
| 89 | + countryCode = request.countryCode, | |
| 90 | + isPrimary = request.isPrimary, | |
| 91 | + ), | |
| 92 | + ).toResponse() | |
| 93 | + | |
| 94 | + @DeleteMapping("/{id}") | |
| 95 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 96 | + fun delete( | |
| 97 | + @PathVariable partnerId: UUID, | |
| 98 | + @PathVariable id: UUID, | |
| 99 | + ) { | |
| 100 | + addressService.delete(id) | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +// ─── DTOs ──────────────────────────────────────────────────────────── | |
| 105 | + | |
| 106 | +data class CreateAddressRequest( | |
| 107 | + val addressType: AddressType, | |
| 108 | + @field:NotBlank @field:Size(max = 256) val line1: String, | |
| 109 | + @field:Size(max = 256) val line2: String? = null, | |
| 110 | + @field:NotBlank @field:Size(max = 128) val city: String, | |
| 111 | + @field:Size(max = 128) val region: String? = null, | |
| 112 | + @field:Size(max = 32) val postalCode: String? = null, | |
| 113 | + @field:NotBlank @field:Size(min = 2, max = 2) val countryCode: String, | |
| 114 | + val isPrimary: Boolean? = false, | |
| 115 | +) | |
| 116 | + | |
| 117 | +data class UpdateAddressRequest( | |
| 118 | + val addressType: AddressType? = null, | |
| 119 | + @field:Size(max = 256) val line1: String? = null, | |
| 120 | + @field:Size(max = 256) val line2: String? = null, | |
| 121 | + @field:Size(max = 128) val city: String? = null, | |
| 122 | + @field:Size(max = 128) val region: String? = null, | |
| 123 | + @field:Size(max = 32) val postalCode: String? = null, | |
| 124 | + @field:Size(min = 2, max = 2) val countryCode: String? = null, | |
| 125 | + val isPrimary: Boolean? = null, | |
| 126 | +) | |
| 127 | + | |
| 128 | +data class AddressResponse( | |
| 129 | + val id: UUID, | |
| 130 | + val partnerId: UUID, | |
| 131 | + val addressType: AddressType, | |
| 132 | + val line1: String, | |
| 133 | + val line2: String?, | |
| 134 | + val city: String, | |
| 135 | + val region: String?, | |
| 136 | + val postalCode: String?, | |
| 137 | + val countryCode: String, | |
| 138 | + val isPrimary: Boolean, | |
| 139 | +) | |
| 140 | + | |
| 141 | +private fun Address.toResponse() = AddressResponse( | |
| 142 | + id = this.id, | |
| 143 | + partnerId = this.partnerId, | |
| 144 | + addressType = this.addressType, | |
| 145 | + line1 = this.line1, | |
| 146 | + line2 = this.line2, | |
| 147 | + city = this.city, | |
| 148 | + region = this.region, | |
| 149 | + postalCode = this.postalCode, | |
| 150 | + countryCode = this.countryCode, | |
| 151 | + isPrimary = this.isPrimary, | |
| 152 | +) | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/ContactController.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.http | |
| 2 | + | |
| 3 | +import jakarta.validation.Valid | |
| 4 | +import jakarta.validation.constraints.NotBlank | |
| 5 | +import jakarta.validation.constraints.Size | |
| 6 | +import org.springframework.http.HttpStatus | |
| 7 | +import org.springframework.http.ResponseEntity | |
| 8 | +import org.springframework.web.bind.annotation.DeleteMapping | |
| 9 | +import org.springframework.web.bind.annotation.GetMapping | |
| 10 | +import org.springframework.web.bind.annotation.PatchMapping | |
| 11 | +import org.springframework.web.bind.annotation.PathVariable | |
| 12 | +import org.springframework.web.bind.annotation.PostMapping | |
| 13 | +import org.springframework.web.bind.annotation.RequestBody | |
| 14 | +import org.springframework.web.bind.annotation.RequestMapping | |
| 15 | +import org.springframework.web.bind.annotation.ResponseStatus | |
| 16 | +import org.springframework.web.bind.annotation.RestController | |
| 17 | +import org.vibeerp.pbc.partners.application.ContactService | |
| 18 | +import org.vibeerp.pbc.partners.application.CreateContactCommand | |
| 19 | +import org.vibeerp.pbc.partners.application.UpdateContactCommand | |
| 20 | +import org.vibeerp.pbc.partners.domain.Contact | |
| 21 | +import java.util.UUID | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * REST API for partner contact people. | |
| 25 | + * | |
| 26 | + * Mounted at `/api/v1/partners/partners/{partnerId}/contacts`. Like | |
| 27 | + * [AddressController], the URL itself encodes the parent-child | |
| 28 | + * relationship — every operation is implicitly scoped to a specific | |
| 29 | + * partner. | |
| 30 | + * | |
| 31 | + * **PII boundary.** Contact data is personal information. Once the | |
| 32 | + * permission system lands (P4.3) this controller will be guarded by | |
| 33 | + * `partners.contact.read` etc. Until then, plain authentication is the | |
| 34 | + * only gate, and that is a known short-term posture, NOT the long-term | |
| 35 | + * one — see the metadata YAML for the planned permission keys. | |
| 36 | + */ | |
| 37 | +@RestController | |
| 38 | +@RequestMapping("/api/v1/partners/partners/{partnerId}/contacts") | |
| 39 | +class ContactController( | |
| 40 | + private val contactService: ContactService, | |
| 41 | +) { | |
| 42 | + | |
| 43 | + @GetMapping | |
| 44 | + fun list(@PathVariable partnerId: UUID): List<ContactResponse> = | |
| 45 | + contactService.listFor(partnerId).map { it.toResponse() } | |
| 46 | + | |
| 47 | + @GetMapping("/{id}") | |
| 48 | + fun get( | |
| 49 | + @PathVariable partnerId: UUID, | |
| 50 | + @PathVariable id: UUID, | |
| 51 | + ): ResponseEntity<ContactResponse> { | |
| 52 | + val contact = contactService.findById(id) ?: return ResponseEntity.notFound().build() | |
| 53 | + if (contact.partnerId != partnerId) return ResponseEntity.notFound().build() | |
| 54 | + return ResponseEntity.ok(contact.toResponse()) | |
| 55 | + } | |
| 56 | + | |
| 57 | + @PostMapping | |
| 58 | + @ResponseStatus(HttpStatus.CREATED) | |
| 59 | + fun create( | |
| 60 | + @PathVariable partnerId: UUID, | |
| 61 | + @RequestBody @Valid request: CreateContactRequest, | |
| 62 | + ): ContactResponse = | |
| 63 | + contactService.create( | |
| 64 | + partnerId, | |
| 65 | + CreateContactCommand( | |
| 66 | + fullName = request.fullName, | |
| 67 | + role = request.role, | |
| 68 | + email = request.email, | |
| 69 | + phone = request.phone, | |
| 70 | + active = request.active ?: true, | |
| 71 | + ), | |
| 72 | + ).toResponse() | |
| 73 | + | |
| 74 | + @PatchMapping("/{id}") | |
| 75 | + fun update( | |
| 76 | + @PathVariable partnerId: UUID, | |
| 77 | + @PathVariable id: UUID, | |
| 78 | + @RequestBody @Valid request: UpdateContactRequest, | |
| 79 | + ): ContactResponse = | |
| 80 | + contactService.update( | |
| 81 | + id, | |
| 82 | + UpdateContactCommand( | |
| 83 | + fullName = request.fullName, | |
| 84 | + role = request.role, | |
| 85 | + email = request.email, | |
| 86 | + phone = request.phone, | |
| 87 | + active = request.active, | |
| 88 | + ), | |
| 89 | + ).toResponse() | |
| 90 | + | |
| 91 | + @DeleteMapping("/{id}") | |
| 92 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 93 | + fun deactivate( | |
| 94 | + @PathVariable partnerId: UUID, | |
| 95 | + @PathVariable id: UUID, | |
| 96 | + ) { | |
| 97 | + contactService.deactivate(id) | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 101 | +// ─── DTOs ──────────────────────────────────────────────────────────── | |
| 102 | + | |
| 103 | +data class CreateContactRequest( | |
| 104 | + @field:NotBlank @field:Size(max = 256) val fullName: String, | |
| 105 | + @field:Size(max = 128) val role: String? = null, | |
| 106 | + @field:Size(max = 256) val email: String? = null, | |
| 107 | + @field:Size(max = 64) val phone: String? = null, | |
| 108 | + val active: Boolean? = true, | |
| 109 | +) | |
| 110 | + | |
| 111 | +data class UpdateContactRequest( | |
| 112 | + @field:Size(max = 256) val fullName: String? = null, | |
| 113 | + @field:Size(max = 128) val role: String? = null, | |
| 114 | + @field:Size(max = 256) val email: String? = null, | |
| 115 | + @field:Size(max = 64) val phone: String? = null, | |
| 116 | + val active: Boolean? = null, | |
| 117 | +) | |
| 118 | + | |
| 119 | +data class ContactResponse( | |
| 120 | + val id: UUID, | |
| 121 | + val partnerId: UUID, | |
| 122 | + val fullName: String, | |
| 123 | + val role: String?, | |
| 124 | + val email: String?, | |
| 125 | + val phone: String?, | |
| 126 | + val active: Boolean, | |
| 127 | +) | |
| 128 | + | |
| 129 | +private fun Contact.toResponse() = ContactResponse( | |
| 130 | + id = this.id, | |
| 131 | + partnerId = this.partnerId, | |
| 132 | + fullName = this.fullName, | |
| 133 | + role = this.role, | |
| 134 | + email = this.email, | |
| 135 | + phone = this.phone, | |
| 136 | + active = this.active, | |
| 137 | +) | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/PartnerController.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.http | |
| 2 | + | |
| 3 | +import jakarta.validation.Valid | |
| 4 | +import jakarta.validation.constraints.NotBlank | |
| 5 | +import jakarta.validation.constraints.Size | |
| 6 | +import org.springframework.http.HttpStatus | |
| 7 | +import org.springframework.http.ResponseEntity | |
| 8 | +import org.springframework.web.bind.annotation.DeleteMapping | |
| 9 | +import org.springframework.web.bind.annotation.GetMapping | |
| 10 | +import org.springframework.web.bind.annotation.PatchMapping | |
| 11 | +import org.springframework.web.bind.annotation.PathVariable | |
| 12 | +import org.springframework.web.bind.annotation.PostMapping | |
| 13 | +import org.springframework.web.bind.annotation.RequestBody | |
| 14 | +import org.springframework.web.bind.annotation.RequestMapping | |
| 15 | +import org.springframework.web.bind.annotation.ResponseStatus | |
| 16 | +import org.springframework.web.bind.annotation.RestController | |
| 17 | +import org.vibeerp.pbc.partners.application.CreatePartnerCommand | |
| 18 | +import org.vibeerp.pbc.partners.application.PartnerService | |
| 19 | +import org.vibeerp.pbc.partners.application.UpdatePartnerCommand | |
| 20 | +import org.vibeerp.pbc.partners.domain.Partner | |
| 21 | +import org.vibeerp.pbc.partners.domain.PartnerType | |
| 22 | +import java.util.UUID | |
| 23 | + | |
| 24 | +/** | |
| 25 | + * REST API for the partners PBC's Partner aggregate. | |
| 26 | + * | |
| 27 | + * Mounted at `/api/v1/partners/partners`. Authenticated; the framework's | |
| 28 | + * Spring Security filter chain rejects anonymous requests with 401. | |
| 29 | + * | |
| 30 | + * The DTO layer (request/response) is intentionally separate from the | |
| 31 | + * JPA entity. Sales-order DTOs and purchase-order DTOs in future PBCs | |
| 32 | + * will quote a `partnerCode` field that resolves through the api.v1 | |
| 33 | + * facade rather than embedding the entity directly. | |
| 34 | + */ | |
| 35 | +@RestController | |
| 36 | +@RequestMapping("/api/v1/partners/partners") | |
| 37 | +class PartnerController( | |
| 38 | + private val partnerService: PartnerService, | |
| 39 | +) { | |
| 40 | + | |
| 41 | + @GetMapping | |
| 42 | + fun list(): List<PartnerResponse> = | |
| 43 | + partnerService.list().map { it.toResponse() } | |
| 44 | + | |
| 45 | + @GetMapping("/{id}") | |
| 46 | + fun get(@PathVariable id: UUID): ResponseEntity<PartnerResponse> { | |
| 47 | + val partner = partnerService.findById(id) ?: return ResponseEntity.notFound().build() | |
| 48 | + return ResponseEntity.ok(partner.toResponse()) | |
| 49 | + } | |
| 50 | + | |
| 51 | + @GetMapping("/by-code/{code}") | |
| 52 | + fun getByCode(@PathVariable code: String): ResponseEntity<PartnerResponse> { | |
| 53 | + val partner = partnerService.findByCode(code) ?: return ResponseEntity.notFound().build() | |
| 54 | + return ResponseEntity.ok(partner.toResponse()) | |
| 55 | + } | |
| 56 | + | |
| 57 | + @PostMapping | |
| 58 | + @ResponseStatus(HttpStatus.CREATED) | |
| 59 | + fun create(@RequestBody @Valid request: CreatePartnerRequest): PartnerResponse = | |
| 60 | + partnerService.create( | |
| 61 | + CreatePartnerCommand( | |
| 62 | + code = request.code, | |
| 63 | + name = request.name, | |
| 64 | + type = request.type, | |
| 65 | + taxId = request.taxId, | |
| 66 | + website = request.website, | |
| 67 | + email = request.email, | |
| 68 | + phone = request.phone, | |
| 69 | + active = request.active ?: true, | |
| 70 | + ), | |
| 71 | + ).toResponse() | |
| 72 | + | |
| 73 | + @PatchMapping("/{id}") | |
| 74 | + fun update( | |
| 75 | + @PathVariable id: UUID, | |
| 76 | + @RequestBody @Valid request: UpdatePartnerRequest, | |
| 77 | + ): PartnerResponse = | |
| 78 | + partnerService.update( | |
| 79 | + id, | |
| 80 | + UpdatePartnerCommand( | |
| 81 | + name = request.name, | |
| 82 | + type = request.type, | |
| 83 | + taxId = request.taxId, | |
| 84 | + website = request.website, | |
| 85 | + email = request.email, | |
| 86 | + phone = request.phone, | |
| 87 | + active = request.active, | |
| 88 | + ), | |
| 89 | + ).toResponse() | |
| 90 | + | |
| 91 | + @DeleteMapping("/{id}") | |
| 92 | + @ResponseStatus(HttpStatus.NO_CONTENT) | |
| 93 | + fun deactivate(@PathVariable id: UUID) { | |
| 94 | + partnerService.deactivate(id) | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +// ─── DTOs ──────────────────────────────────────────────────────────── | |
| 99 | + | |
| 100 | +data class CreatePartnerRequest( | |
| 101 | + @field:NotBlank @field:Size(max = 64) val code: String, | |
| 102 | + @field:NotBlank @field:Size(max = 256) val name: String, | |
| 103 | + val type: PartnerType, | |
| 104 | + @field:Size(max = 64) val taxId: String? = null, | |
| 105 | + @field:Size(max = 256) val website: String? = null, | |
| 106 | + @field:Size(max = 256) val email: String? = null, | |
| 107 | + @field:Size(max = 64) val phone: String? = null, | |
| 108 | + val active: Boolean? = true, | |
| 109 | +) | |
| 110 | + | |
| 111 | +data class UpdatePartnerRequest( | |
| 112 | + @field:Size(max = 256) val name: String? = null, | |
| 113 | + val type: PartnerType? = null, | |
| 114 | + @field:Size(max = 64) val taxId: String? = null, | |
| 115 | + @field:Size(max = 256) val website: String? = null, | |
| 116 | + @field:Size(max = 256) val email: String? = null, | |
| 117 | + @field:Size(max = 64) val phone: String? = null, | |
| 118 | + val active: Boolean? = null, | |
| 119 | +) | |
| 120 | + | |
| 121 | +data class PartnerResponse( | |
| 122 | + val id: UUID, | |
| 123 | + val code: String, | |
| 124 | + val name: String, | |
| 125 | + val type: PartnerType, | |
| 126 | + val taxId: String?, | |
| 127 | + val website: String?, | |
| 128 | + val email: String?, | |
| 129 | + val phone: String?, | |
| 130 | + val active: Boolean, | |
| 131 | +) | |
| 132 | + | |
| 133 | +private fun Partner.toResponse() = PartnerResponse( | |
| 134 | + id = this.id, | |
| 135 | + code = this.code, | |
| 136 | + name = this.name, | |
| 137 | + type = this.type, | |
| 138 | + taxId = this.taxId, | |
| 139 | + website = this.website, | |
| 140 | + email = this.email, | |
| 141 | + phone = this.phone, | |
| 142 | + active = this.active, | |
| 143 | +) | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/infrastructure/AddressJpaRepository.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.infrastructure | |
| 2 | + | |
| 3 | +import org.springframework.data.jpa.repository.JpaRepository | |
| 4 | +import org.springframework.stereotype.Repository | |
| 5 | +import org.vibeerp.pbc.partners.domain.Address | |
| 6 | +import java.util.UUID | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Spring Data JPA repository for [Address]. | |
| 10 | + * | |
| 11 | + * Lookups are by `partner_id`. Addresses don't have a natural key of | |
| 12 | + * their own — they're identified relative to the partner they belong to. | |
| 13 | + * Sales orders that snapshot an address at order time should COPY the | |
| 14 | + * fields, not store the id, because addresses can change after the | |
| 15 | + * order is placed and the order needs to remember the original. | |
| 16 | + */ | |
| 17 | +@Repository | |
| 18 | +interface AddressJpaRepository : JpaRepository<Address, UUID> { | |
| 19 | + | |
| 20 | + fun findByPartnerId(partnerId: UUID): List<Address> | |
| 21 | + | |
| 22 | + fun deleteByPartnerId(partnerId: UUID) | |
| 23 | +} | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/infrastructure/ContactJpaRepository.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.infrastructure | |
| 2 | + | |
| 3 | +import org.springframework.data.jpa.repository.JpaRepository | |
| 4 | +import org.springframework.stereotype.Repository | |
| 5 | +import org.vibeerp.pbc.partners.domain.Contact | |
| 6 | +import java.util.UUID | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Spring Data JPA repository for [Contact]. | |
| 10 | + * | |
| 11 | + * Lookups are by `partner_id`. Like addresses, contacts have no natural | |
| 12 | + * key of their own — they're identified by the partner they belong to. | |
| 13 | + */ | |
| 14 | +@Repository | |
| 15 | +interface ContactJpaRepository : JpaRepository<Contact, UUID> { | |
| 16 | + | |
| 17 | + fun findByPartnerId(partnerId: UUID): List<Contact> | |
| 18 | + | |
| 19 | + fun deleteByPartnerId(partnerId: UUID) | |
| 20 | +} | ... | ... |
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/infrastructure/PartnerJpaRepository.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.infrastructure | |
| 2 | + | |
| 3 | +import org.springframework.data.jpa.repository.JpaRepository | |
| 4 | +import org.springframework.stereotype.Repository | |
| 5 | +import org.vibeerp.pbc.partners.domain.Partner | |
| 6 | +import java.util.UUID | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Spring Data JPA repository for [Partner]. | |
| 10 | + * | |
| 11 | + * Lookups are predominantly by `code` because partner codes are stable, | |
| 12 | + * human-meaningful, and the natural key every other PBC and every plug-in | |
| 13 | + * uses to reference a partner ("CUST-0042" is more meaningful than a | |
| 14 | + * UUID and survives database re-imports). | |
| 15 | + */ | |
| 16 | +@Repository | |
| 17 | +interface PartnerJpaRepository : JpaRepository<Partner, UUID> { | |
| 18 | + | |
| 19 | + fun findByCode(code: String): Partner? | |
| 20 | + | |
| 21 | + fun existsByCode(code: String): Boolean | |
| 22 | +} | ... | ... |
pbc/pbc-partners/src/main/resources/META-INF/vibe-erp/metadata/partners.yml
0 → 100644
| 1 | +# pbc-partners metadata. | |
| 2 | +# | |
| 3 | +# Loaded at boot by MetadataLoader, tagged source='core'. | |
| 4 | +# This declares what the partners PBC offers to the rest of the | |
| 5 | +# framework and the SPA: which entities exist, which permissions guard | |
| 6 | +# them, which menu entries point at them. | |
| 7 | + | |
| 8 | +entities: | |
| 9 | + - name: Partner | |
| 10 | + pbc: partners | |
| 11 | + table: partners__partner | |
| 12 | + description: An external organisation the company does business with (customer, supplier, or both) | |
| 13 | + | |
| 14 | + - name: Address | |
| 15 | + pbc: partners | |
| 16 | + table: partners__address | |
| 17 | + description: A postal address attached to a partner (billing, shipping, other) | |
| 18 | + | |
| 19 | + - name: Contact | |
| 20 | + pbc: partners | |
| 21 | + table: partners__contact | |
| 22 | + description: A named human at a partner organisation (PII — GDPR-relevant) | |
| 23 | + | |
| 24 | +permissions: | |
| 25 | + - key: partners.partner.read | |
| 26 | + description: Read partner records | |
| 27 | + - key: partners.partner.create | |
| 28 | + description: Create partners | |
| 29 | + - key: partners.partner.update | |
| 30 | + description: Update partners | |
| 31 | + - key: partners.partner.deactivate | |
| 32 | + description: Deactivate partners (preserved for historical references) | |
| 33 | + | |
| 34 | + - key: partners.address.read | |
| 35 | + description: Read partner addresses | |
| 36 | + - key: partners.address.create | |
| 37 | + description: Add an address to a partner | |
| 38 | + - key: partners.address.update | |
| 39 | + description: Update a partner address | |
| 40 | + - key: partners.address.delete | |
| 41 | + description: Remove a partner address | |
| 42 | + | |
| 43 | + - key: partners.contact.read | |
| 44 | + description: Read partner contact people (PII — elevated) | |
| 45 | + - key: partners.contact.create | |
| 46 | + description: Add a contact to a partner | |
| 47 | + - key: partners.contact.update | |
| 48 | + description: Update a partner contact | |
| 49 | + - key: partners.contact.deactivate | |
| 50 | + description: Deactivate a partner contact | |
| 51 | + | |
| 52 | +menus: | |
| 53 | + - path: /partners/partners | |
| 54 | + label: Partners | |
| 55 | + icon: users | |
| 56 | + section: Partners | |
| 57 | + order: 300 | ... | ... |
pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/AddressServiceTest.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.application | |
| 2 | + | |
| 3 | +import assertk.assertFailure | |
| 4 | +import assertk.assertThat | |
| 5 | +import assertk.assertions.hasMessage | |
| 6 | +import assertk.assertions.isEqualTo | |
| 7 | +import assertk.assertions.isFalse | |
| 8 | +import assertk.assertions.isInstanceOf | |
| 9 | +import assertk.assertions.isTrue | |
| 10 | +import io.mockk.Runs | |
| 11 | +import io.mockk.every | |
| 12 | +import io.mockk.just | |
| 13 | +import io.mockk.mockk | |
| 14 | +import io.mockk.slot | |
| 15 | +import org.junit.jupiter.api.BeforeEach | |
| 16 | +import org.junit.jupiter.api.Test | |
| 17 | +import org.vibeerp.pbc.partners.domain.Address | |
| 18 | +import org.vibeerp.pbc.partners.domain.AddressType | |
| 19 | +import org.vibeerp.pbc.partners.infrastructure.AddressJpaRepository | |
| 20 | +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository | |
| 21 | +import java.util.Optional | |
| 22 | +import java.util.UUID | |
| 23 | + | |
| 24 | +class AddressServiceTest { | |
| 25 | + | |
| 26 | + private lateinit var addresses: AddressJpaRepository | |
| 27 | + private lateinit var partners: PartnerJpaRepository | |
| 28 | + private lateinit var service: AddressService | |
| 29 | + | |
| 30 | + @BeforeEach | |
| 31 | + fun setUp() { | |
| 32 | + addresses = mockk() | |
| 33 | + partners = mockk() | |
| 34 | + service = AddressService(addresses, partners) | |
| 35 | + } | |
| 36 | + | |
| 37 | + @Test | |
| 38 | + fun `create rejects unknown partner id`() { | |
| 39 | + val partnerId = UUID.randomUUID() | |
| 40 | + every { partners.existsById(partnerId) } returns false | |
| 41 | + | |
| 42 | + assertFailure { | |
| 43 | + service.create( | |
| 44 | + partnerId, | |
| 45 | + CreateAddressCommand( | |
| 46 | + addressType = AddressType.BILLING, | |
| 47 | + line1 = "1 Example St", | |
| 48 | + city = "Springfield", | |
| 49 | + countryCode = "US", | |
| 50 | + ), | |
| 51 | + ) | |
| 52 | + } | |
| 53 | + .isInstanceOf(IllegalArgumentException::class) | |
| 54 | + .hasMessage("partner not found: $partnerId") | |
| 55 | + } | |
| 56 | + | |
| 57 | + @Test | |
| 58 | + fun `create persists with generated id`() { | |
| 59 | + val partnerId = UUID.randomUUID() | |
| 60 | + val saved = slot<Address>() | |
| 61 | + every { partners.existsById(partnerId) } returns true | |
| 62 | + every { addresses.save(capture(saved)) } answers { saved.captured } | |
| 63 | + | |
| 64 | + val result = service.create( | |
| 65 | + partnerId, | |
| 66 | + CreateAddressCommand( | |
| 67 | + addressType = AddressType.SHIPPING, | |
| 68 | + line1 = "42 Factory Rd", | |
| 69 | + line2 = "Bldg 3", | |
| 70 | + city = "Shenzhen", | |
| 71 | + region = "Guangdong", | |
| 72 | + postalCode = "518000", | |
| 73 | + countryCode = "CN", | |
| 74 | + isPrimary = false, | |
| 75 | + ), | |
| 76 | + ) | |
| 77 | + | |
| 78 | + assertThat(result.partnerId).isEqualTo(partnerId) | |
| 79 | + assertThat(result.addressType).isEqualTo(AddressType.SHIPPING) | |
| 80 | + assertThat(result.city).isEqualTo("Shenzhen") | |
| 81 | + assertThat(result.countryCode).isEqualTo("CN") | |
| 82 | + assertThat(result.isPrimary).isFalse() | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Test | |
| 86 | + fun `create as primary demotes other primaries of the same type`() { | |
| 87 | + val partnerId = UUID.randomUUID() | |
| 88 | + val existingPrimary = Address( | |
| 89 | + partnerId = partnerId, | |
| 90 | + addressType = AddressType.BILLING, | |
| 91 | + line1 = "old primary", | |
| 92 | + city = "Old", | |
| 93 | + countryCode = "US", | |
| 94 | + isPrimary = true, | |
| 95 | + ).also { it.id = UUID.randomUUID() } | |
| 96 | + val otherType = Address( | |
| 97 | + partnerId = partnerId, | |
| 98 | + addressType = AddressType.SHIPPING, | |
| 99 | + line1 = "other type", | |
| 100 | + city = "Other", | |
| 101 | + countryCode = "US", | |
| 102 | + isPrimary = true, | |
| 103 | + ).also { it.id = UUID.randomUUID() } | |
| 104 | + | |
| 105 | + val saved = slot<Address>() | |
| 106 | + every { partners.existsById(partnerId) } returns true | |
| 107 | + every { addresses.save(capture(saved)) } answers { | |
| 108 | + saved.captured.also { it.id = UUID.randomUUID() } | |
| 109 | + } | |
| 110 | + every { addresses.findByPartnerId(partnerId) } returns listOf(existingPrimary, otherType) | |
| 111 | + | |
| 112 | + val result = service.create( | |
| 113 | + partnerId, | |
| 114 | + CreateAddressCommand( | |
| 115 | + addressType = AddressType.BILLING, | |
| 116 | + line1 = "new primary", | |
| 117 | + city = "New", | |
| 118 | + countryCode = "US", | |
| 119 | + isPrimary = true, | |
| 120 | + ), | |
| 121 | + ) | |
| 122 | + | |
| 123 | + assertThat(result.isPrimary).isTrue() | |
| 124 | + assertThat(existingPrimary.isPrimary).isFalse() // demoted | |
| 125 | + assertThat(otherType.isPrimary).isTrue() // different type — untouched | |
| 126 | + } | |
| 127 | + | |
| 128 | + @Test | |
| 129 | + fun `delete throws NoSuchElementException when missing`() { | |
| 130 | + val id = UUID.randomUUID() | |
| 131 | + every { addresses.findById(id) } returns Optional.empty() | |
| 132 | + | |
| 133 | + assertFailure { service.delete(id) } | |
| 134 | + .isInstanceOf(NoSuchElementException::class) | |
| 135 | + } | |
| 136 | + | |
| 137 | + @Test | |
| 138 | + fun `delete removes existing address`() { | |
| 139 | + val id = UUID.randomUUID() | |
| 140 | + val existing = Address( | |
| 141 | + partnerId = UUID.randomUUID(), | |
| 142 | + addressType = AddressType.BILLING, | |
| 143 | + line1 = "x", | |
| 144 | + city = "x", | |
| 145 | + countryCode = "US", | |
| 146 | + ).also { it.id = id } | |
| 147 | + every { addresses.findById(id) } returns Optional.of(existing) | |
| 148 | + every { addresses.delete(existing) } just Runs | |
| 149 | + | |
| 150 | + service.delete(id) | |
| 151 | + } | |
| 152 | +} | ... | ... |
pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/ContactServiceTest.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.application | |
| 2 | + | |
| 3 | +import assertk.assertFailure | |
| 4 | +import assertk.assertThat | |
| 5 | +import assertk.assertions.hasMessage | |
| 6 | +import assertk.assertions.isEqualTo | |
| 7 | +import assertk.assertions.isFalse | |
| 8 | +import assertk.assertions.isInstanceOf | |
| 9 | +import io.mockk.every | |
| 10 | +import io.mockk.mockk | |
| 11 | +import io.mockk.slot | |
| 12 | +import org.junit.jupiter.api.BeforeEach | |
| 13 | +import org.junit.jupiter.api.Test | |
| 14 | +import org.vibeerp.pbc.partners.domain.Contact | |
| 15 | +import org.vibeerp.pbc.partners.infrastructure.ContactJpaRepository | |
| 16 | +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository | |
| 17 | +import java.util.Optional | |
| 18 | +import java.util.UUID | |
| 19 | + | |
| 20 | +class ContactServiceTest { | |
| 21 | + | |
| 22 | + private lateinit var contacts: ContactJpaRepository | |
| 23 | + private lateinit var partners: PartnerJpaRepository | |
| 24 | + private lateinit var service: ContactService | |
| 25 | + | |
| 26 | + @BeforeEach | |
| 27 | + fun setUp() { | |
| 28 | + contacts = mockk() | |
| 29 | + partners = mockk() | |
| 30 | + service = ContactService(contacts, partners) | |
| 31 | + } | |
| 32 | + | |
| 33 | + @Test | |
| 34 | + fun `create rejects unknown partner id`() { | |
| 35 | + val partnerId = UUID.randomUUID() | |
| 36 | + every { partners.existsById(partnerId) } returns false | |
| 37 | + | |
| 38 | + assertFailure { | |
| 39 | + service.create( | |
| 40 | + partnerId, | |
| 41 | + CreateContactCommand(fullName = "Alice"), | |
| 42 | + ) | |
| 43 | + } | |
| 44 | + .isInstanceOf(IllegalArgumentException::class) | |
| 45 | + .hasMessage("partner not found: $partnerId") | |
| 46 | + } | |
| 47 | + | |
| 48 | + @Test | |
| 49 | + fun `create persists on the happy path`() { | |
| 50 | + val partnerId = UUID.randomUUID() | |
| 51 | + val saved = slot<Contact>() | |
| 52 | + every { partners.existsById(partnerId) } returns true | |
| 53 | + every { contacts.save(capture(saved)) } answers { saved.captured } | |
| 54 | + | |
| 55 | + val result = service.create( | |
| 56 | + partnerId, | |
| 57 | + CreateContactCommand( | |
| 58 | + fullName = "Marie Durand", | |
| 59 | + role = "Accounts Payable", | |
| 60 | + email = "marie@acme.example", | |
| 61 | + phone = "+33 1 23 45 67 89", | |
| 62 | + ), | |
| 63 | + ) | |
| 64 | + | |
| 65 | + assertThat(result.partnerId).isEqualTo(partnerId) | |
| 66 | + assertThat(result.fullName).isEqualTo("Marie Durand") | |
| 67 | + assertThat(result.role).isEqualTo("Accounts Payable") | |
| 68 | + assertThat(result.email).isEqualTo("marie@acme.example") | |
| 69 | + } | |
| 70 | + | |
| 71 | + @Test | |
| 72 | + fun `update partial fields without clobbering others`() { | |
| 73 | + val id = UUID.randomUUID() | |
| 74 | + val existing = Contact( | |
| 75 | + partnerId = UUID.randomUUID(), | |
| 76 | + fullName = "Original Name", | |
| 77 | + role = "CFO", | |
| 78 | + email = "a@example.com", | |
| 79 | + phone = "+1 555 0100", | |
| 80 | + ).also { it.id = id } | |
| 81 | + every { contacts.findById(id) } returns Optional.of(existing) | |
| 82 | + | |
| 83 | + val updated = service.update( | |
| 84 | + id, | |
| 85 | + UpdateContactCommand(email = "b@example.com"), | |
| 86 | + ) | |
| 87 | + | |
| 88 | + assertThat(updated.email).isEqualTo("b@example.com") | |
| 89 | + assertThat(updated.fullName).isEqualTo("Original Name") // untouched | |
| 90 | + assertThat(updated.role).isEqualTo("CFO") // untouched | |
| 91 | + assertThat(updated.phone).isEqualTo("+1 555 0100") // untouched | |
| 92 | + } | |
| 93 | + | |
| 94 | + @Test | |
| 95 | + fun `deactivate flips active without deleting`() { | |
| 96 | + val id = UUID.randomUUID() | |
| 97 | + val existing = Contact( | |
| 98 | + partnerId = UUID.randomUUID(), | |
| 99 | + fullName = "x", | |
| 100 | + active = true, | |
| 101 | + ).also { it.id = id } | |
| 102 | + every { contacts.findById(id) } returns Optional.of(existing) | |
| 103 | + | |
| 104 | + service.deactivate(id) | |
| 105 | + | |
| 106 | + assertThat(existing.active).isFalse() | |
| 107 | + } | |
| 108 | + | |
| 109 | + @Test | |
| 110 | + fun `deactivate throws NoSuchElementException when missing`() { | |
| 111 | + val id = UUID.randomUUID() | |
| 112 | + every { contacts.findById(id) } returns Optional.empty() | |
| 113 | + | |
| 114 | + assertFailure { service.deactivate(id) } | |
| 115 | + .isInstanceOf(NoSuchElementException::class) | |
| 116 | + } | |
| 117 | +} | ... | ... |
pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/PartnerServiceTest.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.partners.application | |
| 2 | + | |
| 3 | +import assertk.assertFailure | |
| 4 | +import assertk.assertThat | |
| 5 | +import assertk.assertions.hasMessage | |
| 6 | +import assertk.assertions.isEqualTo | |
| 7 | +import assertk.assertions.isFalse | |
| 8 | +import assertk.assertions.isInstanceOf | |
| 9 | +import io.mockk.every | |
| 10 | +import io.mockk.mockk | |
| 11 | +import io.mockk.slot | |
| 12 | +import io.mockk.verify | |
| 13 | +import org.junit.jupiter.api.BeforeEach | |
| 14 | +import org.junit.jupiter.api.Test | |
| 15 | +import org.vibeerp.pbc.partners.domain.Contact | |
| 16 | +import org.vibeerp.pbc.partners.domain.Partner | |
| 17 | +import org.vibeerp.pbc.partners.domain.PartnerType | |
| 18 | +import org.vibeerp.pbc.partners.infrastructure.AddressJpaRepository | |
| 19 | +import org.vibeerp.pbc.partners.infrastructure.ContactJpaRepository | |
| 20 | +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository | |
| 21 | +import java.util.Optional | |
| 22 | +import java.util.UUID | |
| 23 | + | |
| 24 | +class PartnerServiceTest { | |
| 25 | + | |
| 26 | + private lateinit var partners: PartnerJpaRepository | |
| 27 | + private lateinit var addresses: AddressJpaRepository | |
| 28 | + private lateinit var contacts: ContactJpaRepository | |
| 29 | + private lateinit var service: PartnerService | |
| 30 | + | |
| 31 | + @BeforeEach | |
| 32 | + fun setUp() { | |
| 33 | + partners = mockk() | |
| 34 | + addresses = mockk() | |
| 35 | + contacts = mockk() | |
| 36 | + service = PartnerService(partners, addresses, contacts) | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Test | |
| 40 | + fun `create rejects duplicate code`() { | |
| 41 | + every { partners.existsByCode("CUST-1") } returns true | |
| 42 | + | |
| 43 | + assertFailure { | |
| 44 | + service.create( | |
| 45 | + CreatePartnerCommand( | |
| 46 | + code = "CUST-1", | |
| 47 | + name = "Acme", | |
| 48 | + type = PartnerType.CUSTOMER, | |
| 49 | + ), | |
| 50 | + ) | |
| 51 | + } | |
| 52 | + .isInstanceOf(IllegalArgumentException::class) | |
| 53 | + .hasMessage("partner code 'CUST-1' is already taken") | |
| 54 | + } | |
| 55 | + | |
| 56 | + @Test | |
| 57 | + fun `create persists on the happy path`() { | |
| 58 | + val saved = slot<Partner>() | |
| 59 | + every { partners.existsByCode("CUST-2") } returns false | |
| 60 | + every { partners.save(capture(saved)) } answers { saved.captured } | |
| 61 | + | |
| 62 | + val result = service.create( | |
| 63 | + CreatePartnerCommand( | |
| 64 | + code = "CUST-2", | |
| 65 | + name = "Acme Printing Co.", | |
| 66 | + type = PartnerType.CUSTOMER, | |
| 67 | + taxId = "US-12-3456789", | |
| 68 | + email = "orders@acme.example", | |
| 69 | + ), | |
| 70 | + ) | |
| 71 | + | |
| 72 | + assertThat(result.code).isEqualTo("CUST-2") | |
| 73 | + assertThat(result.name).isEqualTo("Acme Printing Co.") | |
| 74 | + assertThat(result.type).isEqualTo(PartnerType.CUSTOMER) | |
| 75 | + assertThat(result.taxId).isEqualTo("US-12-3456789") | |
| 76 | + assertThat(result.email).isEqualTo("orders@acme.example") | |
| 77 | + assertThat(result.active).isEqualTo(true) | |
| 78 | + verify(exactly = 1) { partners.save(any()) } | |
| 79 | + } | |
| 80 | + | |
| 81 | + @Test | |
| 82 | + fun `update never changes code`() { | |
| 83 | + val id = UUID.randomUUID() | |
| 84 | + val existing = Partner( | |
| 85 | + code = "CUST-orig", | |
| 86 | + name = "Old name", | |
| 87 | + type = PartnerType.CUSTOMER, | |
| 88 | + ).also { it.id = id } | |
| 89 | + every { partners.findById(id) } returns Optional.of(existing) | |
| 90 | + | |
| 91 | + val updated = service.update( | |
| 92 | + id, | |
| 93 | + UpdatePartnerCommand( | |
| 94 | + name = "New name", | |
| 95 | + type = PartnerType.BOTH, | |
| 96 | + email = "new@example.com", | |
| 97 | + ), | |
| 98 | + ) | |
| 99 | + | |
| 100 | + assertThat(updated.name).isEqualTo("New name") | |
| 101 | + assertThat(updated.type).isEqualTo(PartnerType.BOTH) | |
| 102 | + assertThat(updated.email).isEqualTo("new@example.com") | |
| 103 | + assertThat(updated.code).isEqualTo("CUST-orig") // not touched | |
| 104 | + } | |
| 105 | + | |
| 106 | + @Test | |
| 107 | + fun `deactivate flips partner active and cascades to contacts`() { | |
| 108 | + val id = UUID.randomUUID() | |
| 109 | + val existing = Partner( | |
| 110 | + code = "CUST-x", | |
| 111 | + name = "x", | |
| 112 | + type = PartnerType.CUSTOMER, | |
| 113 | + active = true, | |
| 114 | + ).also { it.id = id } | |
| 115 | + val contact1 = Contact(partnerId = id, fullName = "Alice", active = true) | |
| 116 | + val contact2 = Contact(partnerId = id, fullName = "Bob", active = true) | |
| 117 | + every { partners.findById(id) } returns Optional.of(existing) | |
| 118 | + every { contacts.findByPartnerId(id) } returns listOf(contact1, contact2) | |
| 119 | + | |
| 120 | + service.deactivate(id) | |
| 121 | + | |
| 122 | + assertThat(existing.active).isFalse() | |
| 123 | + assertThat(contact1.active).isFalse() | |
| 124 | + assertThat(contact2.active).isFalse() | |
| 125 | + } | |
| 126 | + | |
| 127 | + @Test | |
| 128 | + fun `deactivate throws NoSuchElementException when missing`() { | |
| 129 | + val id = UUID.randomUUID() | |
| 130 | + every { partners.findById(id) } returns Optional.empty() | |
| 131 | + | |
| 132 | + assertFailure { service.deactivate(id) } | |
| 133 | + .isInstanceOf(NoSuchElementException::class) | |
| 134 | + } | |
| 135 | +} | ... | ... |
settings.gradle.kts
| ... | ... | @@ -46,6 +46,9 @@ project(":pbc:pbc-identity").projectDir = file("pbc/pbc-identity") |
| 46 | 46 | include(":pbc:pbc-catalog") |
| 47 | 47 | project(":pbc:pbc-catalog").projectDir = file("pbc/pbc-catalog") |
| 48 | 48 | |
| 49 | +include(":pbc:pbc-partners") | |
| 50 | +project(":pbc:pbc-partners").projectDir = file("pbc/pbc-partners") | |
| 51 | + | |
| 49 | 52 | // ─── Reference customer plug-in (NOT loaded by default) ───────────── |
| 50 | 53 | include(":reference-customer:plugin-printing-shop") |
| 51 | 54 | project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop") | ... | ... |