-
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.