• …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 »
  • Closes the core PBC row of the v1.0 target. Ships pbc-quality as a
    lean v1 recording-only aggregate: any caller that performs a quality
    inspection (inbound goods, in-process work order output, outbound
    shipment) appends an immutable InspectionRecord with a decision
    (APPROVED/REJECTED), inspected/rejected quantities, a free-form
    source reference, and the inspector's principal id.
    
    ## Deliberately narrow v1 scope
    
    pbc-quality does NOT ship:
      - cross-PBC writes (no "rejected stock gets auto-quarantined" rule)
      - event publishing (no InspectionRecordedEvent in api.v1 yet)
      - inspection plans or templates (no "item X requires checks Y, Z")
      - multi-check records (one decision per row; multi-step
        inspections become multiple records)
    
    The rationale is the "follow the consumer" discipline: every seam
    the framework adds has to be driven by a real consumer. With no PBC
    yet subscribing to inspection events or calling into pbc-quality,
    speculatively building those capabilities would be guessing the
    shape. Future chunks that actually need them (e.g. pbc-warehousing
    auto-quarantine on rejection, pbc-production WorkOrder scrap from
    rejected QC) will grow the seam into the shape they need.
    
    Even at this narrow scope pbc-quality delivers real value: a
    queryable, append-only, permission-gated record of every QC
    decision in the system, filterable by source reference or item
    code, and linked to the catalog via CatalogApi.
    
    ## Module contents
    
    - `build.gradle.kts` — new Gradle subproject following the existing
      recipe. api-v1 + platform/persistence + platform/security only;
      no cross-pbc deps (guardrail #9 stays honest).
    - `InspectionRecord` entity — code, item_code, source_reference,
      decision (enum), inspected_quantity, rejected_quantity, inspector
      (principal id as String, same convention as created_by), reason,
      inspected_at. Owns table `quality__inspection_record`. No `ext`
      column in v1 — the aggregate is simple enough that adding Tier 1
      customization now would be speculation; it can be added in one
      edit when a customer asks for it.
    - `InspectionDecision` enum — APPROVED, REJECTED. Deliberately
      two-valued; see the entity KDoc for why "conditional accept" is
      rejected as a shape.
    - `InspectionRecordJpaRepository` — existsByCode, findByCode,
      findBySourceReference, findByItemCode.
    - `InspectionRecordService` — ONE write verb `record`. Inspections
      are immutable; revising means recording a new one with a new code.
      Validates:
        * code is unique
        * source reference non-blank
        * inspected quantity > 0
        * rejected quantity >= 0
        * rejected <= inspected
        * APPROVED ↔ rejected = 0, REJECTED ↔ rejected > 0
        * itemCode resolves via CatalogApi
      Inspector is read from `PrincipalContext.currentOrSystem()` at
      call time so a real HTTP user records their own inspections and
      a background job recording a batch uses a named system principal.
    - `InspectionRecordController` — `/api/v1/quality/inspections`
      with GET list (supports `?sourceReference=` and `?itemCode=`
      query params), GET by id, GET by-code, POST record. Every
      endpoint @RequirePermission-gated.
    - `META-INF/vibe-erp/metadata/quality.yml` — 1 entity, 2
      permissions (`quality.inspection.read`, `quality.inspection.record`),
      1 menu.
    - `distribution/.../db/changelog/pbc-quality/001-quality-init.xml`
      — single table with the full audit column set plus:
        * CHECK decision IN ('APPROVED', 'REJECTED')
        * CHECK inspected_quantity > 0
        * CHECK rejected_quantity >= 0
        * CHECK rejected_quantity <= inspected_quantity
      The application enforces the biconditional (APPROVED ↔ rejected=0)
      because CHECK constraints in Postgres can't express the same
      thing ergonomically; the DB enforces the weaker "rejected is
      within bounds" so a direct INSERT can't fabricate nonsense.
    - `settings.gradle.kts`, `distribution/build.gradle.kts`,
      `master.xml` all wired.
    
    ## Smoke test (fresh DB + running app, as admin)
    
    ```
    POST /api/v1/catalog/items {code: WIDGET-1, baseUomCode: ea}
      → 201
    
    POST /api/v1/quality/inspections
         {code: QC-2026-001, itemCode: WIDGET-1, sourceReference: "WO:WO-001",
          decision: APPROVED, inspectedQuantity: 100, rejectedQuantity: 0}
      → 201 {inspector: <admin principal uuid>, inspectedAt: "..."}
    
    POST /api/v1/quality/inspections
         {code: QC-2026-002, itemCode: WIDGET-1, sourceReference: "WO:WO-002",
          decision: REJECTED, inspectedQuantity: 50, rejectedQuantity: 7,
          reason: "surface scratches detected on 7 units"}
      → 201
    
    GET  /api/v1/quality/inspections?sourceReference=WO:WO-001
      → [{code: QC-2026-001, ...}]
    GET  /api/v1/quality/inspections?itemCode=WIDGET-1
      → [APPROVED, REJECTED]   ← filter works, 2 records
    
    # Negative: APPROVED with positive rejected
    POST /api/v1/quality/inspections
         {decision: APPROVED, rejectedQuantity: 3, ...}
      → 400 "APPROVED inspection must have rejected quantity = 0 (got 3);
             record a REJECTED inspection instead"
    
    # Negative: rejected > inspected
    POST /api/v1/quality/inspections
         {decision: REJECTED, inspectedQuantity: 5, rejectedQuantity: 10, ...}
      → 400 "rejected quantity (10) cannot exceed inspected (5)"
    
    GET  /api/v1/_meta/metadata
      → permissions include ["quality.inspection.read",
                              "quality.inspection.record"]
    ```
    
    The `inspector` field on the created records contains the admin
    user's principal UUID exactly as written by the
    `PrincipalContextFilter` — proving the audit trail end-to-end.
    
    ## Tests
    
    - 9 new unit tests in `InspectionRecordServiceTest`:
      * `record persists an APPROVED inspection with rejected=0`
      * `record persists a REJECTED inspection with positive rejected`
      * `inspector defaults to system when no principal is bound` —
        validates the `PrincipalContext.currentOrSystem()` fallback
      * `record rejects duplicate code`
      * `record rejects non-positive inspected quantity`
      * `record rejects rejected greater than inspected`
      * `APPROVED with positive rejected is rejected`
      * `REJECTED with zero rejected is rejected`
      * `record rejects unknown items via CatalogApi`
    - Total framework unit tests: 297 (was 288), all green.
    
    ## Framework state after this commit
    
    - **20 → 21 Gradle subprojects**
    - **10 of 10 core PBCs live** (pbc-identity, pbc-catalog, pbc-partners,
      pbc-inventory, pbc-warehousing, pbc-orders-sales, pbc-orders-purchase,
      pbc-finance, pbc-production, pbc-quality). The P5.x row of the
      implementation plan is complete at minimal v1 scope.
    - The v1.0 acceptance bar's "core PBC coverage" line is met. Remaining
      v1.0 work is cross-cutting (reports, forms, scheduler, web SPA)
      plus the richer per-PBC v2/v3 scopes.
    
    ## What this unblocks
    
    - **Cross-PBC quality integration** — any PBC that needs to react
      to a quality decision can subscribe when pbc-quality grows its
      event. pbc-warehousing quarantine on rejection is the obvious
      first consumer.
    - **The full buy-make-sell BPMN scenario** — now every step has a
      home: sales → procurement → warehousing → production → quality →
      finance are all live. The big reference-plug-in end-to-end
      flow is unblocked at the PBC level.
    - **Completes the P5.x row** of the implementation plan. Remaining
      v1.0 work is cross-cutting platform units (P1.8 reports, P1.9
      files, P1.10 jobs, P2.2/P2.3 designer/forms) plus the web SPA.
    zichun authored
     
    Browse Code »