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")