• Backend:
    - Add update() method + UpdateWorkOrderCommand to WorkOrderService
      (DRAFT-only, mutable fields: outputQuantity, dueDate, ext)
    - Add PATCH /{id} endpoint + UpdateWorkOrderRequest to WorkOrderController
    - Add production.work-order.update permission to metadata YAML
    - Location, SalesOrder, PurchaseOrder already had PATCH endpoints
    
    Frontend:
    - Add getLocation, updateLocation to inventory client
    - Add update methods to salesOrders, purchaseOrders, production client
    - Create EditLocationPage (name, active, ext)
    - Create EditSalesOrderPage (ext only; DRAFT-only guard)
    - Create EditPurchaseOrderPage (ext only; DRAFT-only guard)
    - Create EditWorkOrderPage (outputQuantity, dueDate, ext; DRAFT-only guard)
    - Wire all four edit routes in App.tsx
    - Fix duplicate i18n keys in messages.ts (label.actions, label.fieldKey)
    zichun authored
     
    Browse Code »

  • 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 »
  • 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 »
  • 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 »

  • 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 »