• …c-warehousing StockTransfer
    
    First cross-PBC reaction originating from pbc-quality. Records a
    REJECTED inspection with explicit source + quarantine location
    codes, publishes an api.v1 event inside the same transaction as
    the row insert, and pbc-warehousing's new subscriber atomically
    creates + confirms a StockTransfer that moves the rejected
    quantity to the quarantine bin. The whole chain — inspection
    insert + event publish + transfer create + confirm + two ledger
    rows — runs in a single transaction under the synchronous
    in-process bus with Propagation.MANDATORY.
    
    ## Why the auto-quarantine is opt-in per-inspection
    
    Not every inspection wants physical movement. A REJECTED batch
    that's already separated from good stock on the shop floor doesn't
    need the framework to move anything; the operator just wants the
    record. Forcing every rejection to create a ledger pair would
    collide with real-world QC workflows.
    
    The contract is simple: the `InspectionRecord` now carries two
    OPTIONAL columns (`source_location_code`, `quarantine_location_code`).
    When BOTH are set AND the decision is REJECTED AND the rejected
    quantity is positive, the subscriber reacts. Otherwise it logs at
    DEBUG and does nothing. The event is published either way, so
    audit/KPI subscribers see every inspection regardless.
    
    ## api.v1 additions
    
    New event class `org.vibeerp.api.v1.event.quality.InspectionRecordedEvent`
    with nine fields:
    
      inspectionCode, itemCode, sourceReference, decision,
      inspectedQuantity, rejectedQuantity,
      sourceLocationCode?, quarantineLocationCode?, inspector
    
    All required fields validated in `init { }` — blank strings,
    non-positive inspected quantity, negative rejected quantity, or
    an unknown decision string all throw at publish time so a
    malformed event never hits the outbox.
    
    `aggregateType = "quality.InspectionRecord"` matches the
    `<pbc>.<aggregate>` convention.
    
    `decision` is carried as a String (not the pbc-quality
    `InspectionDecision` enum) to keep guardrail #10 honest — api.v1
    events MUST NOT leak internal PBC types. Consumers compare
    against the literal `"APPROVED"` / `"REJECTED"` strings.
    
    ## pbc-quality changes
    
    - `InspectionRecord` entity gains two nullable columns:
      `source_location_code` + `quarantine_location_code`.
    - Liquibase migration `002-quality-quarantine-locations.xml` adds
      the columns to `quality__inspection_record`.
    - `InspectionRecordService` now injects `EventBus` and publishes
      `InspectionRecordedEvent` inside the `@Transactional record()`
      method. The publish carries all nine fields including the
      optional locations.
    - `RecordInspectionCommand` + `RecordInspectionRequest` gain the
      two optional location fields; unchanged default-null means
      every existing caller keeps working unchanged.
    - `InspectionRecordResponse` exposes both new columns on the HTTP
      wire.
    
    ## pbc-warehousing changes
    
    - New `QualityRejectionQuarantineSubscriber` @Component.
    - Subscribes in `@PostConstruct` via the typed-class
      `EventBus.subscribe(InspectionRecordedEvent::class.java, ...)`
      overload — same pattern every other PBC subscriber uses
      (SalesOrderConfirmedSubscriber, WorkOrderRequestedSubscriber,
      the pbc-finance order subscribers).
    - `handle(event)` is `internal` so the unit test can drive it
      directly without going through the bus.
    - Activation contract (all must be true): decision=REJECTED,
      rejectedQuantity>0, sourceLocationCode non-blank,
      quarantineLocationCode non-blank. Any missing condition → no-op.
    - Idempotency: derived transfer code is `TR-QC-<inspectionCode>`.
      Before creating, the subscriber checks
      `stockTransfers.findByCode(derivedCode)` — if anything exists
      (DRAFT, CONFIRMED, or CANCELLED), the subscriber skips. A
      replay of the same event under at-least-once delivery is safe.
    - On success: creates a DRAFT StockTransfer with one line moving
      `rejectedQuantity` of `itemCode` from source to quarantine,
      then calls `confirm(id)` which writes the atomic TRANSFER_OUT
      + TRANSFER_IN ledger pair.
    
    ## Smoke test (fresh DB)
    
    ```
    # seed
    POST /api/v1/catalog/items       {code: WIDGET-1, baseUomCode: ea}
    POST /api/v1/inventory/locations {code: WH-MAIN, type: WAREHOUSE}
    POST /api/v1/inventory/locations {code: WH-QUARANTINE, type: WAREHOUSE}
    POST /api/v1/inventory/movements {itemCode: WIDGET-1, locationId: <WH-MAIN>, delta: 100, reason: RECEIPT}
    
    # the cross-PBC reaction
    POST /api/v1/quality/inspections
         {code: QC-R-001,
          itemCode: WIDGET-1,
          sourceReference: "WO:WO-001",
          decision: REJECTED,
          inspectedQuantity: 50,
          rejectedQuantity: 7,
          reason: "surface scratches",
          sourceLocationCode: "WH-MAIN",
          quarantineLocationCode: "WH-QUARANTINE"}
      → 201 {..., sourceLocationCode: "WH-MAIN", quarantineLocationCode: "WH-QUARANTINE"}
    
    # automatically created + confirmed
    GET /api/v1/warehousing/stock-transfers/by-code/TR-QC-QC-R-001
      → 200 {
          "code": "TR-QC-QC-R-001",
          "fromLocationCode": "WH-MAIN",
          "toLocationCode": "WH-QUARANTINE",
          "status": "CONFIRMED",
          "note": "auto-quarantine from rejected inspection QC-R-001",
          "lines": [{"itemCode": "WIDGET-1", "quantity": 7.0}]
        }
    
    # ledger state (raw SQL)
    SELECT l.code, b.item_code, b.quantity
      FROM inventory__stock_balance b
      JOIN inventory__location l ON l.id = b.location_id
      WHERE b.item_code = 'WIDGET-1';
      WH-MAIN       | WIDGET-1 | 93.0000   ← was 100, now 93
      WH-QUARANTINE | WIDGET-1 |  7.0000   ← 7 rejected units here
    
    SELECT item_code, location, reason, delta, reference
      FROM inventory__stock_movement m JOIN inventory__location l ON l.id=m.location_id
      WHERE m.reference = 'TR:TR-QC-QC-R-001';
      WIDGET-1 | WH-MAIN       | TRANSFER_OUT | -7 | TR:TR-QC-QC-R-001
      WIDGET-1 | WH-QUARANTINE | TRANSFER_IN  |  7 | TR:TR-QC-QC-R-001
    
    # negatives
    POST /api/v1/quality/inspections {decision: APPROVED, ...+locations}
      → 201, but GET /TR-QC-QC-A-001 → 404 (no transfer, correct opt-out)
    
    POST /api/v1/quality/inspections {decision: REJECTED, rejected: 2, no locations}
      → 201, but GET /TR-QC-QC-R-002 → 404 (opt-in honored)
    
    # handler log
    [warehousing] auto-quarantining 7 units of 'WIDGET-1'
    from 'WH-MAIN' to 'WH-QUARANTINE'
    (inspection=QC-R-001, transfer=TR-QC-QC-R-001)
    ```
    
    Everything happens in ONE transaction because EventBusImpl uses
    Propagation.MANDATORY with synchronous delivery: the inspection
    insert, the event publish, the StockTransfer create, the
    confirm, and the two ledger rows all commit or roll back
    together.
    
    ## Tests
    
    - Updated `InspectionRecordServiceTest`: the service now takes an
      `EventBus` constructor argument. Every existing test got a
      relaxed `EventBus` mock; the one new test
      `record publishes InspectionRecordedEvent on success` captures
      the published event and asserts every field including the
      location codes.
    - 6 new unit tests in `QualityRejectionQuarantineSubscriberTest`:
      * subscribe registers one listener for InspectionRecordedEvent
      * handle creates and confirms a quarantine transfer on a
        fully-populated REJECTED event (asserts derived code,
        locations, item code, quantity)
      * handle is a no-op when decision is APPROVED
      * handle is a no-op when sourceLocationCode is missing
      * handle is a no-op when quarantineLocationCode is missing
      * handle skips when a transfer with the derived code already
        exists (idempotent replay)
    - Total framework unit tests: 334 (was 327), all green.
    
    ## What this unblocks
    
    - **Quality KPI dashboards** — any PBC can now subscribe to
      `InspectionRecordedEvent` without coupling to pbc-quality.
    - **pbc-finance quality-cost tracking** — when GL growth lands, a
      finance subscriber can debit a "quality variance" account on
      every REJECTED inspection.
    - **REF.2 / customer plug-in workflows** — the printing-shop
      plug-in can emit an `InspectionRecordedEvent` of its own from
      a BPMN service task (via `context.eventBus.publish`) and drive
      the same quarantine chain without touching pbc-quality's HTTP
      surface.
    
    ## Non-goals (parking lot)
    
    - Partial-batch quarantine decisions (moving some units to
      quarantine, some back to general stock, some to scrap). v1
      collapses the decision into a single "reject N units" action
      and assumes the operator splits batches manually before
      inspecting. A richer ResolutionPlan aggregate is a future
      chunk if real workflows need it.
    - Quality metrics storage. The event is audited by the existing
      wildcard event subscriber but no PBC rolls it up into a KPI
      table. Belongs to a future reporting feature.
    - Auto-approval chains. An APPROVED inspection could trigger a
      "release-from-hold" transfer (opposite direction) in a
      future-expanded subscriber, but v1 keeps the reaction
      REJECTED-only to match the "quarantine on fail" use case.
    zichun authored
     
    Browse Code »