Commit 4835785ef17d0f7ec75294b19cfe3932fe5bfd56
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.
Showing
11 changed files
with
709 additions
and
0 deletions
distribution/build.gradle.kts
| @@ -36,6 +36,7 @@ dependencies { | @@ -36,6 +36,7 @@ dependencies { | ||
| 36 | implementation(project(":pbc:pbc-orders-purchase")) | 36 | implementation(project(":pbc:pbc-orders-purchase")) |
| 37 | implementation(project(":pbc:pbc-finance")) | 37 | implementation(project(":pbc:pbc-finance")) |
| 38 | implementation(project(":pbc:pbc-production")) | 38 | implementation(project(":pbc:pbc-production")) |
| 39 | + implementation(project(":pbc:pbc-quality")) | ||
| 39 | 40 | ||
| 40 | implementation(libs.spring.boot.starter) | 41 | implementation(libs.spring.boot.starter) |
| 41 | implementation(libs.spring.boot.starter.web) | 42 | implementation(libs.spring.boot.starter.web) |
distribution/src/main/resources/db/changelog/master.xml
| @@ -25,4 +25,5 @@ | @@ -25,4 +25,5 @@ | ||
| 25 | <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/> | 25 | <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/> |
| 26 | <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/> | 26 | <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/> |
| 27 | <include file="classpath:db/changelog/pbc-production/002-production-v2.xml"/> | 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 | </databaseChangeLog> | 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 > 0), | ||
| 53 | + CONSTRAINT quality__inspection_record_rejected_nonneg | ||
| 54 | + CHECK (rejected_quantity >= 0), | ||
| 55 | + CONSTRAINT quality__inspection_record_rejected_bounded | ||
| 56 | + CHECK (rejected_quantity <= 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(":pbc:pbc-inventory").projectDir = file("pbc/pbc-inventory") | @@ -61,6 +61,9 @@ project(":pbc:pbc-inventory").projectDir = file("pbc/pbc-inventory") | ||
| 61 | include(":pbc:pbc-warehousing") | 61 | include(":pbc:pbc-warehousing") |
| 62 | project(":pbc:pbc-warehousing").projectDir = file("pbc/pbc-warehousing") | 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 | include(":pbc:pbc-orders-sales") | 67 | include(":pbc:pbc-orders-sales") |
| 65 | project(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales") | 68 | project(":pbc:pbc-orders-sales").projectDir = file("pbc/pbc-orders-sales") |
| 66 | 69 |