diff --git a/distribution/build.gradle.kts b/distribution/build.gradle.kts
index 6bc0553..b7d2d7b 100644
--- a/distribution/build.gradle.kts
+++ b/distribution/build.gradle.kts
@@ -36,6 +36,7 @@ dependencies {
implementation(project(":pbc:pbc-orders-purchase"))
implementation(project(":pbc:pbc-finance"))
implementation(project(":pbc:pbc-production"))
+ implementation(project(":pbc:pbc-quality"))
implementation(libs.spring.boot.starter)
implementation(libs.spring.boot.starter.web)
diff --git a/distribution/src/main/resources/db/changelog/master.xml b/distribution/src/main/resources/db/changelog/master.xml
index b13069b..db7f413 100644
--- a/distribution/src/main/resources/db/changelog/master.xml
+++ b/distribution/src/main/resources/db/changelog/master.xml
@@ -25,4 +25,5 @@
+
diff --git a/distribution/src/main/resources/db/changelog/pbc-quality/001-quality-init.xml b/distribution/src/main/resources/db/changelog/pbc-quality/001-quality-init.xml
new file mode 100644
index 0000000..9546ab9
--- /dev/null
+++ b/distribution/src/main/resources/db/changelog/pbc-quality/001-quality-init.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+ Create quality__inspection_record table
+
+ CREATE TABLE quality__inspection_record (
+ id uuid PRIMARY KEY,
+ code varchar(64) NOT NULL,
+ item_code varchar(64) NOT NULL,
+ source_reference varchar(128) NOT NULL,
+ decision varchar(16) NOT NULL,
+ inspected_quantity numeric(18,4) NOT NULL,
+ rejected_quantity numeric(18,4) NOT NULL,
+ inspector varchar(128) NOT NULL,
+ reason varchar(512),
+ inspected_at timestamptz NOT NULL,
+ created_at timestamptz NOT NULL,
+ created_by varchar(128) NOT NULL,
+ updated_at timestamptz NOT NULL,
+ updated_by varchar(128) NOT NULL,
+ version bigint NOT NULL DEFAULT 0,
+ CONSTRAINT quality__inspection_record_decision_check
+ CHECK (decision IN ('APPROVED', 'REJECTED')),
+ CONSTRAINT quality__inspection_record_inspected_pos
+ CHECK (inspected_quantity > 0),
+ CONSTRAINT quality__inspection_record_rejected_nonneg
+ CHECK (rejected_quantity >= 0),
+ CONSTRAINT quality__inspection_record_rejected_bounded
+ CHECK (rejected_quantity <= inspected_quantity)
+ );
+ CREATE UNIQUE INDEX quality__inspection_record_code_uk
+ ON quality__inspection_record (code);
+ CREATE INDEX quality__inspection_record_item_idx
+ ON quality__inspection_record (item_code);
+ CREATE INDEX quality__inspection_record_source_idx
+ ON quality__inspection_record (source_reference);
+ CREATE INDEX quality__inspection_record_decision_idx
+ ON quality__inspection_record (decision);
+ CREATE INDEX quality__inspection_record_inspected_at_idx
+ ON quality__inspection_record (inspected_at);
+
+
+ DROP TABLE quality__inspection_record;
+
+
+
+
diff --git a/pbc/pbc-quality/build.gradle.kts b/pbc/pbc-quality/build.gradle.kts
new file mode 100644
index 0000000..e3acb49
--- /dev/null
+++ b/pbc/pbc-quality/build.gradle.kts
@@ -0,0 +1,57 @@
+plugins {
+ alias(libs.plugins.kotlin.jvm)
+ alias(libs.plugins.kotlin.spring)
+ alias(libs.plugins.kotlin.jpa)
+ alias(libs.plugins.spring.dependency.management)
+}
+
+description = "vibe_erp pbc-quality — minimal inspection-record aggregate (v1). " +
+ "Records inbound/in-process/outbound QC decisions against any source reference. INTERNAL PBC."
+
+java {
+ toolchain {
+ languageVersion.set(JavaLanguageVersion.of(21))
+ }
+}
+
+kotlin {
+ jvmToolchain(21)
+ compilerOptions {
+ freeCompilerArgs.add("-Xjsr305=strict")
+ }
+}
+
+allOpen {
+ annotation("jakarta.persistence.Entity")
+ annotation("jakarta.persistence.MappedSuperclass")
+ annotation("jakarta.persistence.Embeddable")
+}
+
+// Tenth (and final) core PBC. Same dependency rule — api/api-v1 + platform/* only,
+// NEVER another pbc-*. The v1 scope is recording-only: no cross-PBC writes, no event
+// publishing, no reaction to existing events. Every consumer is downstream of this
+// commit (future chunks may add "react to InspectionRecordedEvent" subscribers in
+// pbc-warehousing for quarantine or in pbc-production for scrap).
+dependencies {
+ api(project(":api:api-v1"))
+ implementation(project(":platform:platform-persistence"))
+ implementation(project(":platform:platform-security"))
+
+ implementation(libs.kotlin.stdlib)
+ implementation(libs.kotlin.reflect)
+
+ implementation(libs.spring.boot.starter)
+ implementation(libs.spring.boot.starter.web)
+ implementation(libs.spring.boot.starter.data.jpa)
+ implementation(libs.spring.boot.starter.validation)
+ implementation(libs.jackson.module.kotlin)
+
+ testImplementation(libs.spring.boot.starter.test)
+ testImplementation(libs.junit.jupiter)
+ testImplementation(libs.assertk)
+ testImplementation(libs.mockk)
+}
+
+tasks.test {
+ useJUnitPlatform()
+}
diff --git a/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordService.kt b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordService.kt
new file mode 100644
index 0000000..699cd4e
--- /dev/null
+++ b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordService.kt
@@ -0,0 +1,136 @@
+package org.vibeerp.pbc.quality.application
+
+import org.slf4j.LoggerFactory
+import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional
+import org.vibeerp.api.v1.ext.catalog.CatalogApi
+import org.vibeerp.pbc.quality.domain.InspectionDecision
+import org.vibeerp.pbc.quality.domain.InspectionRecord
+import org.vibeerp.pbc.quality.infrastructure.InspectionRecordJpaRepository
+import org.vibeerp.platform.persistence.security.PrincipalContext
+import java.math.BigDecimal
+import java.time.Instant
+import java.util.UUID
+
+/**
+ * Application service for [InspectionRecord]. The service has ONE
+ * write verb, [record], because inspections are immutable snapshots:
+ * once saved, they cannot be edited, only superseded by a NEW
+ * inspection of the same item with a new code. That matches how
+ * real QC paperwork is treated — you don't rewrite a failed
+ * inspection, you write a second one that says "re-inspected, now
+ * approved".
+ *
+ * **Cross-PBC seams** (all via `api.v1.ext.*`):
+ * - [CatalogApi] — validates that [itemCode] exists at record time.
+ * Same pattern pbc-warehousing and pbc-production use.
+ *
+ * **No event publishing yet.** See the KDoc on [InspectionRecord]
+ * for the YAGNI rationale; a consumer-driven follow-up chunk will
+ * add `InspectionRecordedEvent` in `api.v1.event.quality.*` when a
+ * subscriber actually needs it.
+ *
+ * **Inspector** is taken from [PrincipalContext.currentOrSystem] at
+ * call time so the recorded inspector is always the authenticated
+ * caller. A service-impersonation (background job) can run inside
+ * `PrincipalContext.runAs("system:quality-import")` to get a
+ * well-named system inspector.
+ */
+@Service
+@Transactional
+class InspectionRecordService(
+ private val inspections: InspectionRecordJpaRepository,
+ private val catalogApi: CatalogApi,
+) {
+ private val log = LoggerFactory.getLogger(InspectionRecordService::class.java)
+
+ @Transactional(readOnly = true)
+ fun list(): List = inspections.findAll()
+
+ @Transactional(readOnly = true)
+ fun findById(id: UUID): InspectionRecord? = inspections.findById(id).orElse(null)
+
+ @Transactional(readOnly = true)
+ fun findByCode(code: String): InspectionRecord? = inspections.findByCode(code)
+
+ @Transactional(readOnly = true)
+ fun findBySourceReference(sourceReference: String): List =
+ inspections.findBySourceReference(sourceReference)
+
+ @Transactional(readOnly = true)
+ fun findByItemCode(itemCode: String): List =
+ inspections.findByItemCode(itemCode)
+
+ /**
+ * Record a new inspection. Validates: code uniqueness, non-blank
+ * source reference, positive inspected quantity, non-negative
+ * rejected quantity, rejected <= inspected, decision consistent
+ * with rejected quantity (APPROVED ⇒ rejected=0, REJECTED ⇒
+ * rejected>0), and item present in the catalog. The inspector is
+ * read from [PrincipalContext] at call time (falls back to
+ * `__system__` for unauthenticated service code).
+ */
+ fun record(command: RecordInspectionCommand): InspectionRecord {
+ require(!inspections.existsByCode(command.code)) {
+ "inspection code '${command.code}' is already taken"
+ }
+ require(command.sourceReference.isNotBlank()) {
+ "inspection source reference must not be blank"
+ }
+ require(command.inspectedQuantity.signum() > 0) {
+ "inspected quantity must be positive (got ${command.inspectedQuantity})"
+ }
+ require(command.rejectedQuantity.signum() >= 0) {
+ "rejected quantity must not be negative (got ${command.rejectedQuantity})"
+ }
+ require(command.rejectedQuantity <= command.inspectedQuantity) {
+ "rejected quantity (${command.rejectedQuantity}) cannot exceed inspected " +
+ "(${command.inspectedQuantity})"
+ }
+ when (command.decision) {
+ InspectionDecision.APPROVED -> require(command.rejectedQuantity.signum() == 0) {
+ "APPROVED inspection must have rejected quantity = 0 " +
+ "(got ${command.rejectedQuantity}); record a REJECTED inspection instead"
+ }
+ InspectionDecision.REJECTED -> require(command.rejectedQuantity.signum() > 0) {
+ "REJECTED inspection must have rejected quantity > 0; " +
+ "record an APPROVED inspection if nothing was rejected"
+ }
+ }
+ catalogApi.findItemByCode(command.itemCode)
+ ?: throw IllegalArgumentException(
+ "inspection item code '${command.itemCode}' is not in the catalog (or is inactive)",
+ )
+
+ val record = InspectionRecord(
+ code = command.code,
+ itemCode = command.itemCode,
+ sourceReference = command.sourceReference,
+ decision = command.decision,
+ inspectedQuantity = command.inspectedQuantity,
+ rejectedQuantity = command.rejectedQuantity,
+ inspector = PrincipalContext.currentOrSystem(),
+ reason = command.reason,
+ inspectedAt = Instant.now(),
+ )
+ val saved = inspections.save(record)
+
+ log.info(
+ "[quality] recorded inspection {} decision={} item={} source={} " +
+ "inspected={} rejected={} inspector={}",
+ saved.code, saved.decision, saved.itemCode, saved.sourceReference,
+ saved.inspectedQuantity, saved.rejectedQuantity, saved.inspector,
+ )
+ return saved
+ }
+}
+
+data class RecordInspectionCommand(
+ val code: String,
+ val itemCode: String,
+ val sourceReference: String,
+ val decision: InspectionDecision,
+ val inspectedQuantity: BigDecimal,
+ val rejectedQuantity: BigDecimal,
+ val reason: String? = null,
+)
diff --git a/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/domain/InspectionRecord.kt b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/domain/InspectionRecord.kt
new file mode 100644
index 0000000..f4d3137
--- /dev/null
+++ b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/domain/InspectionRecord.kt
@@ -0,0 +1,120 @@
+package org.vibeerp.pbc.quality.domain
+
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.EnumType
+import jakarta.persistence.Enumerated
+import jakarta.persistence.Table
+import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
+import java.math.BigDecimal
+import java.time.Instant
+
+/**
+ * A single quality-inspection decision captured at a checkpoint.
+ *
+ * **The v1 scope is DELIBERATELY recording-only.** pbc-quality's
+ * first incarnation does not publish events, does not write back to
+ * pbc-inventory or pbc-warehousing, and does not enforce a downstream
+ * "rejected stock must be quarantined" rule. It is a first-class,
+ * queryable record of "who inspected what, when, and whether it
+ * passed". Any side effect (stock quarantine on rejection, work-order
+ * scrap on finished-goods rejection, partner quality KPI) lives
+ * downstream in a follow-up chunk that subscribes to an
+ * `InspectionRecordedEvent` that this PBC will start publishing when
+ * a consumer appears. YAGNI: building the event now without a
+ * consumer to exercise it would be pure speculation about the shape.
+ *
+ * **The three inspection contexts** pbc-quality recognizes via
+ * [sourceReference] are all free-form strings — no enum:
+ * - **Inbound** (PO receipt): `PO:PO-2026-0001:L2` — the purchase
+ * order line this inspection relates to
+ * - **In-process** (work-order output): `WO:WO-FROM-SO-2026-0001-L1`
+ * — the work order whose output was inspected
+ * - **Outbound** (SO shipment): `SO:SO-2026-0007:L3`
+ *
+ * The format mirrors the `reference` convention the inventory ledger
+ * already uses (`WO:`, `SO:`, `PO:`, `TR:`),
+ * so an auditor can grep both the ledger and the inspection table
+ * with the same filter.
+ *
+ * **Why [inspectedQuantity] and [rejectedQuantity] are BOTH stored**
+ * (rather than storing inspected and computing rejected on demand):
+ * a future report needs both numbers per inspection AND a running
+ * rejection rate per item/per inspector; doing that without storing
+ * both would mean rebuilding the rejected count from the decision
+ * field every time, which is what the column exists to prevent.
+ * `APPROVED` records have [rejectedQuantity] = 0; `REJECTED`
+ * records have [rejectedQuantity] > 0 and <= [inspectedQuantity].
+ *
+ * **Why [decision] is enum-stored-as-string but [sourceReference] is
+ * not:** decision has a closed set (APPROVED / REJECTED) that the
+ * framework wants to switch on in code; source reference is a
+ * free-form link into another aggregate's code space and the closed
+ * set is unknown (new sources will land as new PBCs ship).
+ *
+ * **Why [inspector] is a plain String** (principal id rendered as
+ * text) rather than a typed `PrincipalId`: the JPA column carries
+ * the audit-layer convention, same as `created_by` / `updated_by`
+ * on every other entity. The application sets it from
+ * `SecurityContextHolder` via the controller when a real user
+ * records an inspection.
+ */
+@Entity
+@Table(name = "quality__inspection_record")
+class InspectionRecord(
+ code: String,
+ itemCode: String,
+ sourceReference: String,
+ decision: InspectionDecision,
+ inspectedQuantity: BigDecimal,
+ rejectedQuantity: BigDecimal,
+ inspector: String,
+ reason: String? = null,
+ inspectedAt: Instant = Instant.now(),
+) : AuditedJpaEntity() {
+
+ @Column(name = "code", nullable = false, length = 64)
+ var code: String = code
+
+ @Column(name = "item_code", nullable = false, length = 64)
+ var itemCode: String = itemCode
+
+ @Column(name = "source_reference", nullable = false, length = 128)
+ var sourceReference: String = sourceReference
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = "decision", nullable = false, length = 16)
+ var decision: InspectionDecision = decision
+
+ @Column(name = "inspected_quantity", nullable = false, precision = 18, scale = 4)
+ var inspectedQuantity: BigDecimal = inspectedQuantity
+
+ @Column(name = "rejected_quantity", nullable = false, precision = 18, scale = 4)
+ var rejectedQuantity: BigDecimal = rejectedQuantity
+
+ @Column(name = "inspector", nullable = false, length = 128)
+ var inspector: String = inspector
+
+ @Column(name = "reason", nullable = true, length = 512)
+ var reason: String? = reason
+
+ @Column(name = "inspected_at", nullable = false)
+ var inspectedAt: Instant = inspectedAt
+
+ override fun toString(): String =
+ "InspectionRecord(id=$id, code='$code', item='$itemCode', decision=$decision, " +
+ "inspected=$inspectedQuantity, rejected=$rejectedQuantity, ref='$sourceReference')"
+}
+
+/**
+ * Inspection outcome. A closed set with exactly two states; the
+ * framework refuses to grow a "CONDITIONAL_ACCEPT" or similar because
+ * that turns the query "is this batch good?" into a three-valued
+ * logic problem downstream. If a batch needs conditional acceptance,
+ * record two inspections: a REJECTED one for the bad portion and an
+ * APPROVED one for the accepted portion.
+ */
+enum class InspectionDecision {
+ APPROVED,
+ REJECTED,
+}
diff --git a/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/http/InspectionRecordController.kt b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/http/InspectionRecordController.kt
new file mode 100644
index 0000000..e6deffc
--- /dev/null
+++ b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/http/InspectionRecordController.kt
@@ -0,0 +1,117 @@
+package org.vibeerp.pbc.quality.http
+
+import jakarta.validation.Valid
+import jakarta.validation.constraints.NotBlank
+import jakarta.validation.constraints.NotNull
+import jakarta.validation.constraints.Size
+import org.springframework.http.HttpStatus
+import org.springframework.http.ResponseEntity
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.bind.annotation.ResponseStatus
+import org.springframework.web.bind.annotation.RestController
+import org.vibeerp.pbc.quality.application.InspectionRecordService
+import org.vibeerp.pbc.quality.application.RecordInspectionCommand
+import org.vibeerp.pbc.quality.domain.InspectionDecision
+import org.vibeerp.pbc.quality.domain.InspectionRecord
+import org.vibeerp.platform.security.authz.RequirePermission
+import java.math.BigDecimal
+import java.time.Instant
+import java.util.UUID
+
+/**
+ * REST API for the quality PBC. Mounted at
+ * `/api/v1/quality/inspections`. Inspections are append-only: there
+ * is no update or delete endpoint. To "revise" an inspection, record
+ * a new one with a new code (convention: suffix `-R1`, `-R2`, etc.).
+ */
+@RestController
+@RequestMapping("/api/v1/quality/inspections")
+class InspectionRecordController(
+ private val service: InspectionRecordService,
+) {
+
+ @GetMapping
+ @RequirePermission("quality.inspection.read")
+ fun list(
+ @RequestParam("sourceReference", required = false) sourceReference: String?,
+ @RequestParam("itemCode", required = false) itemCode: String?,
+ ): List = when {
+ !sourceReference.isNullOrBlank() -> service.findBySourceReference(sourceReference).map { it.toResponse() }
+ !itemCode.isNullOrBlank() -> service.findByItemCode(itemCode).map { it.toResponse() }
+ else -> service.list().map { it.toResponse() }
+ }
+
+ @GetMapping("/{id}")
+ @RequirePermission("quality.inspection.read")
+ fun get(@PathVariable id: UUID): ResponseEntity {
+ val record = service.findById(id) ?: return ResponseEntity.notFound().build()
+ return ResponseEntity.ok(record.toResponse())
+ }
+
+ @GetMapping("/by-code/{code}")
+ @RequirePermission("quality.inspection.read")
+ fun getByCode(@PathVariable code: String): ResponseEntity {
+ val record = service.findByCode(code) ?: return ResponseEntity.notFound().build()
+ return ResponseEntity.ok(record.toResponse())
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ @RequirePermission("quality.inspection.record")
+ fun record(@RequestBody @Valid request: RecordInspectionRequest): InspectionRecordResponse =
+ service.record(request.toCommand()).toResponse()
+}
+
+// ─── DTOs ────────────────────────────────────────────────────────────
+
+data class RecordInspectionRequest(
+ @field:NotBlank @field:Size(max = 64) val code: String,
+ @field:NotBlank @field:Size(max = 64) val itemCode: String,
+ @field:NotBlank @field:Size(max = 128) val sourceReference: String,
+ @field:NotNull val decision: InspectionDecision,
+ @field:NotNull val inspectedQuantity: BigDecimal,
+ @field:NotNull val rejectedQuantity: BigDecimal,
+ @field:Size(max = 512) val reason: String? = null,
+) {
+ fun toCommand(): RecordInspectionCommand = RecordInspectionCommand(
+ code = code,
+ itemCode = itemCode,
+ sourceReference = sourceReference,
+ decision = decision,
+ inspectedQuantity = inspectedQuantity,
+ rejectedQuantity = rejectedQuantity,
+ reason = reason,
+ )
+}
+
+data class InspectionRecordResponse(
+ val id: UUID,
+ val code: String,
+ val itemCode: String,
+ val sourceReference: String,
+ val decision: InspectionDecision,
+ val inspectedQuantity: BigDecimal,
+ val rejectedQuantity: BigDecimal,
+ val inspector: String,
+ val reason: String?,
+ val inspectedAt: Instant,
+)
+
+private fun InspectionRecord.toResponse(): InspectionRecordResponse =
+ InspectionRecordResponse(
+ id = id,
+ code = code,
+ itemCode = itemCode,
+ sourceReference = sourceReference,
+ decision = decision,
+ inspectedQuantity = inspectedQuantity,
+ rejectedQuantity = rejectedQuantity,
+ inspector = inspector,
+ reason = reason,
+ inspectedAt = inspectedAt,
+ )
diff --git a/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/infrastructure/InspectionRecordJpaRepository.kt b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/infrastructure/InspectionRecordJpaRepository.kt
new file mode 100644
index 0000000..4aa749b
--- /dev/null
+++ b/pbc/pbc-quality/src/main/kotlin/org/vibeerp/pbc/quality/infrastructure/InspectionRecordJpaRepository.kt
@@ -0,0 +1,12 @@
+package org.vibeerp.pbc.quality.infrastructure
+
+import org.springframework.data.jpa.repository.JpaRepository
+import org.vibeerp.pbc.quality.domain.InspectionRecord
+import java.util.UUID
+
+interface InspectionRecordJpaRepository : JpaRepository {
+ fun existsByCode(code: String): Boolean
+ fun findByCode(code: String): InspectionRecord?
+ fun findBySourceReference(sourceReference: String): List
+ fun findByItemCode(itemCode: String): List
+}
diff --git a/pbc/pbc-quality/src/main/resources/META-INF/vibe-erp/metadata/quality.yml b/pbc/pbc-quality/src/main/resources/META-INF/vibe-erp/metadata/quality.yml
new file mode 100644
index 0000000..de92858
--- /dev/null
+++ b/pbc/pbc-quality/src/main/resources/META-INF/vibe-erp/metadata/quality.yml
@@ -0,0 +1,22 @@
+# pbc-quality metadata.
+#
+# Loaded at boot by MetadataLoader, tagged source='core'.
+
+entities:
+ - name: InspectionRecord
+ pbc: quality
+ table: quality__inspection_record
+ description: An append-only QC decision record (APPROVED or REJECTED) captured at any inspection checkpoint, linked to a free-form source reference
+
+permissions:
+ - key: quality.inspection.read
+ description: Read inspection records
+ - key: quality.inspection.record
+ description: Record a new inspection (append-only)
+
+menus:
+ - path: /quality/inspections
+ label: Inspections
+ icon: clipboard-check
+ section: Quality
+ order: 600
diff --git a/pbc/pbc-quality/src/test/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordServiceTest.kt b/pbc/pbc-quality/src/test/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordServiceTest.kt
new file mode 100644
index 0000000..1741e72
--- /dev/null
+++ b/pbc/pbc-quality/src/test/kotlin/org/vibeerp/pbc/quality/application/InspectionRecordServiceTest.kt
@@ -0,0 +1,166 @@
+package org.vibeerp.pbc.quality.application
+
+import assertk.assertFailure
+import assertk.assertThat
+import assertk.assertions.hasMessage
+import assertk.assertions.isEqualTo
+import assertk.assertions.isInstanceOf
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.slot
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+import org.vibeerp.api.v1.core.Id
+import org.vibeerp.api.v1.ext.catalog.CatalogApi
+import org.vibeerp.api.v1.ext.catalog.ItemRef
+import org.vibeerp.pbc.quality.domain.InspectionDecision
+import org.vibeerp.pbc.quality.domain.InspectionRecord
+import org.vibeerp.pbc.quality.infrastructure.InspectionRecordJpaRepository
+import org.vibeerp.platform.persistence.security.PrincipalContext
+import java.math.BigDecimal
+import java.util.UUID
+
+class InspectionRecordServiceTest {
+
+ private val repo: InspectionRecordJpaRepository = mockk(relaxed = true)
+ private val catalog: CatalogApi = mockk()
+ private val service = InspectionRecordService(repo, catalog)
+
+ @AfterEach
+ fun clearContext() {
+ PrincipalContext.clear()
+ }
+
+ private fun stubItemExists(code: String) {
+ every { catalog.findItemByCode(code) } returns ItemRef(
+ id = Id(UUID.randomUUID()),
+ code = code,
+ name = "fake",
+ itemType = "GOOD",
+ baseUomCode = "ea",
+ active = true,
+ )
+ }
+
+ private fun cmd(
+ code: String = "QC-001",
+ decision: InspectionDecision = InspectionDecision.APPROVED,
+ inspected: String = "100",
+ rejected: String = "0",
+ reason: String? = null,
+ ) = RecordInspectionCommand(
+ code = code,
+ itemCode = "ITEM-1",
+ sourceReference = "WO:WO-2026-001",
+ decision = decision,
+ inspectedQuantity = BigDecimal(inspected),
+ rejectedQuantity = BigDecimal(rejected),
+ reason = reason,
+ )
+
+ @Test
+ fun `record persists an APPROVED inspection with rejected=0`() {
+ every { repo.existsByCode("QC-001") } returns false
+ stubItemExists("ITEM-1")
+ val saved = slot()
+ every { repo.save(capture(saved)) } answers { saved.captured }
+
+ PrincipalContext.set("user-42")
+ val result = service.record(cmd())
+
+ assertThat(result.code).isEqualTo("QC-001")
+ assertThat(result.decision).isEqualTo(InspectionDecision.APPROVED)
+ assertThat(result.inspectedQuantity).isEqualTo(BigDecimal("100"))
+ assertThat(result.rejectedQuantity).isEqualTo(BigDecimal("0"))
+ assertThat(result.inspector).isEqualTo("user-42")
+ }
+
+ @Test
+ fun `record persists a REJECTED inspection with positive rejected`() {
+ every { repo.existsByCode("QC-002") } returns false
+ stubItemExists("ITEM-1")
+ every { repo.save(any()) } answers { firstArg() }
+
+ val result = service.record(
+ cmd(
+ code = "QC-002",
+ decision = InspectionDecision.REJECTED,
+ inspected = "100",
+ rejected = "15",
+ reason = "surface scratches",
+ ),
+ )
+ assertThat(result.decision).isEqualTo(InspectionDecision.REJECTED)
+ assertThat(result.rejectedQuantity).isEqualTo(BigDecimal("15"))
+ assertThat(result.reason).isEqualTo("surface scratches")
+ }
+
+ @Test
+ fun `inspector defaults to system when no principal is bound`() {
+ every { repo.existsByCode(any()) } returns false
+ stubItemExists("ITEM-1")
+ every { repo.save(any()) } answers { firstArg() }
+
+ val result = service.record(cmd())
+ assertThat(result.inspector).isEqualTo("__system__")
+ }
+
+ @Test
+ fun `record rejects duplicate code`() {
+ every { repo.existsByCode("QC-dup") } returns true
+
+ assertFailure { service.record(cmd(code = "QC-dup")) }
+ .isInstanceOf(IllegalArgumentException::class)
+ .hasMessage("inspection code 'QC-dup' is already taken")
+ }
+
+ @Test
+ fun `record rejects non-positive inspected quantity`() {
+ every { repo.existsByCode(any()) } returns false
+
+ assertFailure { service.record(cmd(inspected = "0", rejected = "0")) }
+ .isInstanceOf(IllegalArgumentException::class)
+ }
+
+ @Test
+ fun `record rejects rejected greater than inspected`() {
+ every { repo.existsByCode(any()) } returns false
+
+ assertFailure {
+ service.record(
+ cmd(decision = InspectionDecision.REJECTED, inspected = "10", rejected = "20"),
+ )
+ }.isInstanceOf(IllegalArgumentException::class)
+ }
+
+ @Test
+ fun `APPROVED with positive rejected is rejected`() {
+ every { repo.existsByCode(any()) } returns false
+
+ assertFailure {
+ service.record(
+ cmd(decision = InspectionDecision.APPROVED, rejected = "5"),
+ )
+ }.isInstanceOf(IllegalArgumentException::class)
+ }
+
+ @Test
+ fun `REJECTED with zero rejected is rejected`() {
+ every { repo.existsByCode(any()) } returns false
+
+ assertFailure {
+ service.record(
+ cmd(decision = InspectionDecision.REJECTED, inspected = "10", rejected = "0"),
+ )
+ }.isInstanceOf(IllegalArgumentException::class)
+ }
+
+ @Test
+ fun `record rejects unknown items via CatalogApi`() {
+ every { repo.existsByCode(any()) } returns false
+ every { catalog.findItemByCode("ITEM-1") } returns null
+
+ assertFailure { service.record(cmd()) }
+ .isInstanceOf(IllegalArgumentException::class)
+ }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 2b9e07c..b53b87b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -61,6 +61,9 @@ project(":pbc:pbc-inventory").projectDir = file("pbc/pbc-inventory")
include(":pbc:pbc-warehousing")
project(":pbc:pbc-warehousing").projectDir = file("pbc/pbc-warehousing")
+include(":pbc:pbc-quality")
+project(":pbc:pbc-quality").projectDir = file("pbc/pbc-quality")
+
include(":pbc:pbc-orders-sales")
project(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales")