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.