Commit 4835785ef17d0f7ec75294b19cfe3932fe5bfd56

Authored by zichun
1 parent 5f80febb

feat(pbc-quality): P5.8 — InspectionRecord (10th and final core PBC) — core PBC count now 10/10

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.
distribution/build.gradle.kts
... ... @@ -36,6 +36,7 @@ dependencies {
36 36 implementation(project(":pbc:pbc-orders-purchase"))
37 37 implementation(project(":pbc:pbc-finance"))
38 38 implementation(project(":pbc:pbc-production"))
  39 + implementation(project(":pbc:pbc-quality"))
39 40  
40 41 implementation(libs.spring.boot.starter)
41 42 implementation(libs.spring.boot.starter.web)
... ...
distribution/src/main/resources/db/changelog/master.xml
... ... @@ -25,4 +25,5 @@
25 25 <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/>
26 26 <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/>
27 27 <include file="classpath:db/changelog/pbc-production/002-production-v2.xml"/>
  28 + <include file="classpath:db/changelog/pbc-quality/001-quality-init.xml"/>
28 29 </databaseChangeLog>
... ...
distribution/src/main/resources/db/changelog/pbc-quality/001-quality-init.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
  5 + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
  6 +
  7 + <!--
  8 + pbc-quality initial schema (P5.8 — v1 recording-only).
  9 +
  10 + Owns: quality__inspection_record.
  11 +
  12 + Append-only record of "item X was inspected under source
  13 + reference Y, decision was APPROVED/REJECTED, inspected/
  14 + rejected quantities, who recorded it". No FK to item or
  15 + source — cross-PBC references by code, validated at the
  16 + application layer (CatalogApi on record()).
  17 +
  18 + Constraints:
  19 + * decision IN (APPROVED, REJECTED)
  20 + * inspected_quantity > 0
  21 + * rejected_quantity >= 0
  22 + * rejected_quantity <= inspected_quantity
  23 + * decision=APPROVED ↔ rejected_quantity = 0
  24 + * decision=REJECTED ↔ rejected_quantity > 0
  25 + The application service enforces the biconditional; the
  26 + schema enforces the weaker "rejected is within bounds" rule
  27 + so a direct DB insert can't fabricate nonsense.
  28 + -->
  29 +
  30 + <changeSet id="quality-init-001" author="vibe_erp">
  31 + <comment>Create quality__inspection_record table</comment>
  32 + <sql>
  33 + CREATE TABLE quality__inspection_record (
  34 + id uuid PRIMARY KEY,
  35 + code varchar(64) NOT NULL,
  36 + item_code varchar(64) NOT NULL,
  37 + source_reference varchar(128) NOT NULL,
  38 + decision varchar(16) NOT NULL,
  39 + inspected_quantity numeric(18,4) NOT NULL,
  40 + rejected_quantity numeric(18,4) NOT NULL,
  41 + inspector varchar(128) NOT NULL,
  42 + reason varchar(512),
  43 + inspected_at timestamptz NOT NULL,
  44 + created_at timestamptz NOT NULL,
  45 + created_by varchar(128) NOT NULL,
  46 + updated_at timestamptz NOT NULL,
  47 + updated_by varchar(128) NOT NULL,
  48 + version bigint NOT NULL DEFAULT 0,
  49 + CONSTRAINT quality__inspection_record_decision_check
  50 + CHECK (decision IN ('APPROVED', 'REJECTED')),
  51 + CONSTRAINT quality__inspection_record_inspected_pos
  52 + CHECK (inspected_quantity &gt; 0),
  53 + CONSTRAINT quality__inspection_record_rejected_nonneg
  54 + CHECK (rejected_quantity &gt;= 0),
  55 + CONSTRAINT quality__inspection_record_rejected_bounded
  56 + CHECK (rejected_quantity &lt;= inspected_quantity)
  57 + );
  58 + CREATE UNIQUE INDEX quality__inspection_record_code_uk
  59 + ON quality__inspection_record (code);
  60 + CREATE INDEX quality__inspection_record_item_idx
  61 + ON quality__inspection_record (item_code);
  62 + CREATE INDEX quality__inspection_record_source_idx
  63 + ON quality__inspection_record (source_reference);
  64 + CREATE INDEX quality__inspection_record_decision_idx
  65 + ON quality__inspection_record (decision);
  66 + CREATE INDEX quality__inspection_record_inspected_at_idx
  67 + ON quality__inspection_record (inspected_at);
  68 + </sql>
  69 + <rollback>
  70 + DROP TABLE quality__inspection_record;
  71 + </rollback>
  72 + </changeSet>
  73 +
  74 +</databaseChangeLog>
... ...
pbc/pbc-quality/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.kotlin.jpa)
  5 + alias(libs.plugins.spring.dependency.management)
  6 +}
  7 +
  8 +description = "vibe_erp pbc-quality — minimal inspection-record aggregate (v1). " +
  9 + "Records inbound/in-process/outbound QC decisions against any source reference. INTERNAL PBC."
  10 +
  11 +java {
  12 + toolchain {
  13 + languageVersion.set(JavaLanguageVersion.of(21))
  14 + }
  15 +}
  16 +
  17 +kotlin {
  18 + jvmToolchain(21)
  19 + compilerOptions {
  20 + freeCompilerArgs.add("-Xjsr305=strict")
  21 + }
  22 +}
  23 +
  24 +allOpen {
  25 + annotation("jakarta.persistence.Entity")
  26 + annotation("jakarta.persistence.MappedSuperclass")
  27 + annotation("jakarta.persistence.Embeddable")
  28 +}
  29 +
  30 +// Tenth (and final) core PBC. Same dependency rule — api/api-v1 + platform/* only,
  31 +// NEVER another pbc-*. The v1 scope is recording-only: no cross-PBC writes, no event
  32 +// publishing, no reaction to existing events. Every consumer is downstream of this
  33 +// commit (future chunks may add "react to InspectionRecordedEvent" subscribers in
  34 +// pbc-warehousing for quarantine or in pbc-production for scrap).
  35 +dependencies {
  36 + api(project(":api:api-v1"))
  37 + implementation(project(":platform:platform-persistence"))
  38 + implementation(project(":platform:platform-security"))
  39 +
  40 + implementation(libs.kotlin.stdlib)
  41 + implementation(libs.kotlin.reflect)
  42 +
  43 + implementation(libs.spring.boot.starter)
  44 + implementation(libs.spring.boot.starter.web)
  45 + implementation(libs.spring.boot.starter.data.jpa)
  46 + implementation(libs.spring.boot.starter.validation)
  47 + implementation(libs.jackson.module.kotlin)
  48 +
  49 + testImplementation(libs.spring.boot.starter.test)
  50 + testImplementation(libs.junit.jupiter)
  51 + testImplementation(libs.assertk)
  52 + testImplementation(libs.mockk)
  53 +}
  54 +
  55 +tasks.test {
  56 + useJUnitPlatform()
  57 +}
