• Adds GET /api/v1/production/work-orders/shop-floor — a pure read
    that returns every IN_PROGRESS work order with its current
    operation and planned/actual time totals. Designed to feed a
    future shop-floor dashboard (web SPA, mobile, or an external
    reporting tool) without any follow-up round trips.
    
    **Service method.** `WorkOrderService.shopFloorSnapshot()` is a
    @Transactional(readOnly = true) query that:
      1. Pulls every IN_PROGRESS work order via the existing
         `WorkOrderJpaRepository.findByStatus`.
      2. Sorts by WO code ascending so a dashboard poll gets a stable
         row order.
      3. For each WO picks the "current operation" = first op in
         IN_PROGRESS status, or, if none, first PENDING op. This
         captures both live states: "operator is running step N right
         now" and "operator just finished step N and hasn't picked up
         step N+1 yet".
      4. Computes `totalStandardMinutes` (sum across every op) +
         `totalActualMinutes` (sum of completed ops' `actualMinutes`
         only, treating null as zero).
      5. Counts completed vs total operations for a "step 2 of 5"
         badge.
      6. Returns a list of `ShopFloorEntry` DTOs — flat structure, one
         row per WO, nullable `current*` fields when a WO has no
         routing at all (v2-compat path).
    
    **HTTP surface.**
    - `GET /api/v1/production/work-orders/shop-floor`
    - New permission `production.shop-floor.read`
    - Response is `List<ShopFloorEntryResponse>` — flat so a SPA can
      render a table without joining across nested JSON. Fields are
      1:1 with the service-side `ShopFloorEntry`.
    
    **Design choices.**
    - Mounted under `/work-orders/shop-floor` rather than a top-level
      `/production/shop-floor` so every production read stays under
      the same permission/audit/OpenAPI root.
    - Read-only, zero events published, zero ledger writes. Pure
      projection over existing state.
    - Returns empty list when no WO is in-progress — the dashboard
      renders "no jobs running" without a special case.
    - Sorted by code so polling is deterministic. A future chunk
      might add sort-by-work-center if a dashboard needs a
      by-station view.
    
    **Why not a top-level "shop-floor" PBC.** A shop-floor dashboard
    doesn't own any state — every field it displays is projected from
    pbc-production. A new PBC would duplicate the data model and
    create a reaction loop on work order events. Keeping the read in
    pbc-production matches the CLAUDE.md guardrail "grow the PBC when
    real consumers appear, not on speculation".
    
    **Nullable `current*` fields.** A WO with an empty operations list
    (the v2-compat path — auto-spawned from SalesOrderConfirmedSubscriber
    before v3 routings) has all four `current*` fields set to null.
    The dashboard UI renders "no routing" or similar without any
    downstream round trip.
    
    **Tests (5 new).** empty snapshot when no IN_PROGRESS WOs; one
    entry per IN_PROGRESS WO with stable sort; current-op picks
    IN_PROGRESS over PENDING; current-op picks first PENDING when no
    op is IN_PROGRESS (between-operations state); v2-compat WO with
    no operations shows null current-op fields and zero time sums.
    
    **Smoke-tested end-to-end against real Postgres:**
    1. Empty shop-floor initially (no IN_PROGRESS WOs)
    2. Started plugin-printing-shop-quote-to-work-order BPMN with
       quoteCode=Q-DASH-1, quantity=500
    3. Started the resulting WO — shop-floor showed
       currentOperationLineNo=1 (CUT @ PRINTING-CUT-01) status=PENDING,
       0/4 completed, totalStandardMinutes=75, totalActualMinutes=0
    4. Started op 1 — currentOperationStatus flipped to IN_PROGRESS
    5. Completed op 1 with actualMinutes=17 — current op rolled
       forward to line 2 (PRINT @ PRINTING-PRESS-A) status=PENDING,
       operationsCompleted=1/4, totalActualMinutes=17
    
    24 modules, 355 unit tests (+5), all green.
    zichun authored
     
    Browse Code »
  • Extends WorkOrderRequestedEvent with an optional routing so a
    producer — core PBC or customer plug-in — can attach shop-floor
    operations to a requested work order without importing any
    pbc-production internals. The reference printing-shop plug-in's
    quote-to-work-order BPMN now ships a 4-step default routing
    (CUT → PRINT → FOLD → BIND) end-to-end through the public api.v1
    surface.
    
    **api.v1 surface additions (additive, defaulted).**
    - New public data class `RoutingOperationSpec(lineNo, operationCode,
      workCenter, standardMinutes)` in
      `api.v1.event.production.WorkOrderEvents` with init-block
      invariants matching pbc-production v3's internal validation
      (positive lineNo, non-blank operationCode + workCenter,
      non-negative standardMinutes).
    - `WorkOrderRequestedEvent` gains an `operations: List<RoutingOperationSpec>`
      field, defaulted to `emptyList()`. Existing callers compile
      without changes; the event's init block now also validates
      that every operation has a unique lineNo. Convention matches
      the other v1 events that already carry defaulted `eventId` and
      `occurredAt` — additive within a major version.
    
    **pbc-production subscriber wiring.**
    - `WorkOrderRequestedSubscriber.handle` now maps
      `event.operations` → `WorkOrderOperationCommand` 1:1 and passes
      them to `CreateWorkOrderCommand`. Empty list keeps the v2
      behavior exactly (auto-spawned orders from the SO path still
      get no routing and walk DRAFT → IN_PROGRESS → COMPLETED without
      any gate); a non-empty list feeds the new v3 WorkOrderOperation
      children and forces a sequential walk on the shop floor. The
      log line now includes `ops=<size>` so operators can see at a
      glance whether a WO came with a routing.
    
    **Reference plug-in.**
    - `CreateWorkOrderFromQuoteTaskHandler` now attaches
      `DEFAULT_PRINTING_SHOP_ROUTING`: a 4-step sequence modeled on
      the reference business doc's brochure production flow. Each
      step gets its own work center (PRINTING-CUT-01,
      PRINTING-PRESS-A, PRINTING-FOLD-01, PRINTING-BIND-01) so a
      future shop-floor dashboard can show which station is running
      which job. Standard times are round-number placeholders
      (15/30/10/20 minutes) — a real customer tunes them from
      historical data. Deliberately hard-coded in v1: a real shop
      with a dozen different flows would either ship a richer plug-in
      that picks routing per item type, or wait for a future Tier 1
      "routing template" metadata entity. v1 just proves the
      event-driven seam carries v3 operations end-to-end.
    
    **Why this is the right shape.**
    - Zero new compile-time coupling. The plug-in imports only
      `api.v1.event.production.RoutingOperationSpec`; the plug-in
      linter would refuse any reach into `pbc.production.*`.
    - Core pbc-production stays ignorant of the plug-in: the
      subscriber doesn't know where the event came from.
    - The same `WorkOrderRequestedEvent` path now works for ANY
      producer — the next customer plug-in that spawns routed work
      orders gets zero core changes.
    
    **Tests.** New `WorkOrderRequestedSubscriberTest.handle passes
    event operations through as WorkOrderOperationCommand` asserts
    the 1:1 mapping of RoutingOperationSpec → WorkOrderOperationCommand.
    The existing test gains one assertion that an empty `operations`
    list on the event produces an empty `operations` list on the
    command (backwards-compat lock-in).
    
    **Smoke-tested end-to-end against real Postgres:**
    1. POST /api/v1/workflow/process-instances with processDefinitionKey
       `plugin-printing-shop-quote-to-work-order` and variables
       `{quoteCode: "Q-ROUTING-001", itemCode: "FG-BROCHURE", quantity: 250}`
    2. BPMN runs through CreateWorkOrderFromQuoteTaskHandler,
       publishes WorkOrderRequestedEvent with 4 operations
    3. pbc-production subscriber creates WO `WO-FROM-PRINTINGSHOP-Q-ROUTING-001`
    4. GET /api/v1/production/work-orders/by-code/... returns the WO
       with status=DRAFT and 4 operations (CUT/PRINT/FOLD/BIND) all
       PENDING, each with its own work_center and standard_minutes.
    
    This is the framework's first business flow where a customer
    plug-in provides a routing to a core PBC end-to-end through
    api.v1 alone. Closes the loop between the v3 routings feature
    (commit fa867189) and the executable acceptance test in the
    reference plug-in.
    
    24 modules, 350 unit tests (+1), all green.
    zichun authored
     
    Browse Code »
  • Adds WorkOrderOperation child entity and two new verbs that gate
    WorkOrder.complete() behind a strict sequential walk of shop-floor
    steps. An empty operations list keeps the v2 behavior exactly; a
    non-empty list forces every op to reach COMPLETED before the work
    order can finish.
    
    **New domain.**
    - `production__work_order_operation` table with
      `UNIQUE (work_order_id, line_no)` and a status CHECK constraint
      admitting PENDING / IN_PROGRESS / COMPLETED.
    - `WorkOrderOperation` @Entity mirroring the `WorkOrderInput` shape:
      `lineNo`, `operationCode`, `workCenter`, `standardMinutes`,
      `status`, `actualMinutes` (nullable), `startedAt` + `completedAt`
      timestamps. No `ext` JSONB — operations are facts, not master
      records.
    - `WorkOrderOperationStatus` enum (PENDING / IN_PROGRESS / COMPLETED).
    - `WorkOrder.operations` collection with the same @OneToMany +
      cascade=ALL + orphanRemoval + @OrderBy("lineNo ASC") pattern as
      `inputs`.
    
    **State machine (sequential).**
    - `startOperation(workOrderId, operationId)` — parent WO must be
      IN_PROGRESS; target op must be PENDING; every earlier op must be
      COMPLETED. Flips to IN_PROGRESS and stamps `startedAt`.
      Idempotent no-op if already IN_PROGRESS.
    - `completeOperation(workOrderId, operationId, actualMinutes)` —
      parent WO must be IN_PROGRESS; target op must be IN_PROGRESS;
      `actualMinutes` must be non-negative. Flips to COMPLETED and
      stamps `completedAt`. Idempotent with the same `actualMinutes`;
      refuses to clobber with a different value.
    - `WorkOrder.complete()` gains a routings gate: refuses if any
      operation is not COMPLETED. Empty operations list is legal and
      preserves v2 behavior (auto-spawned orders from
      `SalesOrderConfirmedSubscriber` continue to complete without
      any gate).
    
    **Why sequential, not parallel.** v3 deliberately forbids parallel
    operations on one routing. The shop-floor dashboard story is
    trivial when the invariant is "you are on step N of M"; the unit
    test matrix is finite. Parallel routings (two presses in parallel)
    wait for a real consumer asking for them. Same pattern as every
    other pbc-production invariant — grow the PBC when consumers
    appear, not on speculation.
    
    **Why standardMinutes + actualMinutes instead of just timestamps.**
    The variance between planned and actual runtime is the single
    most interesting data point on a routing. Deriving it from
    `completedAt - startedAt` at report time has to fight
    shift-boundary and pause-resume ambiguity; the operator typing in
    "this run took 47 minutes" is the single source of truth. `startedAt`
    and `completedAt` are kept as an audit trail, not used for
    variance math.
    
    **Why work_center is a varchar not a FK.** Same cross-PBC discipline
    as every other identifier in pbc-production: work centers will be
    the seam for a future pbc-equipment PBC, and pinning a FK now
    would couple two PBC schemas before the consumer even exists
    (CLAUDE.md guardrail #9).
    
    **HTTP surface.**
    - `POST /api/v1/production/work-orders/{id}/operations/{operationId}/start`
      → `production.work-order.operation.start`
    - `POST /api/v1/production/work-orders/{id}/operations/{operationId}/complete`
      → `production.work-order.operation.complete`
      Body: `{"actualMinutes": "..."}`. Annotated with the
      single-arg Jackson trap escape hatch (`@JsonCreator(mode=PROPERTIES)`
      + `@param:JsonProperty`) — same trap that bit
      `CompleteWorkOrderRequest`, `ShipSalesOrderRequest`,
      `ReceivePurchaseOrderRequest`. Caught at smoke-test time.
    - `CreateWorkOrderRequest` accepts an optional `operations` array
      alongside `inputs`.
    - `WorkOrderResponse` gains `operations: List<WorkOrderOperationResponse>`
      showing status, standardMinutes, actualMinutes, startedAt,
      completedAt.
    
    **Metadata.** Two new permissions in `production.yml`:
    `production.work-order.operation.start` and
    `production.work-order.operation.complete`.
    
    **Tests (12 new).** create-with-ops happy path; duplicate line_no
    refused; blank operationCode refused; complete() gated when any
    op is not COMPLETED; complete() passes when every op is COMPLETED;
    startOperation refused on DRAFT parent; startOperation flips
    PENDING to IN_PROGRESS and stamps startedAt; startOperation
    refuses skip-ahead over a PENDING predecessor; startOperation is
    idempotent when already IN_PROGRESS; completeOperation records
    actualMinutes and flips to COMPLETED; completeOperation rejects
    negative actualMinutes; completeOperation refuses clobbering an
    already-COMPLETED op with a different value.
    
    **Smoke-tested end-to-end against real Postgres:**
    - Created a WO with 3 operations (CUT → PRINT → BIND)
    - `complete()` refused while DRAFT, then refused while IN_PROGRESS
      with pending ops ("3 routing operation(s) are not yet COMPLETED")
    - Skip-ahead `startOperation(op2)` refused ("earlier operation(s)
      are not yet COMPLETED")
    - Walked ops 1 → 2 → 3 through start + complete with varying
      actualMinutes (17, 32.5, 18 vs standard 15, 30, 20)
    - Final `complete()` succeeded, wrote exactly ONE
      PRODUCTION_RECEIPT ledger row for 100 units of FG-BROCHURE —
      no premature writes
    - Separately verified a no-operations WO still walks DRAFT →
      IN_PROGRESS → COMPLETED exactly like v2
    
    24 modules, 349 unit tests (+12), all green.
    zichun authored
     
    Browse Code »
  • …duction auto-creates WorkOrder
    
    First end-to-end cross-PBC workflow driven entirely from a customer
    plug-in through api.v1 surfaces. A printing-shop BPMN kicks off a
    TaskHandler that publishes a generic api.v1 event; pbc-production
    reacts by creating a DRAFT WorkOrder. The plug-in has zero
    compile-time coupling to pbc-production, and pbc-production has zero
    knowledge the plug-in exists.
    
    ## Why an event, not a facade
    
    Two options were on the table for "how does a plug-in ask
    pbc-production to create a WorkOrder":
    
      (a) add a new cross-PBC facade `api.v1.ext.production.ProductionApi`
          with a `createWorkOrder(command)` method
      (b) add a generic `WorkOrderRequestedEvent` in `api.v1.event.production`
          that anyone can publish — this commit
    
    Facade pattern (a) is what InventoryApi.recordMovement and
    CatalogApi.findItemByCode use: synchronous, in-transaction,
    caller-blocks-on-completion. Event pattern (b) is what
    SalesOrderConfirmedEvent → SalesOrderConfirmedSubscriber uses:
    asynchronous over the bus, still in-transaction (the bus uses
    `Propagation.MANDATORY` with synchronous delivery so a failure
    rolls everything back), but the caller doesn't need a typed result.
    
    Option (b) wins for plug-in → pbc-production:
    
    - Plug-in compile-time surface stays identical: plug-ins already
      import `api.v1.event.*` to publish. No new api.v1.ext package.
      Zero new plug-in dependency.
    - The outbox gets the row for free — a crash between publish and
      delivery replays cleanly from `platform__event_outbox`.
    - A second customer plug-in shipping a different flow that ALSO
      wants to auto-spawn work orders doesn't need a second facade, just
      publishes the same event. pbc-scheduling (future) can subscribe
      to the same channel without duplicating code.
    
    The synchronous facade pattern stays the right tool for cross-PBC
    operations the caller needs to observe (read-throughs, inventory
    debits that must block the current transaction). Creating a DRAFT
    work order is a fire-and-trust operation — the event shape fits.
    
    ## What landed
    
    ### api.v1 — WorkOrderRequestedEvent
    
    New event class `org.vibeerp.api.v1.event.production.WorkOrderRequestedEvent`
    with four required fields:
      - `code`: desired work-order code (must be unique globally;
        convention is to bake the source reference into it so duplicate
        detection is trivial, e.g. `WO-FROM-PRINTINGSHOP-Q-007`)
      - `outputItemCode` + `outputQuantity`: what to produce
      - `sourceReference`: opaque free-form pointer used in logs and
        the outbox audit trail. Example values:
        `plugin:printing-shop:quote:Q-007`,
        `pbc-orders-sales:SO-2026-001:L2`
    
    The class is a `DomainEvent` (not a `WorkOrderEvent` subclass — the
    existing `WorkOrderEvent` sealed interface is for LIFECYCLE events
    published BY pbc-production, not for inbound requests). `init`
    validators reject blank strings and non-positive quantities so a
    malformed event fails fast at publish time rather than at the
    subscriber.
    
    ### pbc-production — WorkOrderRequestedSubscriber
    
    New `@Component` in `pbc/pbc-production/.../event/WorkOrderRequestedSubscriber.kt`.
    Subscribes in `@PostConstruct` via the typed-class `EventBus.subscribe`
    overload (same pattern as `SalesOrderConfirmedSubscriber` + the six
    pbc-finance order subscribers). The subscriber:
    
      1. Looks up `workOrders.findByCode(event.code)` as the idempotent
         short-circuit. If a WorkOrder with that code already exists
         (outbox replay, future async bus retry, developer re-running the
         same BPMN process), the subscriber logs at DEBUG and returns.
         **Second execution of the same BPMN produces the same outbox row
         which the subscriber then skips — the database ends up with
         exactly ONE WorkOrder regardless of how many times the process
         runs.**
      2. Calls `WorkOrderService.create(CreateWorkOrderCommand(...))` with
         the event's fields. `sourceSalesOrderCode` is null because this
         is the generic path, not the SO-driven one.
    
    Why this is a SECOND subscriber rather than extending
    `SalesOrderConfirmedSubscriber`: the two events serve different
    producers. `SalesOrderConfirmedEvent` is pbc-orders-sales-specific
    and requires a round-trip through `SalesOrdersApi.findByCode` to
    fetch the lines; `WorkOrderRequestedEvent` carries everything the
    subscriber needs inline. Collapsing them would mean the generic
    path inherits the SO-flow's SO-specific lookup and short-circuit
    logic that doesn't apply to it.
    
    ### reference printing-shop plug-in — CreateWorkOrderFromQuoteTaskHandler
    
    New plug-in TaskHandler in
    `reference-customer/plugin-printing-shop/.../workflow/CreateWorkOrderFromQuoteTaskHandler.kt`.
    Captures the `PluginContext` via constructor — same pattern as
    `PlateApprovalTaskHandler` landed in `7b2ab34d` — and from inside
    `execute`:
    
      1. Reads `quoteCode`, `itemCode`, `quantity` off the process variables
         (`quantity` accepts Number or String since Flowable's variable
         coercion is flexible).
      2. Derives `workOrderCode = "WO-FROM-PRINTINGSHOP-$quoteCode"` and
         `sourceReference = "plugin:printing-shop:quote:$quoteCode"`.
      3. Logs via `context.logger.info(...)` — the line is tagged
         `[plugin:printing-shop]` by the framework's `Slf4jPluginLogger`.
      4. Publishes `WorkOrderRequestedEvent` via `context.eventBus.publish(...)`.
         This is the first time a plug-in TaskHandler publishes a cross-PBC
         event from inside a workflow — proves the event-bus leg of the
         handler-context pattern works end-to-end.
      5. Writes `workOrderCode` + `workOrderRequested=true` back to the
         process variables so a downstream BPMN step or the HTTP caller
         can see the derived code.
    
    The handler is registered in `PrintingShopPlugin.start(context)`
    alongside `PlateApprovalTaskHandler`:
    
        context.taskHandlers.register(PlateApprovalTaskHandler(context))
        context.taskHandlers.register(CreateWorkOrderFromQuoteTaskHandler(context))
    
    Teardown via `unregisterAllByOwner("printing-shop")` still works
    unchanged — the scoped registrar tracks both handlers.
    
    ### reference printing-shop plug-in — quote-to-work-order.bpmn20.xml
    
    New BPMN file `processes/quote-to-work-order.bpmn20.xml` in the
    plug-in JAR. Single synchronous service task, process definition
    key `plugin-printing-shop-quote-to-work-order`, service task id
    `printing_shop.quote.create_work_order` (matches the handler key).
    Auto-deployed by the host's `PluginProcessDeployer` at plug-in
    start — the printing-shop plug-in now ships two BPMNs bundled into
    one Flowable deployment, both under category `printing-shop`.
    
    ## Smoke test (fresh DB)
    
    ```
    $ docker compose down -v && docker compose up -d db
    $ ./gradlew :distribution:bootRun &
    ...
    registered TaskHandler 'printing_shop.plate.approve' owner='printing-shop' ...
    registered TaskHandler 'printing_shop.quote.create_work_order' owner='printing-shop' ...
    [plugin:printing-shop] registered 2 TaskHandlers: printing_shop.plate.approve, printing_shop.quote.create_work_order
    PluginProcessDeployer: plug-in 'printing-shop' deployed 2 BPMN resource(s) as Flowable deploymentId='1e5c...':
      [processes/quote-to-work-order.bpmn20.xml, processes/plate-approval.bpmn20.xml]
    pbc-production subscribed to WorkOrderRequestedEvent via EventBus.subscribe (typed-class overload)
    
    # 1) seed a catalog item
    $ curl -X POST /api/v1/catalog/items
           {"code":"BOOK-HARDCOVER","name":"Hardcover book","itemType":"GOOD","baseUomCode":"ea"}
      → 201 BOOK-HARDCOVER
    
    # 2) start the plug-in's quote-to-work-order BPMN
    $ curl -X POST /api/v1/workflow/process-instances
           {"processDefinitionKey":"plugin-printing-shop-quote-to-work-order",
            "variables":{"quoteCode":"Q-007","itemCode":"BOOK-HARDCOVER","quantity":500}}
      → 201 {"ended":true,
             "variables":{"quoteCode":"Q-007",
                          "itemCode":"BOOK-HARDCOVER",
                          "quantity":500,
                          "workOrderCode":"WO-FROM-PRINTINGSHOP-Q-007",
                          "workOrderRequested":true}}
    
    Log lines observed:
      [plugin:printing-shop] quote Q-007: publishing WorkOrderRequestedEvent
         (code=WO-FROM-PRINTINGSHOP-Q-007, item=BOOK-HARDCOVER, qty=500)
      [production] WorkOrderRequestedEvent creating work order 'WO-FROM-PRINTINGSHOP-Q-007'
         for item 'BOOK-HARDCOVER' x 500 (source='plugin:printing-shop:quote:Q-007')
    
    # 3) verify the WorkOrder now exists in pbc-production
    $ curl /api/v1/production/work-orders
      → [{"id":"029c2482-...",
          "code":"WO-FROM-PRINTINGSHOP-Q-007",
          "outputItemCode":"BOOK-HARDCOVER",
          "outputQuantity":500.0,
          "status":"DRAFT",
          "sourceSalesOrderCode":null,
          "inputs":[], "ext":{}}]
    
    # 4) run the SAME BPMN a second time — verify idempotent
    $ curl -X POST /api/v1/workflow/process-instances
           {same body as above}
      → 201  (process ends, workOrderRequested=true, new event published + delivered)
    $ curl /api/v1/production/work-orders
      → count=1, still only WO-FROM-PRINTINGSHOP-Q-007
    ```
    
    Every single step runs through an api.v1 public surface. No framework
    core code knows the printing-shop plug-in exists; no plug-in code knows
    pbc-production exists. They meet on the event bus, and the outbox
    guarantees the delivery.
    
    ## Tests
    
    - 3 new tests in `pbc-production/.../WorkOrderRequestedSubscriberTest`:
      * `subscribe registers one listener for WorkOrderRequestedEvent`
      * `handle creates a work order from the event fields` — captures the
        `CreateWorkOrderCommand` and asserts every field
      * `handle short-circuits when a work order with that code already exists`
        — proves the idempotent branch
    - Total framework unit tests: 278 (was 275), all green.
    
    ## What this unblocks
    
    - **Richer multi-step BPMNs** in the plug-in that chain plate
      approval + quote → work order + production start + completion.
    - **Plug-in-owned Quote entity** — the printing-shop plug-in can now
      introduce a `plugin_printingshop__quote` table via its own Liquibase
      changelog and have its HTTP endpoint create quotes that kick off the
      quote-to-work-order workflow automatically (or on operator confirm).
    - **pbc-production routings/operations (v3)** — each operation becomes
      a BPMN step, potentially driven by plug-ins contributing custom
      steps via the same TaskHandler + event seam.
    - **Second reference plug-in** — any new customer plug-in can publish
      `WorkOrderRequestedEvent` from its own workflows without any
      framework change.
    
    ## Non-goals (parking lot)
    
    - The handler publishes but does not also read pbc-production state
      back. A future "wait for WO completion" BPMN step could subscribe
      to `WorkOrderCompletedEvent` inside a user-task + signal flow, but
      the engine's signal/correlation machinery isn't wired to
      plug-ins yet.
    - Quote entity + HTTP + real business logic. REF.1 proves the
      cross-PBC event seam; the richer quote lifecycle is a separate
      chunk that can layer on top of this.
    - Transactional rollback integration test. The synchronous bus +
      `Propagation.MANDATORY` guarantees it, but an explicit test that
      a subscriber throw rolls back both the ledger-adjacent writes and
      the Flowable process state would be worth adding with a real
      test container run.
    zichun authored
     
    Browse Code »
  • Closes the last known gap from the HasExt refactor (commit 986f02ce):
    pbc-production's WorkOrder had an `ext` column but no validator was
    wired, so an operator could write arbitrary JSON without any
    schema enforcement. This fixes that and adds the first Tier 1
    custom fields for WorkOrder.
    
    Code changes:
      - WorkOrder implements HasExt; ext becomes `override var ext`,
        ENTITY_NAME moves onto the entity companion.
      - WorkOrderService injects ExtJsonValidator, calls applyTo() in
        create() before saving (null-safe so the
        SalesOrderConfirmedSubscriber's auto-spawn path still works —
        verified by smoke test).
      - CreateWorkOrderCommand + CreateWorkOrderRequest gain an `ext`
        field that flows through to the validator.
      - WorkOrderResponse gains an `ext: Map<String, Any?>` field; the
        response mapper signature changes to `toResponse(service)` to
        reach the validator via a convenience parseExt delegate on the
        service (same pattern as the other four PBCs).
      - pbc-production Gradle build adds `implementation(project(":platform:platform-metadata"))`.
    
    Metadata (production.yml):
      - Permission keys extended to match the v2 state machine:
        production.work-order.start (was missing) and
        production.work-order.scrap (was missing). The existing
        .read / .create / .complete / .cancel keys stay.
      - Two custom fields declared:
          * production_priority (enum: low, normal, high, urgent)
          * production_routing_notes (string, maxLength 1024)
        Both are optional and non-PII; an operator can now add
        priority and routing notes to a work order through the public
        API without any code change, which is the whole point of
        Tier 1 customization.
    
    Unit tests: WorkOrderServiceTest constructor updated to pass the
    new extValidator dependency and stub applyTo/parseExt as no-ops.
    No behavioral test changes — ext validation is covered by
    ExtJsonValidatorTest and the platform-wide smoke tests.
    
    Smoke verified end-to-end against real Postgres:
      - GET /_meta/metadata/custom-fields/WorkOrder now returns both
        declarations with correct enum sets and maxLength.
      - POST /work-orders with valid ext {production_priority:"high",
        production_routing_notes:"Rush for customer demo"} → 201,
        canonical form persisted, round-trips via GET.
      - POST with invalid enum value → 400 "value 'emergency' is not
        in allowed set [low, normal, high, urgent]".
      - POST with unknown ext key → 400 "ext contains undeclared
        key(s) for 'WorkOrder': [unknown_field]".
      - Auto-spawn from confirmed SO → DRAFT work order with empty
        ext `{}`, confirming the applyTo(null) null-safe path.
    
    Five of the eight PBCs now participate in the HasExt pattern:
    Partner, Location, SalesOrder, PurchaseOrder, WorkOrder. The
    remaining three (Item, Uom, JournalEntry) either have their own
    custom-field story in separate entities or are derived state.
    
    246 unit tests, all green. 18 Gradle subprojects.
    zichun authored
     
    Browse Code »
  • Grows pbc-production from the minimal v1 (DRAFT → COMPLETED in one
    step, single output, no BOM) into a real v2 production PBC:
    
      1. IN_PROGRESS state between DRAFT and COMPLETED so "started but
         not finished" work orders are observable on a dashboard.
         WorkOrderService.start(id) performs the transition and publishes
         a new WorkOrderStartedEvent. cancel() now accepts DRAFT OR
         IN_PROGRESS (v2 writes nothing to the ledger at start() so there
         is nothing to undo on cancel).
    
      2. Bill of materials via a new WorkOrderInput child entity —
         @OneToMany with cascade + orphanRemoval, same shape as
         SalesOrderLine. Each line carries (lineNo, itemCode,
         quantityPerUnit, sourceLocationCode). complete() now iterates
         the inputs in lineNo order and writes one MATERIAL_ISSUE
         ledger row per line (delta = -(quantityPerUnit × outputQuantity))
         BEFORE writing the PRODUCTION_RECEIPT for the output. All in
         one transaction — a failure anywhere rolls back every prior
         ledger row AND the status flip. Empty inputs list is legal
         (the v1 auto-spawn-from-SO path still works unchanged,
         writing only the PRODUCTION_RECEIPT).
    
      3. Scrap flow for COMPLETED work orders via a new scrap(id,
         scrapLocationCode, quantity, note) service method. Writes a
         negative ADJUSTMENT ledger row tagged WO:<code>:SCRAP and
         publishes a new WorkOrderScrappedEvent. Chose ADJUSTMENT over
         adding a new SCRAP movement reason to keep the enum stable —
         the reference-string suffix is the disambiguator. The work
         order itself STAYS COMPLETED; scrap is a correction on top of
         a terminal state, not a state change.
    
      complete() now requires IN_PROGRESS (not DRAFT); existing callers
      must start() first.
    
      api.v1 grows two events (WorkOrderStartedEvent,
      WorkOrderScrappedEvent) alongside the three that already existed.
      Since this is additive within a major version, the api.v1 semver
      contract holds — existing subscribers continue to compile.
    
      Liquibase: 002-production-v2.xml widens the status CHECK and
      creates production__work_order_input with (work_order_id FK,
      line_no, item_code, quantity_per_unit, source_location_code) plus
      a unique (work_order_id, line_no) constraint, a CHECK
      quantity_per_unit > 0, and the audit columns. ON DELETE CASCADE
      from the parent.
    
      Unit tests: WorkOrderServiceTest grows from 8 to 18 cases —
      covers start happy path, start rejection, complete-on-DRAFT
      rejection, empty-BOM complete, BOM-with-two-lines complete
      (verifies both MATERIAL_ISSUE deltas AND the PRODUCTION_RECEIPT
      all fire with the right references), scrap happy path, scrap on
      non-COMPLETED rejection, scrap with non-positive quantity
      rejection, cancel-from-IN_PROGRESS, and BOM validation rejects
      (unknown item, duplicate line_no).
    
    Smoke verified end-to-end against real Postgres:
      - Created WO-SMOKE with 2-line BOM (2 paper + 0.5 ink per
        brochure, output 100).
      - Started (DRAFT → IN_PROGRESS, no ledger rows).
      - Completed: paper balance 500→300 (MATERIAL_ISSUE -200),
        ink 200→150 (MATERIAL_ISSUE -50), FG-BROCHURE 0→100
        (PRODUCTION_RECEIPT +100). All 3 rows tagged WO:WO-SMOKE.
      - Scrapped 7 units: FG-BROCHURE 100→93, ADJUSTMENT -7 tagged
        WO:WO-SMOKE:SCRAP, work order stayed COMPLETED.
      - Auto-spawn: SO-42 confirm still creates WO-FROM-SO-42-L1 as a
        DRAFT with empty BOM; starting + completing it writes only the
        PRODUCTION_RECEIPT (zero MATERIAL_ISSUE rows), proving the
        empty-BOM path is backwards-compatible.
      - Negative paths: complete-on-DRAFT 400s, scrap-on-DRAFT 400s,
        double-start 400s, cancel-from-IN_PROGRESS 200.
    
    240 unit tests, 18 Gradle subprojects.
    zichun authored
     
    Browse Code »

  • The framework's eighth PBC and the first one that's NOT order- or
    master-data-shaped. Work orders are about *making things*, which is
    the reason the printing-shop reference customer exists in the first
    place. With this PBC in place the framework can express the full
    buy-sell-make loop end-to-end.
    
    What landed (new module pbc/pbc-production/)
      - WorkOrder entity (production__work_order):
          code, output_item_code, output_quantity, status (DRAFT|COMPLETED|
          CANCELLED), due_date (display-only), source_sales_order_code
          (nullable — work orders can be either auto-spawned from a
          confirmed SO or created manually), ext.
      - WorkOrderJpaRepository with existsBySourceSalesOrderCode /
        findBySourceSalesOrderCode for the auto-spawn dedup.
      - WorkOrderService.create / complete / cancel:
          • create validates the output item via CatalogApi (same seam
            SalesOrderService and PurchaseOrderService use), rejects
            non-positive quantities, publishes WorkOrderCreatedEvent.
          • complete(outputLocationCode) credits finished goods to the
            named location via InventoryApi.recordMovement with
            reason=PRODUCTION_RECEIPT (added in commit c52d0d59) and
            reference="WO:<order_code>", then flips status to COMPLETED,
            then publishes WorkOrderCompletedEvent — all in the same
            @Transactional method.
          • cancel only allowed from DRAFT (no un-producing finished
            goods); publishes WorkOrderCancelledEvent.
      - SalesOrderConfirmedSubscriber (@PostConstruct →
        EventBus.subscribe(SalesOrderConfirmedEvent::class.java, ...)):
        walks the confirmed sales order's lines via SalesOrdersApi
        (NOT by importing pbc-orders-sales) and calls
        WorkOrderService.create for each line. Coded as one bean with
        one subscription — matches pbc-finance's one-bean-per-subject
        pattern.
          • Idempotent on source sales order code — if any work order
            already exists for the SO, the whole spawn is a no-op.
          • Tolerant of a missing SO (defensive against a future async
            bus that could deliver the confirm event after the SO has
            vanished).
          • The WO code convention: WO-FROM-<so_code>-L<lineno>, e.g.
            WO-FROM-SO-2026-0001-L1.
    
      - REST controller /api/v1/production/work-orders: list, get,
        by-code, create, complete, cancel — each annotated with
        @RequirePermission. Four permission keys declared in the
        production.yml metadata: read / create / complete / cancel.
      - CompleteWorkOrderRequest: single-arg DTO uses the
        @JsonCreator(mode=PROPERTIES) + @param:JsonProperty trick that
        already bit ShipSalesOrderRequest and ReceivePurchaseOrderRequest;
        cross-referenced in the KDoc so the third instance doesn't need
        re-discovery.
      - distribution/.../pbc-production/001-production-init.xml:
        CREATE TABLE with CHECK on status + CHECK on qty>0 + GIN on ext
        + the usual indexes. NEITHER output_item_code NOR
        source_sales_order_code is a foreign key (cross-PBC reference
        policy — guardrail #9).
      - settings.gradle.kts + distribution/build.gradle.kts: registers
        the new module and adds it to the distribution dependency list.
      - master.xml: includes the new changelog in dependency order,
        after pbc-finance.
    
    New api.v1 surface: org.vibeerp.api.v1.event.production.*
      - WorkOrderCreatedEvent, WorkOrderCompletedEvent,
        WorkOrderCancelledEvent — sealed under WorkOrderEvent,
        aggregateType="production.WorkOrder". Same pattern as the
        order events, so any future consumer (finance revenue
        recognition, warehouse put-away dashboard, a customer plug-in
        that needs to react to "work finished") subscribes through the
        public typed-class overload with no dependency on pbc-production.
    
    Unit tests (13 new, 217 → 230 total)
      - WorkOrderServiceTest (9 tests): create dedup, positive quantity
        check, catalog seam, happy-path create with event assertion,
        complete rejects non-DRAFT, complete happy path with
        InventoryApi.recordMovement assertion + event assertion, cancel
        from DRAFT, cancel rejects COMPLETED.
      - SalesOrderConfirmedSubscriberTest (5 tests): subscription
        registration count, spawns N work orders for N SO lines with
        correct code convention, idempotent when WOs already exist,
        no-op on missing SO, and a listener-routing test that captures
        the EventListener instance and verifies it forwards to the
        right service method.
    
    End-to-end smoke verified against real Postgres
      - Fresh DB, fresh boot. Both OrderEventSubscribers (pbc-finance)
        and SalesOrderConfirmedSubscriber (pbc-production) log their
        subscription registration before the first HTTP call.
      - Seeded two items (BROCHURE-A, BROCHURE-B), a customer, and a
        finished-goods location (WH-FG).
      - Created a 2-line sales order (SO-WO-1), confirmed it.
          → Produced ONE orders_sales.SalesOrder outbox row.
          → Produced ONE AR POSTED finance__journal_entry for 1000 USD
            (500 × 1 + 250 × 2 — the pbc-finance consumer still works).
          → Produced TWO draft work orders auto-spawned from the SO
            lines: WO-FROM-SO-WO-1-L1 (BROCHURE-A × 500) and
            WO-FROM-SO-WO-1-L2 (BROCHURE-B × 250), both with
            source_sales_order_code=SO-WO-1.
      - Completed WO1 to WH-FG:
          → Produced a PRODUCTION_RECEIPT ledger row for BROCHURE-A
            delta=500 reference="WO:WO-FROM-SO-WO-1-L1".
          → inventory__stock_balance now has BROCHURE-A = 500 at WH-FG.
          → Flipped status to COMPLETED.
      - Cancelled WO2 → CANCELLED.
      - Created a manual WO-MANUAL-1 with no source SO → succeeds;
        demonstrates the "operator creates a WO to build inventory
        ahead of demand" path.
      - platform__event_outbox ends with 6 rows all DISPATCHED:
          orders_sales.SalesOrder SO-WO-1
          production.WorkOrder WO-FROM-SO-WO-1-L1  (created)
          production.WorkOrder WO-FROM-SO-WO-1-L2  (created)
          production.WorkOrder WO-FROM-SO-WO-1-L1  (completed)
          production.WorkOrder WO-FROM-SO-WO-1-L2  (cancelled)
          production.WorkOrder WO-MANUAL-1         (created)
    
    Why this chunk was the right next move
      - pbc-finance was a PASSIVE consumer — it only wrote derived
        reporting state. pbc-production is the first ACTIVE consumer:
        it creates new aggregates with their own state machines and
        their own cross-PBC writes in reaction to another PBC's events.
        This is a meaningfully harder test of the event-driven
        integration story and it passes end-to-end.
      - "One ledger, three callers" is now real: sales shipments,
        purchase receipts, AND production receipts all feed the same
        inventory__stock_movement ledger through the same
        InventoryApi.recordMovement facade. The facade has proven
        stable under three very different callers.
      - The framework now expresses the basic ERP trinity: buy
        (purchase orders), sell (sales orders), make (work orders).
        That's the shape every real manufacturing customer needs, and
        it's done without any PBC importing another.
    
    What's deliberately NOT in v1
      - No bill of materials. complete() only credits finished goods;
        it does NOT issue raw materials. A shop floor that needs to
        consume 4 sheets of paper to produce 1 brochure does it
        manually via POST /api/v1/inventory/movements with reason=
        MATERIAL_ISSUE (added in commit c52d0d59). A proper BOM lands
        as WorkOrderInput lines in a future chunk.
      - No IN_PROGRESS state. complete() goes DRAFT → COMPLETED in
        one step. A real shop floor needs "started but not finished"
        visibility; that's the next iteration.
      - No routings, operations, machine assignments, or due-date
        enforcement. due_date is display-only.
      - No "scrap defective output" flow for a COMPLETED work order.
        cancel refuses from COMPLETED; the fix requires a new
        MovementReason and a new event, not a special-case method
        on the service.
    zichun authored
     
    Browse Code »