• Removes the ext-handling copy/paste that had grown across four PBCs
    (partners, inventory, orders-sales, orders-purchase). Every service
    that wrote the JSONB `ext` column was manually doing the same
    four-step sequence: validate, null-check, serialize with a local
    ObjectMapper, assign to the entity. And every response mapper was
    doing the inverse: check-if-blank, parse, cast, swallow errors.
    
    Net: ~15 lines saved per PBC, one place to change the ext contract
    later (e.g. PII redaction, audit tagging, field-level events), and
    a stable plug-in opt-in mechanism — any plug-in entity that
    implements `HasExt` automatically participates.
    
    New api.v1 surface:
    
      interface HasExt {
          val extEntityName: String     // key into metadata__custom_field
          var ext: String               // the serialized JSONB column
      }
    
    Lives in `org.vibeerp.api.v1.entity` so plug-ins can opt their own
    entities into the same validation path. Zero Spring/Jackson
    dependencies — api.v1 stays clean.
    
    Extended `ExtJsonValidator` (platform-metadata) with two helpers:
    
      fun applyTo(entity: HasExt, ext: Map<String, Any?>?)
          — null-safe; validates; writes canonical JSON to entity.ext.
            Replaces the validate + writeValueAsString + assign triplet
            in every service's create() and update().
    
      fun parseExt(entity: HasExt): Map<String, Any?>
          — returns empty map on blank/corrupt column; response
            mappers never 500 on bad data. Replaces the four identical
            parseExt local functions.
    
    ExtJsonValidator now takes an ObjectMapper via constructor
    injection (Spring Boot's auto-configured bean).
    
    Entities that now implement HasExt (override val extEntityName;
    override var ext; companion object const val ENTITY_NAME):
      - Partner (`partners.Partner` → "Partner")
      - Location (`inventory.Location` → "Location")
      - SalesOrder (`orders_sales.SalesOrder` → "SalesOrder")
      - PurchaseOrder (`orders_purchase.PurchaseOrder` → "PurchaseOrder")
    
    Deliberately NOT converted this chunk:
      - WorkOrder (pbc-production) — its ext column has no declared
        fields yet; a follow-up that adds declarations AND the
        HasExt implementation is cleaner than splitting the two.
      - JournalEntry (pbc-finance) — derived state, no ext column.
    
    Services lose:
      - The `jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()`
        field (four copies eliminated)
      - The `parseExt(entity): Map` helper function (four copies)
      - The `companion object { const val ENTITY_NAME = ... }` constant
        (moved onto the entity where it belongs)
      - The `val canonicalExt = extValidator.validate(...)` +
        `.also { it.ext = jsonMapper.writeValueAsString(canonicalExt) }`
        create pattern (replaced with one applyTo call)
      - The `if (command.ext != null) { ... }` update pattern
        (applyTo is null-safe)
    
    Unit tests: 6 new cases on ExtJsonValidatorTest cover applyTo and
    parseExt (null-safe path, happy path, failure path, blank column,
    round-trip, malformed JSON). Existing service tests just swap the
    mock setup from stubbing `validate` to stubbing `applyTo` and
    `parseExt` with no-ops.
    
    Smoke verified end-to-end against real Postgres:
      - POST /partners with valid ext (partners_credit_limit,
        partners_industry) → 201, canonical form persisted.
      - GET /partners/by-code/X → 200, ext round-trips.
      - POST with invalid enum value → 400 "value 'x' is not in
        allowed set [printing, publishing, packaging, other]".
      - POST with undeclared key → 400 "ext contains undeclared
        key(s) for 'Partner': [rogue_field]".
      - PATCH with new ext → 200, ext updated.
      - PATCH WITHOUT ext field → 200, prior ext preserved (null-safe
        applyTo).
      - POST /orders/sales-orders with no ext → 201, the create path
        via the shared helper still works.
    
    246 unit tests (+6 over 240), 18 Gradle subprojects.
    zichun authored
     
    Browse Code »
  • Completes the @RequirePermission rollout that started in commit
    b174cf60. Every non-state-transition endpoint in pbc-inventory
    (Location CRUD), pbc-orders-sales, and pbc-orders-purchase is now
    guarded by the pre-declared permission keys from their respective
    metadata YAMLs. State-transition verbs (confirm/cancel/ship/receive)
    were annotated in the original P4.3 demo chunk; this one fills in
    the list/get/create/update gap.
    
    Inventory
      - LocationController: list/get/getByCode → inventory.location.read;
        create → inventory.location.create;
        update → inventory.location.update;
        deactivate → inventory.location.deactivate.
      - (StockBalanceController.adjust + StockMovementController.record
        were already annotated with inventory.stock.adjust.)
    
    Orders-sales
      - SalesOrderController: list/get/getByCode → orders.sales.read;
        create → orders.sales.create; update → orders.sales.update.
        (confirm/cancel/ship were already annotated.)
    
    Orders-purchase
      - PurchaseOrderController: list/get/getByCode → orders.purchase.read;
        create → orders.purchase.create; update → orders.purchase.update.
        (confirm/cancel/receive were already annotated.)
    
    No new permission keys. Every key this chunk consumes was already
    declared in the relevant metadata YAML since the respective PBC was
    first built — catalog + partners already shipped in this state, and
    the inventory/orders YAMLs declared their read/create/update keys
    from day one but the controllers hadn't started using them.
    
    Admin happy path still works (bootstrap admin has the wildcard
    `admin` role, same as after commit b174cf60). 230 unit tests still
    green — annotations are purely additive, no existing test hits the
    @RequirePermission path since service-level tests bypass the
    controller entirely.
    
    Combined with b174cf60, the framework now has full @RequirePermission
    coverage on every PBC controller except pbc-identity's user admin
    (which is a separate permission surface — user/role administration
    has its own security story). A minimum-privilege role like
    "sales-clerk" can now be granted exactly `orders.sales.read` +
    `orders.sales.create` + `partners.partner.read` and NOT accidentally
    see catalog admin, inventory movements, finance journals, or
    contact PII.
    zichun authored
     
    Browse Code »

  • The event bus and transactional outbox have existed since P1.7 but no
    real PBC business logic was publishing through them. This change closes
    that loop end-to-end:
    
    api.v1.event.orders (new public surface)
      - SalesOrderConfirmedEvent / SalesOrderShippedEvent /
        SalesOrderCancelledEvent — sealed under SalesOrderEvent,
        aggregateType = "orders_sales.SalesOrder"
      - PurchaseOrderConfirmedEvent / PurchaseOrderReceivedEvent /
        PurchaseOrderCancelledEvent — sealed under PurchaseOrderEvent,
        aggregateType = "orders_purchase.PurchaseOrder"
      - Events live in api.v1 (not inside the PBCs) so other PBCs and
        customer plug-ins can subscribe without importing the producing
        PBC — that would violate guardrail #9.
    
    pbc-orders-sales / pbc-orders-purchase
      - SalesOrderService and PurchaseOrderService now inject EventBus
        and publish a typed event from each state-changing method
        (confirm, ship/receive, cancel). The publish runs INSIDE the
        same @Transactional method as the JPA mutation and the
        InventoryApi.recordMovement ledger writes — EventBusImpl uses
        Propagation.MANDATORY, so a publish outside a transaction
        fails loudly. A failure in any line rolls back the status
        change AND every ledger row AND the would-have-been outbox row.
      - 6 new unit tests (3 per service) mockk the EventBus and verify
        each transition publishes exactly one matching event with the
        expected fields. Total tests: 186 → 192.
    
    End-to-end smoke verified against real Postgres
      - Created supplier, customer, item PAPER-A4, location WH-MAIN.
      - Drove a PO and an SO through the full state machine plus a
        cancel of each. 6 events fired:
          orders_purchase.PurchaseOrder × 3 (confirm + receive + cancel)
          orders_sales.SalesOrder       × 3 (confirm + ship + cancel)
      - The wildcard EventAuditLogSubscriber logged each one at INFO
        level to /tmp/vibe-erp-boot.log with the [event-audit] tag.
      - platform__event_outbox shows 6 rows, all flipped from PENDING
        to DISPATCHED by the OutboxPoller within seconds.
      - The publish-inside-the-ledger-transaction guarantee means a
        subscriber that reads inventory__stock_movement on event
        receipt is guaranteed to see the matching SALES_SHIPMENT or
        PURCHASE_RECEIPT rows. This is what the architecture spec
        section 9 promised and now delivers.
    
    Why this is the right shape
      - Other PBCs (production, finance) and customer plug-ins can now
        react to "an order was confirmed/shipped/received/cancelled"
        without ever importing pbc-orders-* internals. The event class
        objects live in api.v1, the only stable contract surface.
      - The aggregateType strings ("orders_sales.SalesOrder",
        "orders_purchase.PurchaseOrder") match the <pbc>.<aggregate>
        convention documented on DomainEvent.aggregateType, so a
        cross-classloader subscriber can use the topic-string subscribe
        overload without holding the concrete Class<E>.
      - The bus's outbox row is the durability anchor for the future
        Kafka/NATS bridge: switching from in-process delivery to
        cross-process delivery will require zero changes to either
        PBC's publish call.
    zichun authored
     
    Browse Code »
  • The killer demo finally works: place a sales order, ship it, watch
    inventory drop. This chunk lands the two pieces that close the loop:
    the inventory movement ledger (the audit-grade history of every
    stock change) and the sales-order /ship endpoint that calls
    InventoryApi.recordMovement to atomically debit stock for every line.
    
    This is the framework's FIRST cross-PBC WRITE flow. Every earlier
    cross-PBC call was a read (CatalogApi.findItemByCode,
    PartnersApi.findPartnerByCode, InventoryApi.findStockBalance).
    Shipping inverts that: pbc-orders-sales synchronously writes to
    inventory's tables (via the api.v1 facade) as a side effect of
    changing its own state, all in ONE Spring transaction.
    
    What landed
    -----------
    * New `inventory__stock_movement` table — append-only ledger
      (id, item_code, location_id FK, signed delta, reason enum,
      reference, occurred_at, audit cols). CHECK constraint
      `delta <> 0` rejects no-op rows. Indexes on item_code,
      location_id, the (item, location) composite, reference, and
      occurred_at. Migration is in its own changelog file
      (002-inventory-movement-ledger.xml) per the project convention
      that each new schema cut is a new file.
    * New `StockMovement` JPA entity + repository + `MovementReason`
      enum (RECEIPT, ISSUE, ADJUSTMENT, SALES_SHIPMENT, PURCHASE_RECEIPT,
      TRANSFER_OUT, TRANSFER_IN). Each value carries a documented sign
      convention; the service rejects mismatches (a SALES_SHIPMENT
      with positive delta is a caller bug, not silently coerced).
    * New `StockMovementService.record(...)` — the ONE entry point for
      changing inventory. Cross-PBC item validation via CatalogApi,
      local location validation, sign-vs-reason enforcement, and
      negative-balance rejection all happen BEFORE the write. The
      ledger row insert AND the balance row update happen in the
      SAME database transaction so the two cannot drift.
    * `StockBalanceService.adjust` refactored to delegate: it computes
      delta = newQty - oldQty and calls record(... ADJUSTMENT). The
      REST endpoint keeps its absolute-quantity semantics — operators
      type "the shelf has 47" not "decrease by 3" — but every
      adjustment now writes a ledger row too. A no-op adjustment
      (re-saving the same value) does NOT write a row, so the audit
      log doesn't fill with noise from operator clicks that didn't
      change anything.
    * New `StockMovementController` at `/api/v1/inventory/movements`:
      GET filters by itemCode, locationId, or reference (for "all
      movements caused by SO-2026-0001"); POST records a manual
      movement. Both protected by `inventory.stock.adjust`.
    * `InventoryApi` facade extended with `recordMovement(itemCode,
      locationCode, delta, reason: String, reference)`. The reason is
      a String in the api.v1 surface (not the local enum) so plug-ins
      don't import inventory's internal types — the closed set is
      documented on the interface. The adapter parses the string with
      a meaningful error on unknown values.
    * New `SHIPPED` status on `SalesOrderStatus`. Transitions:
      DRAFT → CONFIRMED → SHIPPED (terminal). Cancelling a SHIPPED
      order is rejected with "issue a return / refund flow instead".
    * New `SalesOrderService.ship(id, shippingLocationCode)`: walks
      every line, calls `inventoryApi.recordMovement(... -line.quantity
      reason="SALES_SHIPMENT" reference="SO:{order_code}")`, flips
      status to SHIPPED. The whole operation runs in ONE transaction
      so a failure on any line — bad item, bad location, would push
      balance negative — rolls back the order status change AND every
      other line's already-written movement. The customer never ends
      up with "5 of 7 lines shipped, status still CONFIRMED, ledger
      half-written".
    * New `POST /api/v1/orders/sales-orders/{id}/ship` endpoint with
      body `{"shippingLocationCode": "WH-MAIN"}`, gated by the new
      `orders.sales.ship` permission key.
    * `ShipSalesOrderRequest` is a single-arg Kotlin data class — same
      Jackson deserialization trap as `RefreshRequest`. Fixed with
      `@JsonCreator(mode = PROPERTIES) + @param:JsonProperty`. The
      trap is documented in the class KDoc.
    
    End-to-end smoke test (the killer demo)
    ---------------------------------------
    Reset Postgres, booted the app, ran:
    * Login as admin
    * POST /catalog/items → PAPER-A4
    * POST /partners → CUST-ACME
    * POST /inventory/locations → WH-MAIN
    * POST /inventory/balances/adjust → quantity=1000
      (now writes a ledger row via the new path)
    * GET /inventory/movements?itemCode=PAPER-A4 →
      ADJUSTMENT delta=1000 ref=null
    * POST /orders/sales-orders → SO-2026-0001 (50 units of PAPER-A4)
    * POST /sales-orders/{id}/confirm → status CONFIRMED
    * POST /sales-orders/{id}/ship body={"shippingLocationCode":"WH-MAIN"}
      → status SHIPPED
    * GET /inventory/balances?itemCode=PAPER-A4 → quantity=950
      (1000 - 50)
    * GET /inventory/movements?itemCode=PAPER-A4 →
      ADJUSTMENT     delta=1000   ref=null
      SALES_SHIPMENT delta=-50    ref=SO:SO-2026-0001
    
    Failure paths verified:
    * Re-ship a SHIPPED order → 400 "only CONFIRMED orders can be shipped"
    * Cancel a SHIPPED order → 400 "issue a return / refund flow instead"
    * Place a 10000-unit order, confirm, try to ship from a 950-stock
      warehouse → 400 "stock movement would push balance for 'PAPER-A4'
      at location ... below zero (current=950.0000, delta=-10000.0000)";
      balance unchanged after the rollback (transaction integrity
      verified)
    
    Regression: catalog uoms, identity users, inventory locations,
    printing-shop plates with i18n, metadata entities — all still
    HTTP 2xx.
    
    Build
    -----
    * `./gradlew build`: 15 subprojects, 175 unit tests (was 163),
      all green. The 12 new tests cover:
      - StockMovementServiceTest (8): zero-delta rejection, positive
        SALES_SHIPMENT rejection, negative RECEIPT rejection, both
        signs allowed on ADJUSTMENT, unknown item via CatalogApi seam,
        unknown location, would-push-balance-negative rejection,
        new-row + existing-row balance update.
      - StockBalanceServiceTest, rewritten (5): negative-quantity
        early reject, delegation with computed positive delta,
        delegation with computed negative delta, no-op adjustment
        short-circuit (NO ledger row written), no-op on missing row
        creates an empty row at zero.
      - SalesOrderServiceTest, additions (3): ship rejects non-CONFIRMED,
        ship walks lines and calls recordMovement with negated quantity
        + correct reference, cancel rejects SHIPPED.
    
    What was deferred
    -----------------
    * **Event publication.** A `StockMovementRecorded` event would
      let pbc-finance and pbc-production react to ledger writes
      without polling. The event bus has been wired since P1.7 but
      no real cross-PBC flow uses it yet — that's the natural next
      chunk and the chunk after this commit.
    * **Multi-leg transfers.** TRANSFER_OUT and TRANSFER_IN are in
      the enum but no service operation atomically writes both legs
      yet (both legs in one transaction is required to keep total
      on-hand invariant).
    * **Reservation / pick lists.** "Reserve 50 of PAPER-A4 for an
      unconfirmed order" is its own concept that lands later.
    * **Shipped-order returns / refunds.** The cancel-SHIPPED rule
      points the user at "use a return flow" — that flow doesn't
      exist yet. v1 says shipments are terminal.
    zichun authored
     
    Browse Code »
  • The framework's authorization layer is now live. Until now, every
    authenticated user could do everything; the framework had only an
    authentication gate. This chunk adds method-level @RequirePermission
    annotations enforced by a Spring AOP aspect that consults the JWT's
    roles claim and a metadata-driven role-permission map.
    
    What landed
    -----------
    * New `Role` and `UserRole` JPA entities mapping the existing
      identity__role + identity__user_role tables (the schema was
      created in the original identity init but never wired to JPA).
      RoleJpaRepository + UserRoleJpaRepository with a JPQL query that
      returns a user's role codes in one round-trip.
    * `JwtIssuer.issueAccessToken(userId, username, roles)` now accepts a
      Set<String> of role codes and encodes them as a `roles` JWT claim
      (sorted for deterministic tests). Refresh tokens NEVER carry roles
      by design — see the rationale on `JwtIssuer.issueRefreshToken`. A
      role revocation propagates within one access-token lifetime
      (15 min default).
    * `JwtVerifier` reads the `roles` claim into `DecodedToken.roles`.
      Missing claim → empty set, NOT an error (refresh tokens, system
      tokens, and pre-P4.3 tokens all legitimately omit it).
    * `AuthService.login` now calls `userRoles.findRoleCodesByUserId(...)`
      before minting the access token. `AuthService.refresh` re-reads
      the user's roles too — so a refresh always picks up the latest
      set, since refresh tokens deliberately don't carry roles.
    * New `AuthorizationContext` ThreadLocal in `platform-security.authz`
      carrying an `AuthorizedPrincipal(id, username, roles)`. Separate
      from `PrincipalContext` (which lives in platform-persistence and
      carries only the principal id, for the audit listener). The two
      contexts coexist because the audit listener has no business
      knowing what roles a user has.
    * `PrincipalContextFilter` now populates BOTH contexts on every
      authenticated request, reading the JWT's `username` and `roles`
      claims via `Jwt.getClaimAsStringList("roles")`. The filter is the
      one and only place that knows about Spring Security types AND
      about both vibe_erp contexts; everything downstream uses just the
      Spring-free abstractions.
    * `PermissionEvaluator` Spring bean: takes a role set + permission
      key, returns boolean. Resolution chain:
      1. The literal `admin` role short-circuits to `true` for every
         key (the wildcard exists so the bootstrap admin can do
         everything from the very first boot without seeding a complete
         role-permission mapping).
      2. Otherwise consults an in-memory `Map<role, Set<permission>>`
         loaded from `metadata__role_permission` rows. The cache is
         rebuilt by `refresh()`, called from `VibeErpPluginManager`
         after the initial core load AND after every plug-in load.
      3. Empty role set is always denied. No implicit grants.
    * `@RequirePermission("...")` annotation in `platform-security.authz`.
      `RequirePermissionAspect` is a Spring AOP @Aspect with @Around
      advice that intercepts every annotated method, reads the current
      request's `AuthorizationContext`, calls
      `PermissionEvaluator.has(...)`, and either proceeds or throws
      `PermissionDeniedException`.
    * New `PermissionDeniedException` carrying the offending key.
      `GlobalExceptionHandler` maps it to HTTP 403 Forbidden with
      `"permission denied: 'partners.partner.deactivate'"` as the
      detail. The key IS surfaced to the caller (unlike the 401's
      generic "invalid credentials") because the SPA needs it to
      render a useful "your role doesn't include X" message and
      callers are already authenticated, so it's not an enumeration
      vector.
    * `BootstrapAdminInitializer` now creates the wildcard `admin`
      role on first boot and grants it to the bootstrap admin user.
    * `@RequirePermission` applied to four sensitive endpoints as the
      demo: `PartnerController.deactivate`,
      `StockBalanceController.adjust`, `SalesOrderController.confirm`,
      `SalesOrderController.cancel`. More endpoints will gain
      annotations as additional roles are introduced; v1 keeps the
      blast radius narrow.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, verified:
    * Admin login → JWT length 265 (was 241), decoded claims include
      `"roles":["admin"]`
    * Admin POST /sales-orders/{id}/confirm → 200, status DRAFT → CONFIRMED
      (admin wildcard short-circuits the permission check)
    * Inserted a 'powerless' user via raw SQL with no role assignments
      but copied the admin's password hash so login works
    * Powerless login → JWT length 247, decoded claims have NO roles
      field at all
    * Powerless POST /sales-orders/{id}/cancel → **403 Forbidden** with
      `"permission denied: 'orders.sales.cancel'"` in the body
    * Powerless DELETE /partners/{id} → **403 Forbidden** with
      `"permission denied: 'partners.partner.deactivate'"`
    * Powerless GET /sales-orders, /partners, /catalog/items → all 200
      (read endpoints have no @RequirePermission)
    * Admin regression: catalog uoms, identity users, inventory
      locations, printing-shop plates with i18n, metadata custom-fields
      endpoint — all still HTTP 2xx
    
    Build
    -----
    * `./gradlew build`: 15 subprojects, 163 unit tests (was 153),
      all green. The 10 new tests cover:
      - PermissionEvaluator: empty roles deny, admin wildcard, explicit
        role-permission grant, multi-role union, unknown role denial,
        malformed payload tolerance, currentHas with no AuthorizationContext,
        currentHas with bound context (8 tests).
      - JwtRoundTrip: roles claim round-trips through the access token,
        refresh token never carries roles even when asked (2 tests).
    
    What was deferred
    -----------------
    * **OIDC integration (P4.2)**. Built-in JWT only. The Keycloak-
      compatible OIDC client will reuse the same authorization layer
      unchanged — the roles will come from OIDC ID tokens instead of
      the local user store.
    * **Permission key validation at boot.** The framework does NOT
      yet check that every `@RequirePermission` value matches a
      declared metadata permission key. The plug-in linter is the
      natural place for that check to land later.
    * **Role hierarchy**. Roles are flat in v1; a role with permission
      X cannot inherit from another role. Adding a `parent_role` field
      on the role row is a non-breaking change later.
    * **Resource-aware permissions** ("the user owns THIS partner").
      v1 only checks the operation, not the operand. Resource-aware
      checks are post-v1.
    * **Composite (AND/OR) permission requirements**. A single key
      per call site keeps the contract simple. Composite requirements
      live in service code that calls `PermissionEvaluator.currentHas`
      directly.
    * **Role management UI / REST**. The framework can EVALUATE
      permissions but has no first-class endpoints for "create a
      role", "grant a permission to a role", "assign a role to a
      user". v1 expects these to be done via direct DB writes or via
      the future SPA's role editor (P3.x); the wiring above is
      intentionally policy-only, not management.
    zichun authored
     
    Browse Code »
  • The fifth real PBC and the first business workflow PBC. pbc-inventory
    proved a PBC could consume ONE cross-PBC facade (CatalogApi).
    pbc-orders-sales consumes TWO simultaneously (PartnersApi for the
    customer, CatalogApi for every line's item) in a single transaction —
    the most rigorous test of the modular monolith story so far. Neither
    source PBC is on the compile classpath; the Gradle build refuses any
    direct dependency. Spring DI wires the api.v1 interfaces to their
    concrete adapters at runtime.
    
    What landed
    -----------
    * New Gradle subproject `pbc/pbc-orders-sales` (15 modules total).
    * Two JPA entities, both extending `AuditedJpaEntity`:
      - `SalesOrder` (header) — code, partner_code (varchar, NOT a UUID
        FK to partners), status enum DRAFT/CONFIRMED/CANCELLED, order_date,
        currency_code (varchar(3)), total_amount numeric(18,4),
        ext jsonb. Eager-loaded `lines` collection because every read of
        the header is followed by a read of the lines in practice.
      - `SalesOrderLine` — sales_order_id FK, line_no, item_code (varchar,
        NOT a UUID FK to catalog), quantity, unit_price, currency_code.
        Per-line currency in the schema even though v1 enforces all-lines-
        match-header (so multi-currency relaxation is later schema-free).
        No `ext` jsonb on lines: lines are facts, not master records;
        custom fields belong on the header.
    * `SalesOrderService.create` performs **three independent
      cross-PBC validations** in one transaction:
      1. PartnersApi.findPartnerByCode → reject if null (covers unknown
         AND inactive partners; the facade hides them).
      2. PartnersApi result.type must be CUSTOMER or BOTH (a SUPPLIER-only
         partner cannot be the customer of a sales order).
      3. CatalogApi.findItemByCode for EVERY line → reject if null.
      Then it ALSO validates: at least one line, no duplicate line numbers,
      positive quantity, non-negative price, currency matches header.
      The header total is RECOMPUTED from the lines — the caller's value
      is intentionally ignored. Never trust a financial aggregate sent
      over the wire.
    * State machine enforced by `confirm()` and `cancel()`:
      - DRAFT → CONFIRMED   (confirm)
      - DRAFT → CANCELLED   (cancel from draft)
      - CONFIRMED → CANCELLED (cancel a confirmed order)
      Anything else throws with a descriptive message. CONFIRMED orders
      are immutable except for cancellation — the `update` method refuses
      to mutate a non-DRAFT order.
    * `update` with line items REPLACES the existing lines wholesale
      (PUT semantics for lines, PATCH for header columns). Partial line
      edits are not modelled because the typical "edit one line" UI
      gesture renders to a full re-send anyway.
    * REST: `/api/v1/orders/sales-orders` (CRUD + `/confirm` + `/cancel`).
      State transitions live on dedicated POST endpoints rather than
      PATCH-based status writes — they have side effects (lines become
      immutable, downstream PBCs will receive events in future versions),
      and sentinel-status writes hide that.
    * New api.v1 facade `org.vibeerp.api.v1.ext.orders.SalesOrdersApi`
      with `findByCode`, `findById`, `SalesOrderRef`, `SalesOrderLineRef`.
      Fifth ext.* package after identity, catalog, partners, inventory.
      Sets up the next consumers: pbc-production for work orders, pbc-finance
      for invoicing, the printing-shop reference plug-in for the
      quote-to-job-card workflow.
    * `SalesOrdersApiAdapter` runtime implementation. Cancelled orders ARE
      returned by the facade (unlike inactive items / partners which are
      hidden) because downstream consumers may legitimately need to react
      to a cancellation — release a production slot, void an invoice, etc.
    * `orders-sales.yml` metadata declaring 2 entities, 5 permission keys,
      1 menu entry.
    
    Build enforcement (still load-bearing)
    --------------------------------------
    The root `build.gradle.kts` STILL refuses any direct dependency from
    `pbc-orders-sales` to either `pbc-partners` or `pbc-catalog`. Try
    adding either as `implementation(project(...))` and the build fails
    at configuration time with the architectural violation. The
    cross-PBC interfaces live in api-v1; the concrete adapters live in
    their owning PBCs; Spring DI assembles them at runtime via the
    bootstrap @ComponentScan. pbc-orders-sales sees only the api.v1
    interfaces.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, hit:
    * POST /api/v1/catalog/items × 2  → PAPER-A4, INK-CYAN
    * POST /api/v1/partners/partners → CUST-ACME (CUSTOMER), SUP-ONLY (SUPPLIER)
    * POST /api/v1/orders/sales-orders → 201, two lines, total 386.50
      (5000 × 0.05 + 3 × 45.50 = 250.00 + 136.50, correctly recomputed)
    * POST .../sales-orders with FAKE-PARTNER → 400 with the meaningful
      message "partner code 'FAKE-PARTNER' is not in the partners
      directory (or is inactive)"
    * POST .../sales-orders with SUP-ONLY → 400 "partner 'SUP-ONLY' is
      type SUPPLIER and cannot be the customer of a sales order"
    * POST .../sales-orders with FAKE-ITEM line → 400 "line 1: item code
      'FAKE-ITEM' is not in the catalog (or is inactive)"
    * POST /{id}/confirm → status DRAFT → CONFIRMED
    * PATCH the CONFIRMED order → 400 "only DRAFT orders are mutable"
    * Re-confirm a CONFIRMED order → 400 "only DRAFT can be confirmed"
    * POST /{id}/cancel a CONFIRMED order → status CANCELLED (allowed)
    * SELECT * FROM orders_sales__sales_order — single row, total
      386.5000, status CANCELLED
    * SELECT * FROM orders_sales__sales_order_line — two rows in line_no
      order with the right items and quantities
    * GET /api/v1/_meta/metadata/entities → 13 entities now (was 11)
    * Regression: catalog uoms, identity users, partners, inventory
      locations, printing-shop plates with i18n (Accept-Language: zh-CN)
      all still HTTP 2xx.
    
    Build
    -----
    * `./gradlew build`: 15 subprojects, 153 unit tests (was 139),
      all green. The 14 new tests cover: unknown/SUPPLIER-only/BOTH-type
      partner paths, unknown item path, empty/duplicate-lineno line
      arrays, negative-quantity early reject (verifies CatalogApi NOT
      consulted), currency mismatch reject, total recomputation, all
      three state-machine transitions and the rejected ones.
    
    What was deferred
    -----------------
    * **Sales-order shipping**. Confirmed orders cannot yet ship, because
      shipping requires atomically debiting inventory — which needs the
      movement ledger that was deferred from P5.3. The pair of chunks
      (movement ledger + sales-order shipping flow) is the natural next
      combination.
    * **Multi-currency lines**. The schema column is per-line but the
      service enforces all-lines-match-header in v1. Relaxing this is a
      service-only change.
    * **Quotes** (DRAFT-but-customer-visible) and **deliveries** (the
      thing that triggers shipping). v1 only models the order itself.
    * **Pricing engine / discounts**. v1 takes the unit price the caller
      sends. A real ERP has a price book lookup, customer-specific
      pricing, volume discounts, promotional pricing — all of which slot
      in BEFORE the line price is set, leaving the schema unchanged.
    * **Tax**. v1 totals are pre-tax. Tax calculation is its own PBC
      (and a regulatory minefield) that lands later.
    zichun authored
     
    Browse Code »