... ...
pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordService.kt 0 → 100644
  1 +package org.vibeerp.pbc.quality.application
  2 +
  3 +import org.slf4j.LoggerFactory
  4 +import org.springframework.stereotype.Service
  5 +import org.springframework.transaction.annotation.Transactional
  6 +import org.vibeerp.api.v1.ext.catalog.CatalogApi
  7 +import org.vibeerp.pbc.quality.domain.InspectionDecision
  8 +import org.vibeerp.pbc.quality.domain.InspectionRecord
  9 +import org.vibeerp.pbc.quality.infrastructure.InspectionRecordJpaRepository
  10 +import org.vibeerp.platform.persistence.security.PrincipalContext
  11 +import java.math.BigDecimal
  12 +import java.time.Instant
  13 +import java.util.UUID
  14 +
  15 +/**
  16 + * Application service for [InspectionRecord]. The service has ONE
  17 + * write verb, [record], because inspections are immutable snapshots:
  18 + * once saved, they cannot be edited, only superseded by a NEW
  19 + * inspection of the same item with a new code. That matches how
  20 + * real QC paperwork is treated — you don't rewrite a failed
  21 + * inspection, you write a second one that says "re-inspected, now
  22 + * approved".
  23 + *
  24 + * **Cross-PBC seams** (all via `api.v1.ext.*`):
  25 + * - [CatalogApi] — validates that [itemCode] exists at record time.
  26 + * Same pattern pbc-warehousing and pbc-production use.
  27 + *
  28 + * **No event publishing yet.** See the KDoc on [InspectionRecord]
  29 + * for the YAGNI rationale; a consumer-driven follow-up chunk will
  30 + * add `InspectionRecordedEvent` in `api.v1.event.quality.*` when a
  31 + * subscriber actually needs it.
  32 + *
  33 + * **Inspector** is taken from [PrincipalContext.currentOrSystem] at
  34 + * call time so the recorded inspector is always the authenticated
  35 + * caller. A service-impersonation (background job) can run inside
  36 + * `PrincipalContext.runAs("system:quality-import")` to get a
  37 + * well-named system inspector.
  38 + */
  39 +@Service
  40 +@Transactional
  41 +class InspectionRecordService(
  42 + private val inspections: InspectionRecordJpaRepository,
  43 + private val catalogApi: CatalogApi,
  44 +) {
  45 + private val log = LoggerFactory.getLogger(InspectionRecordService::class.java)
  46 +
  47 + @Transactional(readOnly = true)
  48 + fun list(): List<InspectionRecord> = inspections.findAll()
  49 +
  50 + @Transactional(readOnly = true)
  51 + fun findById(id: UUID): InspectionRecord? = inspections.findById(id).orElse(null)
  52 +
  53 + @Transactional(readOnly = true)
  54 + fun findByCode(code: String): InspectionRecord? = inspections.findByCode(code)
  55 +
  56 + @Transactional(readOnly = true)
  57 + fun findBySourceReference(sourceReference: String): List<InspectionRecord> =
  58 + inspections.findBySourceReference(sourceReference)
  59 +
  60 + @Transactional(readOnly = true)
  61 + fun findByItemCode(itemCode: String): List<InspectionRecord> =
  62 + inspections.findByItemCode(itemCode)
  63 +
  64 + /**
  65 + * Record a new inspection. Validates: code uniqueness, non-blank
  66 + * source reference, positive inspected quantity, non-negative
  67 + * rejected quantity, rejected <= inspected, decision consistent
  68 + * with rejected quantity (APPROVED ⇒ rejected=0, REJECTED ⇒
  69 + * rejected>0), and item present in the catalog. The inspector is
  70 + * read from [PrincipalContext] at call time (falls back to
  71 + * `__system__` for unauthenticated service code).
  72 + */
  73 + fun record(command: RecordInspectionCommand): InspectionRecord {
  74 + require(!inspections.existsByCode(command.code)) {
  75 + "inspection code '${command.code}' is already taken"
  76 + }
  77 + require(command.sourceReference.isNotBlank()) {
  78 + "inspection source reference must not be blank"
  79 + }
  80 + require(command.inspectedQuantity.signum() > 0) {
  81 + "inspected quantity must be positive (got ${command.inspectedQuantity})"
  82 + }
  83 + require(command.rejectedQuantity.signum() >= 0) {
  84 + "rejected quantity must not be negative (got ${command.rejectedQuantity})"
  85 + }
  86 + require(command.rejectedQuantity <= command.inspectedQuantity) {
  87 + "rejected quantity (${command.rejectedQuantity}) cannot exceed inspected " +
  88 + "(${command.inspectedQuantity})"
  89 + }
  90 + when (command.decision) {
  91 + InspectionDecision.APPROVED -> require(command.rejectedQuantity.signum() == 0) {
  92 + "APPROVED inspection must have rejected quantity = 0 " +
  93 + "(got ${command.rejectedQuantity}); record a REJECTED inspection instead"
  94 + }
  95 + InspectionDecision.REJECTED -> require(command.rejectedQuantity.signum() > 0) {
  96 + "REJECTED inspection must have rejected quantity > 0; " +
  97 + "record an APPROVED inspection if nothing was rejected"
  98 + }
  99 + }
  100 + catalogApi.findItemByCode(command.itemCode)
  101 + ?: throw IllegalArgumentException(
  102 + "inspection item code '${command.itemCode}' is not in the catalog (or is inactive)",
  103 + )
  104 +
  105 + val record = InspectionRecord(
  106 + code = command.code,
  107 + itemCode = command.itemCode,
  108 + sourceReference = command.sourceReference,
  109 + decision = command.decision,
  110 + inspectedQuantity = command.inspectedQuantity,
  111 + rejectedQuantity = command.rejectedQuantity,
  112 + inspector = PrincipalContext.currentOrSystem(),
  113 + reason = command.reason,
  114 + inspectedAt = Instant.now(),
  115 + )
  116 + val saved = inspections.save(record)
  117 +
  118 + log.info(
  119 + "[quality] recorded inspection {} decision={} item={} source={} " +
  120 + "inspected={} rejected={} inspector={}",
  121 + saved.code, saved.decision, saved.itemCode, saved.sourceReference,
  122 + saved.inspectedQuantity, saved.rejectedQuantity, saved.inspector,
  123 + )
  124 + return saved
  125 + }
  126 +}
  127 +
  128 +data class RecordInspectionCommand(
  129 + val code: String,
  130 + val itemCode: String,
  131 + val sourceReference: String,
  132 + val decision: InspectionDecision,
  133 + val inspectedQuantity: BigDecimal,
  134 + val rejectedQuantity: BigDecimal,
  135 + val reason: String? = null,
  136 +)
