• First runnable end-to-end demo: open the browser, log in, click
    through every PBC, and walk a sales order DRAFT → CONFIRMED →
    SHIPPED. Stock balances drop, the SALES_SHIPMENT row appears in the
    ledger, and the AR journal entry settles — all visible in the SPA
    without touching curl.
    
    Bumps version to 0.29.0-SNAPSHOT.
    
    What landed
    -----------
    
    * New `:web` Gradle subproject — Vite + React 18 + TypeScript +
      Tailwind 3.4. The Gradle wrapper is two `Exec` tasks
      (`npmInstall`, `npmBuild`) with proper inputs/outputs declared
      for incremental builds. Deliberately no node-gradle plugin —
      one less moving piece.
    
    * SPA architecture: hand-written typed REST client over `fetch`
      (auth header injection + 401 handler), AuthContext that decodes
      the JWT for display, ProtectedRoute, AppLayout with sidebar
      grouped by PBC, 16 page components covering the full v1 surface
      (Items, UoMs, Partners, Locations, Stock Balances + Movements,
      Sales Orders + detail w/ confirm/ship/cancel, Purchase Orders +
      detail w/ confirm/receive/cancel, Work Orders + detail w/
      start/complete, Shop-Floor dashboard with 5s polling, Journal
      Entries). 211 KB JS / 21 KB CSS gzipped.
    
    * Sales-order detail page: confirm/ship/cancel verbs each refresh
      the order, the (SO-filtered) movements list, and the
      (SO-filtered) journal entries — so an operator watches the
      ledger row appear and the AR row settle in real time after a
      single click. Same pattern on the purchase-order detail page
      for the AP/RECEIPT side.
    
    * Shop-floor dashboard polls /api/v1/production/work-orders/shop-
      floor every 5s and renders one card per IN_PROGRESS WO with
      current operation, planned vs actual minutes (progress bar),
      and operations-completed.
    
    * `:distribution` consumes the SPA dist via a normal Gradle
      outgoing/incoming configuration: `:web` exposes
      `webStaticBundle`, `:distribution`'s `bundleWebStatic` Sync
      task copies it into `${buildDir}/web-static/static/`, and
      that parent directory is added to the main resources source
      set so Spring Boot serves the SPA from `classpath:/static/`
      out of the same fat-jar. Single artifact, no nginx, no CORS.
    
    * New `SpaController` in platform-bootstrap forwards every
      known SPA route prefix to `/index.html` so React Router's
      HTML5 history mode works on hard refresh / deep-link entry.
      Explicit list (12 prefixes) rather than catch-all so typoed
      API URLs still get an honest 404 instead of the SPA shell.
    
    * SecurityConfiguration restructured: keeps the public allowlist
      for /api/v1/auth + /api/v1/_meta + /v3/api-docs + /swagger-ui,
      then `/api/**` is `.authenticated()`, then SPA static assets
      + every SPA route prefix are `.permitAll()`. The order is
      load-bearing — putting `.authenticated()` for /api/** BEFORE
      the SPA permitAll preserves the framework's "API is always
      authenticated" invariant even with the SPA bundled in the
      same fat-jar. The SPA bundle itself is just HTML+CSS+JS so
      permitting it is correct; secrets are gated by /api/**.
    
    * New `DemoSeedRunner` in `:distribution` (gated behind
      `vibeerp.demo.seed=true`, set in application-dev.yaml only).
      Idempotent — the runner short-circuits if its sentinel item
      (DEMO-PAPER-A4) already exists. Seeds 5 items, 2 warehouses,
      4 partners, opening stock for every item, one open
      DEMO-SO-0001 (50× business cards + 20× brochures, $720), one
      open DEMO-PO-0001 (10000× paper, $400). Every row carries
      the DEMO- prefix so it's trivially distinguishable from
      hand-created data; a future "delete demo data" command has
      an obvious filter. Production deploys never set the property,
      so the @ConditionalOnProperty bean stays absent from the
      context.
    
    How to run
    ----------
    
      docker compose up -d db
      ./gradlew :distribution:bootRun
      open http://localhost:8080
    
      # Read the bootstrap admin password from the boot log,
      # log in as admin, and walk DEMO-SO-0001 through the
      # confirm + ship flow to see the buy-sell loop in the UI.
    
    What was caught by the smoke test
    ---------------------------------
    
    * TypeScript strict mode + `error: unknown` in React state →
      `{error && <X/>}` evaluates to `unknown` and JSX rejects it.
      Fixed by typing the state as `Error | null` and converting
      in catches with `e instanceof Error ? e : new Error(String(e))`.
      Affected 16 page files; the conversion is now uniform.
    
    * DataTable's `T extends Record<string, unknown>` constraint was
      too restrictive for typed row interfaces; relaxed to
      unconstrained `T` with `(row as unknown as Record<…>)[key]`
      for the unkeyed cell read fallback.
    
    * `vite.config.ts` needs `@types/node` for `node:path` +
      `__dirname`; added to devDependencies and tsconfig.node.json
      declares `"types": ["node"]`.
    
    * KDoc nested-comment trap (4th time): SpaController's KDoc
      had `/api/v1/...` in backticks; the `/*` inside backticks
      starts a nested block comment and breaks Kotlin compilation
      with "Unclosed comment". Rephrased to "the api-v1 prefix".
    
    * SecurityConfiguration order: a draft version that put the
      SPA permit-all rules BEFORE `/api/**` authenticated() let
      unauthenticated requests reach API endpoints. Caught by an
      explicit smoke test (curl /api/v1/some-bogus-endpoint should
      return 401, not 404 from a missing static file). Reordered
      so /api/** authentication runs first.
    
    End-to-end smoke (real Postgres, fresh DB)
    ------------------------------------------
    
      - bootRun starts in 7.6s
      - DemoSeedRunner reports "populating starter dataset… done"
      - GET / returns the SPA HTML
      - GET /sales-orders, /sales-orders/<uuid>, /journal-entries
        all return 200 (SPA shell — React Router takes over)
      - GET /assets/index-*.js / /assets/index-*.css both 200
      - GET /api/v1/some-bogus-endpoint → 401 (Spring Security
        rejects before any controller mapping)
      - admin login via /api/v1/auth/login → 200 + JWT
      - GET /catalog/items → 5 DEMO-* rows
      - GET /partners/partners → 4 DEMO-* rows
      - GET /inventory/locations → 2 DEMO-* warehouses
      - GET /inventory/balances → 5 starting balances
      - POST /orders/sales-orders/<id>/confirm → CONFIRMED;
        GET /finance/journal-entries shows AR POSTED 720 USD
      - POST /orders/sales-orders/<id>/ship {"shippingLocationCode":
        "DEMO-WH-FG"} → SHIPPED; balances drop to 150 + 80;
        journal entry flips to SETTLED
      - POST /orders/purchase-orders/<id>/confirm → CONFIRMED;
        AP POSTED 400 USD appears
      - POST /orders/purchase-orders/<id>/receive → RECEIVED;
        PAPER balance grows from 5000 to 15000; AP row SETTLED
      - 8 stock_movement rows in the ledger total
    zichun authored
     
    Browse Code »
  • Closes a 15-commit-old TODO on MetaController and unifies the
    version story across /api/v1/_meta/info, /v3/api-docs, and the
    api-v1.jar manifest.
    
    **Build metadata wiring.** `distribution/build.gradle.kts` now
    calls `buildInfo()` inside the `springBoot { }` block. This makes
    Spring Boot's Gradle plug-in write `META-INF/build-info.properties`
    into the bootJar at build time with group / artifact / version /
    build time pulled from `project.version` + timestamps. Spring
    Boot's `BuildInfoAutoConfiguration` then exposes a `BuildProperties`
    bean that injection points can consume.
    
    **MetaController enriched.** Now injects:
      - `ObjectProvider<BuildProperties>` — returns the real version
        (`0.28.0-SNAPSHOT`) and the build timestamp when packaged
        through the distribution bootJar; falls back to `0.0.0-test`
        inside a bare platform-bootstrap unit test classloader with
        no build-info file on the classpath.
      - `Environment` — returns `spring.profiles.active` so a
        dashboard can distinguish "dev" from "staging" from a prod
        container that activates no profile.
    
    The GET /api/v1/_meta/info response now carries:
      - `name`, `apiVersion` — unchanged
      - `implementationVersion` — from BuildProperties (was stuck at
        "0.1.0-SNAPSHOT" via an unreachable `javaClass.package` lookup)
      - `buildTime` — ISO-8601 string from BuildProperties, null if
        the classpath has no build-info file
      - `activeProfiles` — list of effective spring profiles
    
    **OpenApiConfiguration now reads version from BuildProperties too.**
    Previously OPENAPI_INFO_VERSION was a hardcoded "v0.28.0"
    constant. Now it's injected via ObjectProvider<BuildProperties>
    with the same fallback pattern as MetaController. A single
    version bump in gradle.properties now flows to:
      gradle.properties
        → Spring Boot's buildInfo()
        → build-info.properties (on the classpath)
        → BuildProperties bean
        → MetaController (/_meta/info)
        → OpenApiConfiguration (/v3/api-docs + Swagger UI)
        → api-v1.jar manifest (already wired)
    
    No more hand-maintained version strings in code. Bump
    `vibeerp.version` in gradle.properties and every display follows.
    
    **Version bump.** `gradle.properties` `vibeerp.version`:
    `0.1.0-SNAPSHOT` → `0.28.0-SNAPSHOT`. This matches the numeric
    label used on PROGRESS.md's "Latest version" row and carries a
    documentation comment explaining the propagation chain so the
    next person bumping it knows what to update alongside (just the
    one line + PROGRESS.md).
    
    **KDoc trap caught.** A literal `/api/v1/_meta/**` path pattern
    in MetaController's KDoc tripped the Kotlin nested-comment
    parser (`/**` starts a KDoc). Rephrased as "the whole `/api/v1/_meta`
    prefix" to sidestep the trap — same workaround I saved in
    feedback memory after the first time it bit me.
    
    **Smoke-tested end-to-end against real Postgres:**
      - GET /api/v1/_meta/info returns
        `{"implementationVersion": "0.28.0-SNAPSHOT",
          "buildTime": "2026-04-09T09:48:25.646Z",
          "activeProfiles": ["dev"]}`
      - GET /v3/api-docs `info.version` = "0.28.0-SNAPSHOT" (was
        the hardcoded "v0.28.0" constant before this chunk)
      - Single edit to gradle.properties propagates cleanly.
    
    24 modules, 355 unit tests, all green.
    zichun authored
     
    Browse Code »