... ...
pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/domain/InspectionRecord.kt 0 → 100644
  1 +package org.vibeerp.pbc.quality.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.EnumType
  6 +import jakarta.persistence.Enumerated
  7 +import jakarta.persistence.Table
  8 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  9 +import java.math.BigDecimal
  10 +import java.time.Instant
  11 +
  12 +/**
  13 + * A single quality-inspection decision captured at a checkpoint.
  14 + *
  15 + * **The v1 scope is DELIBERATELY recording-only.** pbc-quality's
  16 + * first incarnation does not publish events, does not write back to
  17 + * pbc-inventory or pbc-warehousing, and does not enforce a downstream
  18 + * "rejected stock must be quarantined" rule. It is a first-class,
  19 + * queryable record of "who inspected what, when, and whether it
  20 + * passed". Any side effect (stock quarantine on rejection, work-order
  21 + * scrap on finished-goods rejection, partner quality KPI) lives
  22 + * downstream in a follow-up chunk that subscribes to an
  23 + * `InspectionRecordedEvent` that this PBC will start publishing when
  24 + * a consumer appears. YAGNI: building the event now without a
  25 + * consumer to exercise it would be pure speculation about the shape.
  26 + *
  27 + * **The three inspection contexts** pbc-quality recognizes via
  28 + * [sourceReference] are all free-form strings — no enum:
  29 + * - **Inbound** (PO receipt): `PO:PO-2026-0001:L2` — the purchase
  30 + * order line this inspection relates to
  31 + * - **In-process** (work-order output): `WO:WO-FROM-SO-2026-0001-L1`
  32 + * — the work order whose output was inspected
  33 + * - **Outbound** (SO shipment): `SO:SO-2026-0007:L3`
  34 + *
  35 + * The format mirrors the `reference` convention the inventory ledger
  36 + * already uses (`WO:<code>`, `SO:<code>`, `PO:<code>`, `TR:<code>`),
  37 + * so an auditor can grep both the ledger and the inspection table
  38 + * with the same filter.
  39 + *
  40 + * **Why [inspectedQuantity] and [rejectedQuantity] are BOTH stored**
  41 + * (rather than storing inspected and computing rejected on demand):
  42 + * a future report needs both numbers per inspection AND a running
  43 + * rejection rate per item/per inspector; doing that without storing
  44 + * both would mean rebuilding the rejected count from the decision
  45 + * field every time, which is what the column exists to prevent.
  46 + * `APPROVED` records have [rejectedQuantity] = 0; `REJECTED`
  47 + * records have [rejectedQuantity] > 0 and <= [inspectedQuantity].
  48 + *
  49 + * **Why [decision] is enum-stored-as-string but [sourceReference] is
  50 + * not:** decision has a closed set (APPROVED / REJECTED) that the
  51 + * framework wants to switch on in code; source reference is a
  52 + * free-form link into another aggregate's code space and the closed
  53 + * set is unknown (new sources will land as new PBCs ship).
  54 + *
  55 + * **Why [inspector] is a plain String** (principal id rendered as
  56 + * text) rather than a typed `PrincipalId`: the JPA column carries
  57 + * the audit-layer convention, same as `created_by` / `updated_by`
  58 + * on every other entity. The application sets it from
  59 + * `SecurityContextHolder` via the controller when a real user
  60 + * records an inspection.
  61 + */
  62 +@Entity
  63 +@Table(name = "quality__inspection_record")
  64 +class InspectionRecord(
  65 + code: String,
  66 + itemCode: String,
  67 + sourceReference: String,
  68 + decision: InspectionDecision,
  69 + inspectedQuantity: BigDecimal,
  70 + rejectedQuantity: BigDecimal,
  71 + inspector: String,
  72 + reason: String? = null,
  73 + inspectedAt: Instant = Instant.now(),
  74 +) : AuditedJpaEntity() {
  75 +
  76 + @Column(name = "code", nullable = false, length = 64)
  77 + var code: String = code
  78 +
  79 + @Column(name = "item_code", nullable = false, length = 64)
  80 + var itemCode: String = itemCode
  81 +
  82 + @Column(name = "source_reference", nullable = false, length = 128)
  83 + var sourceReference: String = sourceReference
  84 +
  85 + @Enumerated(EnumType.STRING)
  86 + @Column(name = "decision", nullable = false, length = 16)
  87 + var decision: InspectionDecision = decision
  88 +
  89 + @Column(name = "inspected_quantity", nullable = false, precision = 18, scale = 4)
  90 + var inspectedQuantity: BigDecimal = inspectedQuantity
  91 +
  92 + @Column(name = "rejected_quantity", nullable = false, precision = 18, scale = 4)
  93 + var rejectedQuantity: BigDecimal = rejectedQuantity
  94 +
  95 + @Column(name = "inspector", nullable = false, length = 128)
  96 + var inspector: String = inspector
  97 +
  98 + @Column(name = "reason", nullable = true, length = 512)
  99 + var reason: String? = reason
  100 +
  101 + @Column(name = "inspected_at", nullable = false)
  102 + var inspectedAt: Instant = inspectedAt
  103 +
  104 + override fun toString(): String =
  105 + "InspectionRecord(id=$id, code='$code', item='$itemCode', decision=$decision, " +
  106 + "inspected=$inspectedQuantity, rejected=$rejectedQuantity, ref='$sourceReference')"
  107 +}
  108 +
  109 +/**
  110 + * Inspection outcome. A closed set with exactly two states; the
  111 + * framework refuses to grow a "CONDITIONAL_ACCEPT" or similar because
  112 + * that turns the query "is this batch good?" into a three-valued
  113 + * logic problem downstream. If a batch needs conditional acceptance,
  114 + * record two inspections: a REJECTED one for the bad portion and an
  115 + * APPROVED one for the accepted portion.
  116 + */
  117 +enum class InspectionDecision {
  118 + APPROVED,
  119 + REJECTED,
  120 +}
... ...
pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/http/InspectionRecordController.kt 0 → 100644
  1 +package org.vibeerp.pbc.quality.http
  2 +
  3 +import jakarta.validation.Valid
  4 +import jakarta.validation.constraints.NotBlank
  5 +import jakarta.validation.constraints.NotNull
  6 +import jakarta.validation.constraints.Size
  7 +import org.springframework.http.HttpStatus
  8 +import org.springframework.http.ResponseEntity
  9 +import org.springframework.web.bind.annotation.GetMapping
  10 +import org.springframework.web.bind.annotation.PathVariable
  11 +import org.springframework.web.bind.annotation.PostMapping
  12 +import org.springframework.web.bind.annotation.RequestBody
  13 +import org.springframework.web.bind.annotation.RequestMapping
  14 +import org.springframework.web.bind.annotation.RequestParam
  15 +import org.springframework.web.bind.annotation.ResponseStatus
  16 +import org.springframework.web.bind.annotation.RestController
  17 +import org.vibeerp.pbc.quality.application.InspectionRecordService
  18 +import org.vibeerp.pbc.quality.application.RecordInspectionCommand
  19 +import org.vibeerp.pbc.quality.domain.InspectionDecision
  20 +import org.vibeerp.pbc.quality.domain.InspectionRecord
  21 +import org.vibeerp.platform.security.authz.RequirePermission
  22 +import java.math.BigDecimal
  23 +import java.time.Instant
  24 +import java.util.UUID
  25 +
  26 +/**
  27 + * REST API for the quality PBC. Mounted at
  28 + * `/api/v1/quality/inspections`. Inspections are append-only: there
  29 + * is no update or delete endpoint. To "revise" an inspection, record
  30 + * a new one with a new code (convention: suffix `-R1`, `-R2`, etc.).
  31 + */
  32 +@RestController
  33 +@RequestMapping("/api/v1/quality/inspections")
  34 +class InspectionRecordController(
  35 + private val service: InspectionRecordService,
  36 +) {
  37 +
  38 + @GetMapping
  39 + @RequirePermission("quality.inspection.read")
  40 + fun list(
  41 + @RequestParam("sourceReference", required = false) sourceReference: String?,
  42 + @RequestParam("itemCode", required = false) itemCode: String?,
  43 + ): List<InspectionRecordResponse> = when {
  44 + !sourceReference.isNullOrBlank() -> service.findBySourceReference(sourceReference).map { it.toResponse() }
  45 + !itemCode.isNullOrBlank() -> service.findByItemCode(itemCode).map { it.toResponse() }
  46 + else -> service.list().map { it.toResponse() }
  47 + }
  48 +
  49 + @GetMapping("/{id}")
  50 + @RequirePermission("quality.inspection.read")
  51 + fun get(@PathVariable id: UUID): ResponseEntity<InspectionRecordResponse> {
  52 + val record = service.findById(id) ?: return ResponseEntity.notFound().build()
  53 + return ResponseEntity.ok(record.toResponse())
  54 + }
  55 +
  56 + @GetMapping("/by-code/{code}")
  57 + @RequirePermission("quality.inspection.read")
  58 + fun getByCode(@PathVariable code: String): ResponseEntity<InspectionRecordResponse> {
  59 + val record = service.findByCode(code) ?: return ResponseEntity.notFound().build()
  60 + return ResponseEntity.ok(record.toResponse())
  61 + }
  62 +
  63 + @PostMapping
  64 + @ResponseStatus(HttpStatus.CREATED)
  65 + @RequirePermission("quality.inspection.record")
  66 + fun record(@RequestBody @Valid request: RecordInspectionRequest): InspectionRecordResponse =
  67 + service.record(request.toCommand()).toResponse()
  68 +}
  69 +
  70 +// ─── DTOs ────────────────────────────────────────────────────────────
  71 +
  72 +data class RecordInspectionRequest(
  73 + @field:NotBlank @field:Size(max = 64) val code: String,
  74 + @field:NotBlank @field:Size(max = 64) val itemCode: String,
  75 + @field:NotBlank @field:Size(max = 128) val sourceReference: String,
  76 + @field:NotNull val decision: InspectionDecision,
  77 + @field:NotNull val inspectedQuantity: BigDecimal,
  78 + @field:NotNull val rejectedQuantity: BigDecimal,
  79 + @field:Size(max = 512) val reason: String? = null,
  80 +) {
  81 + fun toCommand(): RecordInspectionCommand = RecordInspectionCommand(
  82 + code = code,
  83 + itemCode = itemCode,
  84 + sourceReference = sourceReference,
  85 + decision = decision,
  86 + inspectedQuantity = inspectedQuantity,
  87 + rejectedQuantity = rejectedQuantity,
  88 + reason = reason,
  89 + )
  90 +}
  91 +
  92 +data class InspectionRecordResponse(
  93 + val id: UUID,
  94 + val code: String,
  95 + val itemCode: String,
  96 + val sourceReference: String,
  97 + val decision: InspectionDecision,
  98 + val inspectedQuantity: BigDecimal,
  99 + val rejectedQuantity: BigDecimal,
  100 + val inspector: String,
  101 + val reason: String?,
  102 + val inspectedAt: Instant,
  103 +)
  104 +
  105 +private fun InspectionRecord.toResponse(): InspectionRecordResponse =
  106 + InspectionRecordResponse(
  107 + id = id,
  108 + code = code,
  109 + itemCode = itemCode,
  110 + sourceReference = sourceReference,
  111 + decision = decision,
  112 + inspectedQuantity = inspectedQuantity,
  113 + rejectedQuantity = rejectedQuantity,
  114 + inspector = inspector,
  115 + reason = reason,
  116 + inspectedAt = inspectedAt,
  117 + )
... ...
pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/infrastructure/InspectionRecordJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.quality.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.vibeerp.pbc.quality.domain.InspectionRecord
  5 +import java.util.UUID
  6 +
  7 +interface InspectionRecordJpaRepository : JpaRepository<InspectionRecord, UUID> {
  8 + fun existsByCode(code: String): Boolean
  9 + fun findByCode(code: String): InspectionRecord?
  10 + fun findBySourceReference(sourceReference: String): List<InspectionRecord>
  11 + fun findByItemCode(itemCode: String): List<InspectionRecord>
  12 +}
... ...
pbc/pbc-quality/src/main/resources/META-INF/vibe-erp/metadata/quality.yml 0 → 100644
  1 +# pbc-quality metadata.
  2 +#
  3 +# Loaded at boot by MetadataLoader, tagged source='core'.
  4 +
  5 +entities:
  6 + - name: InspectionRecord
  7 + pbc: quality
  8 + table: quality__inspection_record
  9 + description: An append-only QC decision record (APPROVED or REJECTED) captured at any inspection checkpoint, linked to a free-form source reference
  10 +
  11 +permissions:
  12 + - key: quality.inspection.read
  13 + description: Read inspection records
  14 + - key: quality.inspection.record
  15 + description: Record a new inspection (append-only)
  16 +
  17 +menus:
  18 + - path: /quality/inspections
  19 + label: Inspections
  20 + icon: clipboard-check
  21 + section: Quality
  22 + order: 600
... ...
pbc/pbc-quality/src/test/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.quality.application
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.hasMessage
  6 +import assertk.assertions.isEqualTo
  7 +import assertk.assertions.isInstanceOf
  8 +import io.mockk.every
  9 +import io.mockk.mockk
  10 +import io.mockk.slot
  11 +import org.junit.jupiter.api.AfterEach
  12 +import org.junit.jupiter.api.Test
  13 +import org.vibeerp.api.v1.core.Id
  14 +import org.vibeerp.api.v1.ext.catalog.CatalogApi
  15 +import org.vibeerp.api.v1.ext.catalog.ItemRef
  16 +import org.vibeerp.pbc.quality.domain.InspectionDecision
  17 +import org.vibeerp.pbc.quality.domain.InspectionRecord
  18 +import org.vibeerp.pbc.quality.infrastructure.InspectionRecordJpaRepository
  19 +import org.vibeerp.platform.persistence.security.PrincipalContext
  20 +import java.math.BigDecimal
  21 +import java.util.UUID
  22 +
  23 +class InspectionRecordServiceTest {
  24 +
  25 + private val repo: InspectionRecordJpaRepository = mockk(relaxed = true)
  26 + private val catalog: CatalogApi = mockk()
  27 + private val service = InspectionRecordService(repo, catalog)
  28 +
  29 + @AfterEach
  30 + fun clearContext() {
  31 + PrincipalContext.clear()
  32 + }
  33 +
  34 + private fun stubItemExists(code: String) {
  35 + every { catalog.findItemByCode(code) } returns ItemRef(
  36 + id = Id(UUID.randomUUID()),
  37 + code = code,
  38 + name = "fake",
  39 + itemType = "GOOD",
  40 + baseUomCode = "ea",
  41 + active = true,
  42 + )
  43 + }
  44 +
  45 + private fun cmd(
  46 + code: String = "QC-001",
  47 + decision: InspectionDecision = InspectionDecision.APPROVED,
  48 + inspected: String = "100",
  49 + rejected: String = "0",
  50 + reason: String? = null,
  51 + ) = RecordInspectionCommand(
  52 + code = code,
  53 + itemCode = "ITEM-1",
  54 + sourceReference = "WO:WO-2026-001",
  55 + decision = decision,
  56 + inspectedQuantity = BigDecimal(inspected),
  57 + rejectedQuantity = BigDecimal(rejected),
  58 + reason = reason,
  59 + )
  60 +
  61 + @Test
  62 + fun `record persists an APPROVED inspection with rejected=0`() {
  63 + every { repo.existsByCode("QC-001") } returns false
  64 + stubItemExists("ITEM-1")
  65 + val saved = slot<InspectionRecord>()
  66 + every { repo.save(capture(saved)) } answers { saved.captured }
  67 +
  68 + PrincipalContext.set("user-42")
  69 + val result = service.record(cmd())
  70 +
  71 + assertThat(result.code).isEqualTo("QC-001")
  72 + assertThat(result.decision).isEqualTo(InspectionDecision.APPROVED)
  73 + assertThat(result.inspectedQuantity).isEqualTo(BigDecimal("100"))
  74 + assertThat(result.rejectedQuantity).isEqualTo(BigDecimal("0"))
  75 + assertThat(result.inspector).isEqualTo("user-42")
  76 + }
  77 +
  78 + @Test
  79 + fun `record persists a REJECTED inspection with positive rejected`() {
  80 + every { repo.existsByCode("QC-002") } returns false
  81 + stubItemExists("ITEM-1")
  82 + every { repo.save(any<InspectionRecord>()) } answers { firstArg() }
  83 +
  84 + val result = service.record(
  85 + cmd(
  86 + code = "QC-002",
  87 + decision = InspectionDecision.REJECTED,
  88 + inspected = "100",
  89 + rejected = "15",
  90 + reason = "surface scratches",
  91 + ),
  92 + )
  93 + assertThat(result.decision).isEqualTo(InspectionDecision.REJECTED)
  94 + assertThat(result.rejectedQuantity).isEqualTo(BigDecimal("15"))
  95 + assertThat(result.reason).isEqualTo("surface scratches")
  96 + }
  97 +
  98 + @Test
  99 + fun `inspector defaults to system when no principal is bound`() {
  100 + every { repo.existsByCode(any()) } returns false
  101 + stubItemExists("ITEM-1")
  102 + every { repo.save(any<InspectionRecord>()) } answers { firstArg() }
  103 +
  104 + val result = service.record(cmd())
  105 + assertThat(result.inspector).isEqualTo("__system__")
  106 + }
  107 +
  108 + @Test
  109 + fun `record rejects duplicate code`() {
  110 + every { repo.existsByCode("QC-dup") } returns true
  111 +
  112 + assertFailure { service.record(cmd(code = "QC-dup")) }
  113 + .isInstanceOf(IllegalArgumentException::class)
  114 + .hasMessage("inspection code 'QC-dup' is already taken")
  115 + }
  116 +
  117 + @Test
  118 + fun `record rejects non-positive inspected quantity`() {
  119 + every { repo.existsByCode(any()) } returns false
  120 +
  121 + assertFailure { service.record(cmd(inspected = "0", rejected = "0")) }
  122 + .isInstanceOf(IllegalArgumentException::class)
  123 + }
  124 +
  125 + @Test
  126 + fun `record rejects rejected greater than inspected`() {
  127 + every { repo.existsByCode(any()) } returns false
  128 +
  129 + assertFailure {
  130 + service.record(
  131 + cmd(decision = InspectionDecision.REJECTED, inspected = "10", rejected = "20"),
  132 + )
  133 + }.isInstanceOf(IllegalArgumentException::class)
  134 + }
  135 +
  136 + @Test
  137 + fun `APPROVED with positive rejected is rejected`() {
  138 + every { repo.existsByCode(any()) } returns false
  139 +
  140 + assertFailure {
  141 + service.record(
  142 + cmd(decision = InspectionDecision.APPROVED, rejected = "5"),
  143 + )
  144 + }.isInstanceOf(IllegalArgumentException::class)
  145 + }
  146 +
  147 + @Test
  148 + fun `REJECTED with zero rejected is rejected`() {
  149 + every { repo.existsByCode(any()) } returns false
  150 +
  151 + assertFailure {
  152 + service.record(
  153 + cmd(decision = InspectionDecision.REJECTED, inspected = "10", rejected = "0"),
  154 + )
  155 + }.isInstanceOf(IllegalArgumentException::class)
  156 + }
  157 +
  158 + @Test
  159 + fun `record rejects unknown items via CatalogApi`() {
  160 + every { repo.existsByCode(any()) } returns false
  161 + every { catalog.findItemByCode("ITEM-1") } returns null
  162 +
  163 + assertFailure { service.record(cmd()) }
  164 + .isInstanceOf(IllegalArgumentException::class)
  165 + }
  166 +}
... ...
settings.gradle.kts
... ... @@ -61,6 +61,9 @@ project(&quot;:pbc:pbc-inventory&quot;).projectDir = file(&quot;pbc/pbc-inventory&quot;)
61 61 include(":pbc:pbc-warehousing")
62 62 project(":pbc:pbc-warehousing").projectDir = file("pbc/pbc-warehousing")
63 63  
  64 +include(":pbc:pbc-quality")
  65 +project(":pbc:pbc-quality").projectDir = file("pbc/pbc-quality")
  66 +
64 67 include(":pbc:pbc-orders-sales")
65 68 project(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales")
66 69  
... ...