1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 10:19:35 +01:00

feat(inventory): Inventur abschließen mit Ausgleichsbuchungen (US-6.3)

Vier-Augen-Prinzip (completedBy ≠ initiatedBy), Vollständigkeitsprüfung
aller CountItems, und automatische ADJUSTMENT-StockMovements für
Abweichungen (IN bei Ist > Soll, OUT bei Ist < Soll).

Domain: complete()-Methode, InventoryCountReconciliationService
Application: CompleteInventoryCount UseCase
Infrastruktur: POST /{id}/complete Endpoint, Liquibase-Migration

Closes #19
This commit is contained in:
Sebastian Frick 2026-03-18 12:56:31 +01:00
parent 6996a301f9
commit e4f4537581
21 changed files with 1373 additions and 26 deletions

View file

@ -0,0 +1,114 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.CompleteInventoryCountCommand;
import de.effigenix.domain.inventory.*;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.persistence.UnitOfWork;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import java.util.List;
public class CompleteInventoryCount {
private final InventoryCountRepository inventoryCountRepository;
private final StockRepository stockRepository;
private final StockMovementRepository stockMovementRepository;
private final InventoryCountReconciliationService reconciliationService;
private final UnitOfWork unitOfWork;
private final AuthorizationPort authPort;
public CompleteInventoryCount(InventoryCountRepository inventoryCountRepository,
StockRepository stockRepository,
StockMovementRepository stockMovementRepository,
InventoryCountReconciliationService reconciliationService,
UnitOfWork unitOfWork,
AuthorizationPort authPort) {
this.inventoryCountRepository = inventoryCountRepository;
this.stockRepository = stockRepository;
this.stockMovementRepository = stockMovementRepository;
this.reconciliationService = reconciliationService;
this.unitOfWork = unitOfWork;
this.authPort = authPort;
}
public Result<InventoryCountError, InventoryCount> execute(CompleteInventoryCountCommand cmd, ActorId actorId) {
if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_WRITE)) {
return Result.failure(new InventoryCountError.Unauthorized("Not authorized to complete inventory counts"));
}
if (cmd.inventoryCountId() == null || cmd.inventoryCountId().isBlank()) {
return Result.failure(new InventoryCountError.InvalidInventoryCountId("must not be blank"));
}
InventoryCountId id;
try {
id = InventoryCountId.of(cmd.inventoryCountId());
} catch (IllegalArgumentException e) {
return Result.failure(new InventoryCountError.InvalidInventoryCountId(e.getMessage()));
}
// 1. InventoryCount laden
InventoryCount count;
switch (inventoryCountRepository.findById(id)) {
case Result.Failure(var err) ->
{ return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); }
case Result.Success(var optCount) -> {
if (optCount.isEmpty()) {
return Result.failure(new InventoryCountError.InventoryCountNotFound(cmd.inventoryCountId()));
}
count = optCount.get();
}
}
// 2. Inventur abschließen (Vier-Augen-Prinzip, Vollständigkeit)
switch (count.complete(cmd.completedBy())) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var ignored) -> { }
}
// 3. Stocks für StorageLocation laden
List<Stock> stocks;
switch (stockRepository.findAllByStorageLocationId(count.storageLocationId())) {
case Result.Failure(var err) ->
{ return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); }
case Result.Success(var val) -> stocks = val;
}
// 4. Reconciliation: Ausgleichsbuchungen erzeugen
List<StockMovementDraft> movementDrafts;
switch (reconciliationService.reconcile(count, stocks, cmd.completedBy())) {
case Result.Failure(var err) -> { return Result.failure(err); }
case Result.Success(var val) -> movementDrafts = val;
}
// 5. StockMovements erzeugen und validieren
List<StockMovement> movements = new java.util.ArrayList<>();
for (StockMovementDraft draft : movementDrafts) {
switch (StockMovement.record(draft)) {
case Result.Failure(var err) ->
{ return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); }
case Result.Success(var val) -> movements.add(val);
}
}
// 6. Atomar speichern
return unitOfWork.executeAtomically(() -> {
switch (inventoryCountRepository.save(count)) {
case Result.Failure(var err) ->
{ return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
for (StockMovement movement : movements) {
switch (stockMovementRepository.save(movement)) {
case Result.Failure(var err) ->
{ return Result.failure(new InventoryCountError.RepositoryFailure(err.message())); }
case Result.Success(var ignored) -> { }
}
}
return Result.success(count);
});
}
}

View file

@ -0,0 +1,6 @@
package de.effigenix.application.inventory.command;
public record CompleteInventoryCountCommand(
String inventoryCountId,
String completedBy
) {}

View file

@ -25,6 +25,7 @@ import java.util.Objects;
* - addCountItem only in status OPEN * - addCountItem only in status OPEN
* - startCounting only in status OPEN, requires non-empty countItems * - startCounting only in status OPEN, requires non-empty countItems
* - updateCountItem only in status COUNTING * - updateCountItem only in status COUNTING
* - complete only in status COUNTING, requires all items counted, Vier-Augen-Prinzip (completedBy initiatedBy)
*/ */
public class InventoryCount { public class InventoryCount {
@ -32,6 +33,7 @@ public class InventoryCount {
private final StorageLocationId storageLocationId; private final StorageLocationId storageLocationId;
private final LocalDate countDate; private final LocalDate countDate;
private final String initiatedBy; private final String initiatedBy;
private String completedBy;
private InventoryCountStatus status; private InventoryCountStatus status;
private final Instant createdAt; private final Instant createdAt;
private final List<CountItem> countItems; private final List<CountItem> countItems;
@ -41,6 +43,7 @@ public class InventoryCount {
StorageLocationId storageLocationId, StorageLocationId storageLocationId,
LocalDate countDate, LocalDate countDate,
String initiatedBy, String initiatedBy,
String completedBy,
InventoryCountStatus status, InventoryCountStatus status,
Instant createdAt, Instant createdAt,
List<CountItem> countItems List<CountItem> countItems
@ -49,6 +52,7 @@ public class InventoryCount {
this.storageLocationId = storageLocationId; this.storageLocationId = storageLocationId;
this.countDate = countDate; this.countDate = countDate;
this.initiatedBy = initiatedBy; this.initiatedBy = initiatedBy;
this.completedBy = completedBy;
this.status = status; this.status = status;
this.createdAt = createdAt; this.createdAt = createdAt;
this.countItems = new ArrayList<>(countItems); this.countItems = new ArrayList<>(countItems);
@ -90,7 +94,7 @@ public class InventoryCount {
return Result.success(new InventoryCount( return Result.success(new InventoryCount(
InventoryCountId.generate(), storageLocationId, countDate, InventoryCountId.generate(), storageLocationId, countDate,
draft.initiatedBy(), InventoryCountStatus.OPEN, Instant.now(), List.of() draft.initiatedBy(), null, InventoryCountStatus.OPEN, Instant.now(), List.of()
)); ));
} }
@ -102,11 +106,12 @@ public class InventoryCount {
StorageLocationId storageLocationId, StorageLocationId storageLocationId,
LocalDate countDate, LocalDate countDate,
String initiatedBy, String initiatedBy,
String completedBy,
InventoryCountStatus status, InventoryCountStatus status,
Instant createdAt, Instant createdAt,
List<CountItem> countItems List<CountItem> countItems
) { ) {
return new InventoryCount(id, storageLocationId, countDate, initiatedBy, status, createdAt, countItems); return new InventoryCount(id, storageLocationId, countDate, initiatedBy, completedBy, status, createdAt, countItems);
} }
// ==================== Count Item Management ==================== // ==================== Count Item Management ====================
@ -145,6 +150,29 @@ public class InventoryCount {
return Result.success(null); return Result.success(null);
} }
public Result<InventoryCountError, Void> complete(String completedBy) {
if (status != InventoryCountStatus.COUNTING) {
return Result.failure(new InventoryCountError.InvalidStatusTransition(
status.name(), InventoryCountStatus.COMPLETED.name()));
}
if (completedBy == null || completedBy.isBlank()) {
return Result.failure(new InventoryCountError.InvalidInitiatedBy("completedBy must not be blank"));
}
if (countItems.isEmpty()) {
return Result.failure(new InventoryCountError.NoCountItems());
}
boolean allCounted = countItems.stream().allMatch(CountItem::isCounted);
if (!allCounted) {
return Result.failure(new InventoryCountError.IncompleteCountItems());
}
if (completedBy.equals(initiatedBy)) {
return Result.failure(new InventoryCountError.SamePersonViolation());
}
this.completedBy = completedBy;
this.status = InventoryCountStatus.COMPLETED;
return Result.success(null);
}
public Result<InventoryCountError, Void> updateCountItem(CountItemId itemId, Quantity actualQuantity) { public Result<InventoryCountError, Void> updateCountItem(CountItemId itemId, Quantity actualQuantity) {
if (status != InventoryCountStatus.COUNTING) { if (status != InventoryCountStatus.COUNTING) {
return Result.failure(new InventoryCountError.InvalidStatusTransition( return Result.failure(new InventoryCountError.InvalidStatusTransition(
@ -187,6 +215,7 @@ public class InventoryCount {
public StorageLocationId storageLocationId() { return storageLocationId; } public StorageLocationId storageLocationId() { return storageLocationId; }
public LocalDate countDate() { return countDate; } public LocalDate countDate() { return countDate; }
public String initiatedBy() { return initiatedBy; } public String initiatedBy() { return initiatedBy; }
public String completedBy() { return completedBy; }
public InventoryCountStatus status() { return status; } public InventoryCountStatus status() { return status; }
public Instant createdAt() { return createdAt; } public Instant createdAt() { return createdAt; }
public List<CountItem> countItems() { return Collections.unmodifiableList(countItems); } public List<CountItem> countItems() { return Collections.unmodifiableList(countItems); }

View file

@ -85,6 +85,11 @@ public sealed interface InventoryCountError {
@Override public String message() { return "An active inventory count already exists for storage location: " + storageLocationId; } @Override public String message() { return "An active inventory count already exists for storage location: " + storageLocationId; }
} }
record StockNotFoundForArticle(String articleId) implements InventoryCountError {
@Override public String code() { return "STOCK_NOT_FOUND_FOR_ARTICLE"; }
@Override public String message() { return "No stock found for article: " + articleId; }
}
record Unauthorized(String message) implements InventoryCountError { record Unauthorized(String message) implements InventoryCountError {
@Override public String code() { return "UNAUTHORIZED"; } @Override public String code() { return "UNAUTHORIZED"; }
} }

View file

@ -0,0 +1,67 @@
package de.effigenix.domain.inventory;
import de.effigenix.domain.masterdata.article.ArticleId;
import de.effigenix.shared.common.Result;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* Domain Service: Erzeugt ADJUSTMENT-StockMovementDrafts für Inventur-Abweichungen.
*
* Für jedes CountItem mit Abweichung (actual expected) wird ein StockMovementDraft erzeugt:
* - Positive Abweichung (Ist > Soll) Direction IN
* - Negative Abweichung (Ist < Soll) Direction OUT
*
* Wählt die erste verfügbare Charge des jeweiligen Stocks als Buchungsziel.
*/
public class InventoryCountReconciliationService {
public Result<InventoryCountError, List<StockMovementDraft>> reconcile(
InventoryCount count,
List<Stock> stocks,
String performedBy
) {
Map<ArticleId, Stock> stocksByArticle = stocks.stream()
.collect(Collectors.toMap(Stock::articleId, s -> s, (a, b) -> a));
List<StockMovementDraft> drafts = new ArrayList<>();
for (CountItem item : count.countItems()) {
BigDecimal deviation = item.deviation();
if (deviation == null || deviation.compareTo(BigDecimal.ZERO) == 0) {
continue;
}
Stock stock = stocksByArticle.get(item.articleId());
if (stock == null || stock.batches().isEmpty()) {
return Result.failure(new InventoryCountError.StockNotFoundForArticle(item.articleId().value()));
}
StockBatch batch = stock.batches().getFirst();
String direction = deviation.compareTo(BigDecimal.ZERO) > 0 ? "IN" : "OUT";
BigDecimal absAmount = deviation.abs();
drafts.add(new StockMovementDraft(
stock.id().value(),
item.articleId().value(),
batch.id().value(),
batch.batchReference().batchId(),
batch.batchReference().batchType().name(),
"ADJUSTMENT",
direction,
absAmount.toPlainString(),
item.expectedQuantity().uom().name(),
"Inventur-Ausgleich: Inventur " + count.id().value(),
null,
performedBy
));
}
return Result.success(drafts);
}
}

View file

@ -1,5 +1,6 @@
package de.effigenix.infrastructure.config; package de.effigenix.infrastructure.config;
import de.effigenix.application.inventory.CompleteInventoryCount;
import de.effigenix.application.inventory.CreateInventoryCount; import de.effigenix.application.inventory.CreateInventoryCount;
import de.effigenix.application.inventory.GetInventoryCount; import de.effigenix.application.inventory.GetInventoryCount;
import de.effigenix.application.inventory.ListInventoryCounts; import de.effigenix.application.inventory.ListInventoryCounts;
@ -28,6 +29,7 @@ import de.effigenix.application.inventory.GetStorageLocation;
import de.effigenix.application.inventory.ListStorageLocations; import de.effigenix.application.inventory.ListStorageLocations;
import de.effigenix.application.inventory.UpdateStorageLocation; import de.effigenix.application.inventory.UpdateStorageLocation;
import de.effigenix.application.usermanagement.AuditLogger; import de.effigenix.application.usermanagement.AuditLogger;
import de.effigenix.domain.inventory.InventoryCountReconciliationService;
import de.effigenix.domain.inventory.InventoryCountRepository; import de.effigenix.domain.inventory.InventoryCountRepository;
import de.effigenix.domain.inventory.StockMovementRepository; import de.effigenix.domain.inventory.StockMovementRepository;
import de.effigenix.domain.inventory.StockRepository; import de.effigenix.domain.inventory.StockRepository;
@ -184,4 +186,19 @@ public class InventoryUseCaseConfiguration {
public RecordCountItem recordCountItem(InventoryCountRepository inventoryCountRepository, UnitOfWork unitOfWork, AuthorizationPort authorizationPort) { public RecordCountItem recordCountItem(InventoryCountRepository inventoryCountRepository, UnitOfWork unitOfWork, AuthorizationPort authorizationPort) {
return new RecordCountItem(inventoryCountRepository, unitOfWork, authorizationPort); return new RecordCountItem(inventoryCountRepository, unitOfWork, authorizationPort);
} }
@Bean
public InventoryCountReconciliationService inventoryCountReconciliationService() {
return new InventoryCountReconciliationService();
}
@Bean
public CompleteInventoryCount completeInventoryCount(InventoryCountRepository inventoryCountRepository,
StockRepository stockRepository,
StockMovementRepository stockMovementRepository,
InventoryCountReconciliationService reconciliationService,
UnitOfWork unitOfWork,
AuthorizationPort authorizationPort) {
return new CompleteInventoryCount(inventoryCountRepository, stockRepository, stockMovementRepository, reconciliationService, unitOfWork, authorizationPort);
}
} }

View file

@ -101,7 +101,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
int rows = jdbc.sql(""" int rows = jdbc.sql("""
UPDATE inventory_counts UPDATE inventory_counts
SET storage_location_id = :storageLocationId, count_date = :countDate, SET storage_location_id = :storageLocationId, count_date = :countDate,
initiated_by = :initiatedBy, status = :status, created_at = :createdAt initiated_by = :initiatedBy, completed_by = :completedBy,
status = :status, created_at = :createdAt
WHERE id = :id WHERE id = :id
""") """)
.param("id", inventoryCount.id().value()) .param("id", inventoryCount.id().value())
@ -110,8 +111,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
if (rows == 0) { if (rows == 0) {
jdbc.sql(""" jdbc.sql("""
INSERT INTO inventory_counts (id, storage_location_id, count_date, initiated_by, status, created_at) INSERT INTO inventory_counts (id, storage_location_id, count_date, initiated_by, completed_by, status, created_at)
VALUES (:id, :storageLocationId, :countDate, :initiatedBy, :status, :createdAt) VALUES (:id, :storageLocationId, :countDate, :initiatedBy, :completedBy, :status, :createdAt)
""") """)
.param("id", inventoryCount.id().value()) .param("id", inventoryCount.id().value())
.params(countParams(inventoryCount)) .params(countParams(inventoryCount))
@ -134,6 +135,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
params.put("storageLocationId", count.storageLocationId().value()); params.put("storageLocationId", count.storageLocationId().value());
params.put("countDate", count.countDate()); params.put("countDate", count.countDate());
params.put("initiatedBy", count.initiatedBy()); params.put("initiatedBy", count.initiatedBy());
params.put("completedBy", count.completedBy());
params.put("status", count.status().name()); params.put("status", count.status().name());
params.put("createdAt", count.createdAt().atOffset(ZoneOffset.UTC)); params.put("createdAt", count.createdAt().atOffset(ZoneOffset.UTC));
return params; return params;
@ -206,7 +208,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
count.id(), count.storageLocationId(), count.countDate(), count.id(), count.storageLocationId(), count.countDate(),
count.initiatedBy(), count.status(), count.createdAt(), items count.initiatedBy(), count.completedBy(), count.status(), count.createdAt(), items
); );
} }
@ -224,6 +226,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
StorageLocationId.of(rs.getString("storage_location_id")), StorageLocationId.of(rs.getString("storage_location_id")),
rs.getObject("count_date", LocalDate.class), rs.getObject("count_date", LocalDate.class),
rs.getString("initiated_by"), rs.getString("initiated_by"),
rs.getString("completed_by"),
InventoryCountStatus.valueOf(rs.getString("status")), InventoryCountStatus.valueOf(rs.getString("status")),
rs.getObject("created_at", OffsetDateTime.class).toInstant(), rs.getObject("created_at", OffsetDateTime.class).toInstant(),
List.of() List.of()

View file

@ -1,10 +1,12 @@
package de.effigenix.infrastructure.inventory.web.controller; package de.effigenix.infrastructure.inventory.web.controller;
import de.effigenix.application.inventory.CompleteInventoryCount;
import de.effigenix.application.inventory.CreateInventoryCount; import de.effigenix.application.inventory.CreateInventoryCount;
import de.effigenix.application.inventory.GetInventoryCount; import de.effigenix.application.inventory.GetInventoryCount;
import de.effigenix.application.inventory.ListInventoryCounts; import de.effigenix.application.inventory.ListInventoryCounts;
import de.effigenix.application.inventory.RecordCountItem; import de.effigenix.application.inventory.RecordCountItem;
import de.effigenix.application.inventory.StartInventoryCount; import de.effigenix.application.inventory.StartInventoryCount;
import de.effigenix.application.inventory.command.CompleteInventoryCountCommand;
import de.effigenix.application.inventory.command.CreateInventoryCountCommand; import de.effigenix.application.inventory.command.CreateInventoryCountCommand;
import de.effigenix.application.inventory.command.RecordCountItemCommand; import de.effigenix.application.inventory.command.RecordCountItemCommand;
import de.effigenix.domain.inventory.InventoryCountError; import de.effigenix.domain.inventory.InventoryCountError;
@ -34,17 +36,20 @@ public class InventoryCountController {
private final ListInventoryCounts listInventoryCounts; private final ListInventoryCounts listInventoryCounts;
private final StartInventoryCount startInventoryCount; private final StartInventoryCount startInventoryCount;
private final RecordCountItem recordCountItem; private final RecordCountItem recordCountItem;
private final CompleteInventoryCount completeInventoryCount;
public InventoryCountController(CreateInventoryCount createInventoryCount, public InventoryCountController(CreateInventoryCount createInventoryCount,
GetInventoryCount getInventoryCount, GetInventoryCount getInventoryCount,
ListInventoryCounts listInventoryCounts, ListInventoryCounts listInventoryCounts,
StartInventoryCount startInventoryCount, StartInventoryCount startInventoryCount,
RecordCountItem recordCountItem) { RecordCountItem recordCountItem,
CompleteInventoryCount completeInventoryCount) {
this.createInventoryCount = createInventoryCount; this.createInventoryCount = createInventoryCount;
this.getInventoryCount = getInventoryCount; this.getInventoryCount = getInventoryCount;
this.listInventoryCounts = listInventoryCounts; this.listInventoryCounts = listInventoryCounts;
this.startInventoryCount = startInventoryCount; this.startInventoryCount = startInventoryCount;
this.recordCountItem = recordCountItem; this.recordCountItem = recordCountItem;
this.completeInventoryCount = completeInventoryCount;
} }
@PostMapping @PostMapping
@ -135,6 +140,22 @@ public class InventoryCountController {
return ResponseEntity.ok(InventoryCountResponse.from(result.unsafeGetValue())); return ResponseEntity.ok(InventoryCountResponse.from(result.unsafeGetValue()));
} }
@PostMapping("/{id}/complete")
@PreAuthorize("hasAuthority('INVENTORY_COUNT_WRITE')")
public ResponseEntity<InventoryCountResponse> completeInventoryCount(
@PathVariable String id,
Authentication authentication
) {
var cmd = new CompleteInventoryCountCommand(id, authentication.getName());
var result = completeInventoryCount.execute(cmd, ActorId.of(authentication.getName()));
if (result.isFailure()) {
throw new InventoryCountDomainErrorException(result.unsafeGetError());
}
return ResponseEntity.ok(InventoryCountResponse.from(result.unsafeGetValue()));
}
// ==================== Exception Wrapper ==================== // ==================== Exception Wrapper ====================
public static class InventoryCountDomainErrorException extends RuntimeException { public static class InventoryCountDomainErrorException extends RuntimeException {

View file

@ -11,6 +11,7 @@ public record InventoryCountResponse(
String storageLocationId, String storageLocationId,
LocalDate countDate, LocalDate countDate,
String initiatedBy, String initiatedBy,
String completedBy,
String status, String status,
Instant createdAt, Instant createdAt,
List<CountItemResponse> countItems List<CountItemResponse> countItems
@ -21,6 +22,7 @@ public record InventoryCountResponse(
count.storageLocationId().value(), count.storageLocationId().value(),
count.countDate(), count.countDate(),
count.initiatedBy(), count.initiatedBy(),
count.completedBy(),
count.status().name(), count.status().name(),
count.createdAt(), count.createdAt(),
count.countItems().stream() count.countItems().stream()

View file

@ -64,6 +64,7 @@ public final class InventoryErrorHttpStatusMapper {
case InventoryCountError.NoCountItems e -> 409; case InventoryCountError.NoCountItems e -> 409;
case InventoryCountError.IncompleteCountItems e -> 409; case InventoryCountError.IncompleteCountItems e -> 409;
case InventoryCountError.SamePersonViolation e -> 409; case InventoryCountError.SamePersonViolation e -> 409;
case InventoryCountError.StockNotFoundForArticle e -> 409;
case InventoryCountError.CountDateInFuture e -> 400; case InventoryCountError.CountDateInFuture e -> 400;
case InventoryCountError.InvalidStorageLocationId e -> 400; case InventoryCountError.InvalidStorageLocationId e -> 400;
case InventoryCountError.InvalidCountDate e -> 400; case InventoryCountError.InvalidCountDate e -> 400;

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="037-add-completed-by-to-inventory-counts" author="effigenix">
<addColumn tableName="inventory_counts">
<column name="completed_by" type="VARCHAR(36)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>

View file

@ -42,5 +42,6 @@
<include file="db/changelog/changes/034-create-inventory-counts-schema.xml"/> <include file="db/changelog/changes/034-create-inventory-counts-schema.xml"/>
<include file="db/changelog/changes/035-seed-inventory-count-permissions.xml"/> <include file="db/changelog/changes/035-seed-inventory-count-permissions.xml"/>
<include file="db/changelog/changes/036-add-inventory-counts-composite-index.xml"/> <include file="db/changelog/changes/036-add-inventory-counts-composite-index.xml"/>
<include file="db/changelog/changes/037-add-completed-by-to-inventory-counts.xml"/>
</databaseChangeLog> </databaseChangeLog>

View file

@ -0,0 +1,394 @@
package de.effigenix.application.inventory;
import de.effigenix.application.inventory.command.CompleteInventoryCountCommand;
import de.effigenix.domain.inventory.*;
import de.effigenix.domain.masterdata.article.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.common.UnitOfMeasure;
import de.effigenix.shared.persistence.UnitOfWork;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
@DisplayName("CompleteInventoryCount Use Case")
class CompleteInventoryCountTest {
@Mock private InventoryCountRepository inventoryCountRepository;
@Mock private StockRepository stockRepository;
@Mock private StockMovementRepository stockMovementRepository;
@Mock private UnitOfWork unitOfWork;
@Mock private AuthorizationPort authPort;
private CompleteInventoryCount completeInventoryCount;
private InventoryCountReconciliationService reconciliationService;
private final ActorId actorId = ActorId.of("user-2");
@BeforeEach
void setUp() {
reconciliationService = new InventoryCountReconciliationService();
completeInventoryCount = new CompleteInventoryCount(
inventoryCountRepository, stockRepository, stockMovementRepository,
reconciliationService, unitOfWork, authPort
);
lenient().when(authPort.can(any(ActorId.class), any())).thenReturn(true);
}
private void stubUnitOfWork() {
when(unitOfWork.executeAtomically(any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
var supplier = (java.util.function.Supplier<Result<?, ?>>) invocation.getArgument(0);
return supplier.get();
});
}
// ==================== Happy Path ====================
@Test
@DisplayName("should complete inventory count with adjustment movements")
void shouldCompleteWithAdjustments() {
stubUnitOfWork();
var count = createCountingCountAllCounted("10.0", "8.0");
var stocks = List.of(stockFor("article-1", "stock-1"));
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(stocks));
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
when(stockMovementRepository.save(any())).thenReturn(Result.success(null));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().status()).isEqualTo(InventoryCountStatus.COMPLETED);
assertThat(result.unsafeGetValue().completedBy()).isEqualTo("user-2");
verify(inventoryCountRepository).save(any(InventoryCount.class));
verify(stockMovementRepository).save(any(StockMovement.class));
}
@Test
@DisplayName("should complete with no movements when no deviations")
void shouldCompleteWithNoMovementsWhenNoDeviations() {
stubUnitOfWork();
var count = createCountingCountAllCounted("10.0", "10.0");
var stocks = List.of(stockFor("article-1", "stock-1"));
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(stocks));
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().status()).isEqualTo(InventoryCountStatus.COMPLETED);
verify(inventoryCountRepository).save(any(InventoryCount.class));
verify(stockMovementRepository, never()).save(any());
}
@Test
@DisplayName("should create IN movement for positive deviation")
void shouldCreateInMovementForPositiveDeviation() {
stubUnitOfWork();
var count = createCountingCountAllCounted("10.0", "15.0");
var stocks = List.of(stockFor("article-1", "stock-1"));
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(stocks));
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
when(stockMovementRepository.save(any())).thenReturn(Result.success(null));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue();
verify(stockMovementRepository).save(argThat(movement ->
movement.direction() == MovementDirection.IN
&& movement.quantity().amount().compareTo(new BigDecimal("5.0")) == 0
&& movement.movementType() == MovementType.ADJUSTMENT
));
}
@Test
@DisplayName("should create OUT movement for negative deviation")
void shouldCreateOutMovementForNegativeDeviation() {
stubUnitOfWork();
var count = createCountingCountAllCounted("10.0", "7.0");
var stocks = List.of(stockFor("article-1", "stock-1"));
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(stocks));
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
when(stockMovementRepository.save(any())).thenReturn(Result.success(null));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isSuccess()).isTrue();
verify(stockMovementRepository).save(argThat(movement ->
movement.direction() == MovementDirection.OUT
&& movement.quantity().amount().compareTo(new BigDecimal("3.0")) == 0
));
}
// ==================== Authorization ====================
@Test
@DisplayName("should fail when not authorized")
void shouldFailWhenNotAuthorized() {
when(authPort.can(any(ActorId.class), any())).thenReturn(false);
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.Unauthorized.class);
verify(inventoryCountRepository, never()).findById(any());
}
// ==================== Input Validation ====================
@Test
@DisplayName("should fail when inventory count ID is blank")
void shouldFailWhenIdBlank() {
var cmd = new CompleteInventoryCountCommand("", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class);
}
@Test
@DisplayName("should fail when inventory count ID is null")
void shouldFailWhenIdNull() {
var cmd = new CompleteInventoryCountCommand(null, "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInventoryCountId.class);
}
// ==================== Not Found ====================
@Test
@DisplayName("should fail when inventory count not found")
void shouldFailWhenNotFound() {
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.empty()));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class);
}
// ==================== Domain Validation Errors ====================
@Test
@DisplayName("should fail when same person violation (Vier-Augen-Prinzip)")
void shouldFailWhenSamePerson() {
var count = createCountingCountAllCounted("10.0", "8.0");
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
// completedBy = initiatedBy = "user-1"
var cmd = new CompleteInventoryCountCommand("count-1", "user-1");
var result = completeInventoryCount.execute(cmd, ActorId.of("user-1"));
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.SamePersonViolation.class);
}
@Test
@DisplayName("should fail when count items are incomplete")
void shouldFailWhenIncompleteItems() {
var items = new ArrayList<>(List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null)
));
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.IncompleteCountItems.class);
}
@Test
@DisplayName("should fail when count is not in COUNTING status")
void shouldFailWhenWrongStatus() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), new ArrayList<>()
);
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
}
// ==================== Repository Failures ====================
@Test
@DisplayName("should fail when findById repository fails")
void shouldFailWhenFindByIdFails() {
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail when stock repository fails")
void shouldFailWhenStockRepositoryFails() {
var count = createCountingCountAllCounted("10.0", "8.0");
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("stock db error")));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail when inventory count save fails")
void shouldFailWhenSaveFails() {
stubUnitOfWork();
var count = createCountingCountAllCounted("10.0", "10.0");
var stocks = List.of(stockFor("article-1", "stock-1"));
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(stocks));
when(inventoryCountRepository.save(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("save failed")));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail when stock movement save fails")
void shouldFailWhenMovementSaveFails() {
stubUnitOfWork();
var count = createCountingCountAllCounted("10.0", "8.0");
var stocks = List.of(stockFor("article-1", "stock-1"));
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(stocks));
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
when(stockMovementRepository.save(any()))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("movement save failed")));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
}
@Test
@DisplayName("should fail when stock not found for deviating article")
void shouldFailWhenStockNotFoundForArticle() {
var count = createCountingCountAllCounted("10.0", "8.0");
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
.thenReturn(Result.success(Optional.of(count)));
when(stockRepository.findAllByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of()));
var cmd = new CompleteInventoryCountCommand("count-1", "user-2");
var result = completeInventoryCount.execute(cmd, actorId);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.StockNotFoundForArticle.class);
}
// ==================== Helpers ====================
private InventoryCount createCountingCountAllCounted(String expected, String actual) {
var items = new ArrayList<>(List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal(expected), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal(actual), UnitOfMeasure.KILOGRAM))
));
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
);
}
private Stock stockFor(String articleId, String stockId) {
var batch = StockBatch.reconstitute(
StockBatchId.of("batch-1"),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("100.0"), UnitOfMeasure.KILOGRAM),
LocalDate.now().plusDays(30),
StockBatchStatus.AVAILABLE,
Instant.now()
);
return Stock.reconstitute(
StockId.of(stockId), ArticleId.of(articleId),
StorageLocationId.of("location-1"), null, null,
List.of(batch), List.of()
);
}
}

View file

@ -42,6 +42,7 @@ class GetInventoryCountTest {
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
LocalDate.now(), LocalDate.now(),
"user-1", "user-1",
null,
InventoryCountStatus.OPEN, InventoryCountStatus.OPEN,
Instant.now(), Instant.now(),
List.of() List.of()

View file

@ -42,6 +42,7 @@ class ListInventoryCountsTest {
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
LocalDate.now(), LocalDate.now(),
"user-1", "user-1",
null,
InventoryCountStatus.OPEN, InventoryCountStatus.OPEN,
Instant.now(), Instant.now(),
List.of() List.of()
@ -52,6 +53,7 @@ class ListInventoryCountsTest {
StorageLocationId.of("location-2"), StorageLocationId.of("location-2"),
LocalDate.now(), LocalDate.now(),
"user-1", "user-1",
null,
InventoryCountStatus.COMPLETED, InventoryCountStatus.COMPLETED,
Instant.now(), Instant.now(),
List.of() List.of()

View file

@ -55,7 +55,7 @@ class RecordCountItemTest {
private InventoryCount createCountingCount() { private InventoryCount createCountingCount() {
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING, LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), List.of( Instant.now(), List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"), CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null), Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null),
@ -226,7 +226,7 @@ class RecordCountItemTest {
void shouldFailWhenStatusIsOpen() { void shouldFailWhenStatusIsOpen() {
var openCount = InventoryCount.reconstitute( var openCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.OPEN, LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of( Instant.now(), List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"), CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null) Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null)

View file

@ -58,7 +58,7 @@ class StartInventoryCountTest {
); );
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.OPEN, LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), items Instant.now(), items
); );
} }
@ -110,7 +110,7 @@ class StartInventoryCountTest {
void shouldFailWhenNoItems() { void shouldFailWhenNoItems() {
var emptyCount = InventoryCount.reconstitute( var emptyCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.OPEN, LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of() Instant.now(), List.of()
); );
@ -128,7 +128,7 @@ class StartInventoryCountTest {
void shouldFailWhenAlreadyCounting() { void shouldFailWhenAlreadyCounting() {
var countingCount = InventoryCount.reconstitute( var countingCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING, LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), List.of( Instant.now(), List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"), CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null) Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null)

View file

@ -46,6 +46,17 @@ class InventoryCountFuzzTest {
} }
} }
} }
// Exercise state transitions with fuzzed input
switch (count.startCounting()) {
case Result.Failure(var err) -> { }
case Result.Success(var ignored) -> {
// Try to complete with fuzzed completedBy
switch (count.complete(data.consumeString(50))) {
case Result.Failure(var err2) -> { }
case Result.Success(var ignored2) -> { }
}
}
}
// Verify aggregate getters don't throw // Verify aggregate getters don't throw
count.isActive(); count.isActive();
count.countItems(); count.countItems();
@ -54,6 +65,7 @@ class InventoryCountFuzzTest {
count.countDate(); count.countDate();
count.status(); count.status();
count.initiatedBy(); count.initiatedBy();
count.completedBy();
count.createdAt(); count.createdAt();
} }
} }

View file

@ -0,0 +1,271 @@
package de.effigenix.domain.inventory;
import de.effigenix.domain.masterdata.article.ArticleId;
import de.effigenix.shared.common.Quantity;
import de.effigenix.shared.common.UnitOfMeasure;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("InventoryCountReconciliationService")
class InventoryCountReconciliationServiceTest {
private InventoryCountReconciliationService service;
@BeforeEach
void setUp() {
service = new InventoryCountReconciliationService();
}
@Test
@DisplayName("should return empty list when no deviations")
void shouldReturnEmptyWhenNoDeviations() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "10.0")
));
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should create IN adjustment for positive deviation (actual > expected)")
void shouldCreateInAdjustmentForPositiveDeviation() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "15.0")
));
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
var drafts = result.unsafeGetValue();
assertThat(drafts).hasSize(1);
var draft = drafts.getFirst();
assertThat(draft.direction()).isEqualTo("IN");
assertThat(draft.quantityAmount()).isEqualTo("5.0");
assertThat(draft.movementType()).isEqualTo("ADJUSTMENT");
assertThat(draft.stockId()).isEqualTo("stock-1");
assertThat(draft.articleId()).isEqualTo("article-1");
}
@Test
@DisplayName("should create OUT adjustment for negative deviation (actual < expected)")
void shouldCreateOutAdjustmentForNegativeDeviation() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "7.0")
));
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
var drafts = result.unsafeGetValue();
assertThat(drafts).hasSize(1);
var draft = drafts.getFirst();
assertThat(draft.direction()).isEqualTo("OUT");
assertThat(draft.quantityAmount()).isEqualTo("3.0");
}
@Test
@DisplayName("should skip items with zero deviation")
void shouldSkipZeroDeviation() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "10.0"),
countedItem("item-2", "article-2", "5.0", "8.0")
));
var stocks = List.of(
stockFor("article-1", "stock-1"),
stockFor("article-2", "stock-2")
);
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
var drafts = result.unsafeGetValue();
assertThat(drafts).hasSize(1);
assertThat(drafts.getFirst().articleId()).isEqualTo("article-2");
}
@Test
@DisplayName("should create multiple adjustments for multiple deviating items")
void shouldCreateMultipleAdjustments() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "12.0"),
countedItem("item-2", "article-2", "5.0", "3.0"),
countedItem("item-3", "article-3", "20.0", "20.0")
));
var stocks = List.of(
stockFor("article-1", "stock-1"),
stockFor("article-2", "stock-2"),
stockFor("article-3", "stock-3")
);
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
var drafts = result.unsafeGetValue();
assertThat(drafts).hasSize(2);
assertThat(drafts.get(0).direction()).isEqualTo("IN");
assertThat(drafts.get(0).quantityAmount()).isEqualTo("2.0");
assertThat(drafts.get(1).direction()).isEqualTo("OUT");
assertThat(drafts.get(1).quantityAmount()).isEqualTo("2.0");
}
@Test
@DisplayName("should fail when stock not found for deviating article")
void shouldFailWhenStockNotFound() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "15.0")
));
var result = service.reconcile(count, List.of(), "user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.StockNotFoundForArticle.class);
}
@Test
@DisplayName("should fail when stock has no batches for deviating article")
void shouldFailWhenStockHasNoBatches() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "15.0")
));
var emptyStock = Stock.reconstitute(
StockId.of("stock-1"), ArticleId.of("article-1"),
StorageLocationId.of("location-1"), null, null,
List.of(), List.of()
);
var result = service.reconcile(count, List.of(emptyStock), "user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.StockNotFoundForArticle.class);
}
@Test
@DisplayName("should not fail for zero-deviation item even without stock")
void shouldNotFailForZeroDeviationWithoutStock() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "10.0")
));
var result = service.reconcile(count, List.of(), "user-2");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty();
}
@Test
@DisplayName("should use correct batch data in draft")
void shouldUseCorrectBatchData() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "12.0")
));
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
var draft = result.unsafeGetValue().getFirst();
assertThat(draft.stockBatchId()).isEqualTo("batch-1");
assertThat(draft.batchId()).isEqualTo("BATCH-001");
assertThat(draft.batchType()).isEqualTo("PRODUCED");
}
@Test
@DisplayName("should set correct reason containing inventory count ID")
void shouldSetCorrectReason() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "12.0")
));
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().getFirst().reason()).contains("count-1");
}
@Test
@DisplayName("should set performedBy from parameter")
void shouldSetPerformedBy() {
var count = createCompletedCount(List.of(
countedItem("item-1", "article-1", "10.0", "12.0")
));
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "completer-user");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().getFirst().performedBy()).isEqualTo("completer-user");
}
@Test
@DisplayName("should use correct unit of measure from count item")
void shouldUseCorrectUom() {
var items = new ArrayList<>(List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.LITER),
Quantity.reconstitute(new BigDecimal("12.0"), UnitOfMeasure.LITER))
));
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
Instant.now(), items
);
var stocks = List.of(stockFor("article-1", "stock-1"));
var result = service.reconcile(count, stocks, "user-2");
assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue().getFirst().quantityUnit()).isEqualTo("LITER");
}
// ==================== Helpers ====================
private InventoryCount createCompletedCount(List<CountItem> items) {
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
Instant.now(), new ArrayList<>(items)
);
}
private CountItem countedItem(String itemId, String articleId, String expected, String actual) {
return CountItem.reconstitute(
CountItemId.of(itemId), ArticleId.of(articleId),
Quantity.reconstitute(new BigDecimal(expected), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal(actual), UnitOfMeasure.KILOGRAM)
);
}
private Stock stockFor(String articleId, String stockId) {
var batch = StockBatch.reconstitute(
StockBatchId.of("batch-1"),
new BatchReference("BATCH-001", BatchType.PRODUCED),
Quantity.reconstitute(new BigDecimal("100.0"), UnitOfMeasure.KILOGRAM),
LocalDate.now().plusDays(30),
StockBatchStatus.AVAILABLE,
Instant.now()
);
return Stock.reconstitute(
StockId.of(stockId), ArticleId.of(articleId),
StorageLocationId.of("location-1"), null, null,
List.of(batch), List.of()
);
}
}

View file

@ -258,6 +258,7 @@ class InventoryCountTest {
StorageLocationId.of("location-1"), StorageLocationId.of("location-1"),
LocalDate.now(), LocalDate.now(),
"user-1", "user-1",
null,
InventoryCountStatus.COUNTING, InventoryCountStatus.COUNTING,
java.time.Instant.now(), java.time.Instant.now(),
java.util.List.of() java.util.List.of()
@ -381,7 +382,7 @@ class InventoryCountTest {
private InventoryCount reconstitute(InventoryCountStatus status) { private InventoryCount reconstitute(InventoryCountStatus status) {
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", status, LocalDate.now(), "user-1", null, status,
Instant.now(), List.of() Instant.now(), List.of()
); );
} }
@ -452,7 +453,7 @@ class InventoryCountTest {
private InventoryCount reconstitute(InventoryCountStatus status, List<CountItem> items) { private InventoryCount reconstitute(InventoryCountStatus status, List<CountItem> items) {
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", status, LocalDate.now(), "user-1", null, status,
Instant.now(), items Instant.now(), items
); );
} }
@ -525,7 +526,7 @@ class InventoryCountTest {
var items = createCountItems(); var items = createCountItems();
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.COMPLETED, LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED,
Instant.now(), items Instant.now(), items
); );
var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM); var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM);
@ -599,7 +600,7 @@ class InventoryCountTest {
); );
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING, LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items Instant.now(), items
); );
var actualQty = Quantity.reconstitute(new BigDecimal("3.0"), UnitOfMeasure.LITER); var actualQty = Quantity.reconstitute(new BigDecimal("3.0"), UnitOfMeasure.LITER);
@ -702,7 +703,7 @@ class InventoryCountTest {
void countingShouldBeActive() { void countingShouldBeActive() {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"), InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING, LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), List.of() Instant.now(), List.of()
); );
assertThat(count.isActive()).isTrue(); assertThat(count.isActive()).isTrue();
@ -713,7 +714,7 @@ class InventoryCountTest {
void completedShouldNotBeActive() { void completedShouldNotBeActive() {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"), InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", InventoryCountStatus.COMPLETED, LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED,
Instant.now(), List.of() Instant.now(), List.of()
); );
assertThat(count.isActive()).isFalse(); assertThat(count.isActive()).isFalse();
@ -724,7 +725,7 @@ class InventoryCountTest {
void cancelledShouldNotBeActive() { void cancelledShouldNotBeActive() {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"), InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", InventoryCountStatus.CANCELLED, LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED,
Instant.now(), List.of() Instant.now(), List.of()
); );
assertThat(count.isActive()).isFalse(); assertThat(count.isActive()).isFalse();
@ -753,7 +754,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.of(2025, 6, 15), "user-1", InventoryCountStatus.COUNTING, LocalDate.of(2025, 6, 15), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items Instant.now(), items
); );
@ -787,12 +788,12 @@ class InventoryCountTest {
void sameIdShouldBeEqual() { void sameIdShouldBeEqual() {
var a = InventoryCount.reconstitute( var a = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-a"), InventoryCountId.of("count-1"), StorageLocationId.of("loc-a"),
LocalDate.now(), "user-a", InventoryCountStatus.OPEN, LocalDate.now(), "user-a", null, InventoryCountStatus.OPEN,
Instant.now(), List.of() Instant.now(), List.of()
); );
var b = InventoryCount.reconstitute( var b = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-b"), InventoryCountId.of("count-1"), StorageLocationId.of("loc-b"),
LocalDate.now().minusDays(1), "user-b", InventoryCountStatus.COMPLETED, LocalDate.now().minusDays(1), "user-b", null, InventoryCountStatus.COMPLETED,
Instant.now(), List.of() Instant.now(), List.of()
); );
@ -805,12 +806,12 @@ class InventoryCountTest {
void differentIdShouldNotBeEqual() { void differentIdShouldNotBeEqual() {
var a = InventoryCount.reconstitute( var a = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-1"), InventoryCountId.of("count-1"), StorageLocationId.of("loc-1"),
LocalDate.now(), "user-1", InventoryCountStatus.OPEN, LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of() Instant.now(), List.of()
); );
var b = InventoryCount.reconstitute( var b = InventoryCount.reconstitute(
InventoryCountId.of("count-2"), StorageLocationId.of("loc-1"), InventoryCountId.of("count-2"), StorageLocationId.of("loc-1"),
LocalDate.now(), "user-1", InventoryCountStatus.OPEN, LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of() Instant.now(), List.of()
); );
@ -818,6 +819,210 @@ class InventoryCountTest {
} }
} }
// ==================== complete ====================
@Nested
@DisplayName("complete()")
class Complete {
@Test
@DisplayName("should transition from COUNTING to COMPLETED")
void shouldCompleteWhenValid() {
var count = createCountingCountAllCounted();
var result = count.complete("user-2");
assertThat(result.isSuccess()).isTrue();
assertThat(count.status()).isEqualTo(InventoryCountStatus.COMPLETED);
assertThat(count.completedBy()).isEqualTo("user-2");
}
@Test
@DisplayName("should not be active after completion")
void shouldNotBeActiveAfterCompletion() {
var count = createCountingCountAllCounted();
count.complete("user-2");
assertThat(count.isActive()).isFalse();
}
@Test
@DisplayName("should complete with multiple counted items")
void shouldCompleteWithMultipleItems() {
var items = new ArrayList<>(List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM)),
CountItem.reconstitute(CountItemId.of("item-2"), ArticleId.of("article-2"),
Quantity.reconstitute(new BigDecimal("5.0"), UnitOfMeasure.LITER),
Quantity.reconstitute(new BigDecimal("5.0"), UnitOfMeasure.LITER))
));
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
);
var result = count.complete("user-2");
assertThat(result.isSuccess()).isTrue();
assertThat(count.status()).isEqualTo(InventoryCountStatus.COMPLETED);
}
@Test
@DisplayName("should fail when status is OPEN")
void shouldFailWhenStatusIsOpen() {
var count = createOpenCount();
count.addCountItem(new CountItemDraft("article-1", "10.0", "KILOGRAM"));
var result = count.complete("user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when status is COMPLETED")
void shouldFailWhenStatusIsCompleted() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
Instant.now(), createCountedItems()
);
var result = count.complete("user-3");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when status is CANCELLED")
void shouldFailWhenStatusIsCancelled() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED,
Instant.now(), createCountedItems()
);
var result = count.complete("user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
}
@Test
@DisplayName("should fail when completedBy is null")
void shouldFailWhenCompletedByNull() {
var count = createCountingCountAllCounted();
var result = count.complete(null);
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class);
}
@Test
@DisplayName("should fail when completedBy is blank")
void shouldFailWhenCompletedByBlank() {
var count = createCountingCountAllCounted();
var result = count.complete(" ");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidInitiatedBy.class);
}
@Test
@DisplayName("should fail when no count items exist")
void shouldFailWhenNoCountItems() {
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), new ArrayList<>()
);
var result = count.complete("user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.NoCountItems.class);
}
@Test
@DisplayName("should fail when not all items have been counted")
void shouldFailWhenIncompleteItems() {
var items = new ArrayList<>(List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM)),
CountItem.reconstitute(CountItemId.of("item-2"), ArticleId.of("article-2"),
Quantity.reconstitute(new BigDecimal("5.0"), UnitOfMeasure.LITER),
null)
));
var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
);
var result = count.complete("user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.IncompleteCountItems.class);
}
@Test
@DisplayName("should fail when all items are uncounted")
void shouldFailWhenAllItemsUncounted() {
var count = createCountingCount();
var result = count.complete("user-2");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.IncompleteCountItems.class);
}
@Test
@DisplayName("should fail when completedBy equals initiatedBy (Vier-Augen-Prinzip)")
void shouldFailWhenSamePerson() {
var count = createCountingCountAllCounted();
var result = count.complete("user-1");
assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.SamePersonViolation.class);
}
@Test
@DisplayName("should not modify status when validation fails")
void shouldNotModifyStatusOnFailure() {
var count = createCountingCountAllCounted();
count.complete("user-1"); // Same person fails
assertThat(count.status()).isEqualTo(InventoryCountStatus.COUNTING);
assertThat(count.completedBy()).isNull();
}
private InventoryCount createCountingCountAllCounted() {
var items = createCountedItems();
return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items
);
}
private List<CountItem> createCountedItems() {
return new ArrayList<>(List.of(
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM),
Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM))
));
}
}
// ==================== Helpers ==================== // ==================== Helpers ====================
private InventoryCount createOpenCount() { private InventoryCount createOpenCount() {
@ -830,7 +1035,7 @@ class InventoryCountTest {
var items = createCountItems(); var items = createCountItems();
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING, LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items Instant.now(), items
); );
} }

View file

@ -5,6 +5,7 @@ import de.effigenix.infrastructure.AbstractIntegrationTest;
import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest; import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
@ -22,11 +23,13 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
* Abgedeckte Testfälle: * Abgedeckte Testfälle:
* - US-6.1 Inventur anlegen und Zählpositionen befüllen * - US-6.1 Inventur anlegen und Zählpositionen befüllen
* - US-6.2 Inventur durchführen (Ist-Mengen erfassen) * - US-6.2 Inventur durchführen (Ist-Mengen erfassen)
* - US-6.3 Inventur abschließen mit Ausgleichsbuchungen
*/ */
@DisplayName("InventoryCount Controller Integration Tests") @DisplayName("InventoryCount Controller Integration Tests")
class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest { class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
private String adminToken; private String adminToken;
private String completerToken;
private String viewerToken; private String viewerToken;
private String storageLocationId; private String storageLocationId;
@ -36,9 +39,11 @@ class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
String viewerRoleId = createRole(RoleName.PRODUCTION_WORKER, "Viewer"); String viewerRoleId = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
String adminId = createUser("ic.admin", "ic.admin@test.com", Set.of(adminRoleId), "BRANCH-01"); String adminId = createUser("ic.admin", "ic.admin@test.com", Set.of(adminRoleId), "BRANCH-01");
String completerId = createUser("ic.completer", "ic.completer@test.com", Set.of(adminRoleId), "BRANCH-01");
String viewerId = createUser("ic.viewer", "ic.viewer@test.com", Set.of(viewerRoleId), "BRANCH-01"); String viewerId = createUser("ic.viewer", "ic.viewer@test.com", Set.of(viewerRoleId), "BRANCH-01");
adminToken = generateToken(adminId, "ic.admin", "INVENTORY_COUNT_WRITE,INVENTORY_COUNT_READ,STOCK_WRITE,STOCK_READ"); adminToken = generateToken(adminId, "ic.admin", "INVENTORY_COUNT_WRITE,INVENTORY_COUNT_READ,STOCK_WRITE,STOCK_READ,STOCK_MOVEMENT_READ");
completerToken = generateToken(completerId, "ic.completer", "INVENTORY_COUNT_WRITE,INVENTORY_COUNT_READ,STOCK_WRITE,STOCK_READ,STOCK_MOVEMENT_READ");
viewerToken = generateToken(viewerId, "ic.viewer", "USER_READ"); viewerToken = generateToken(viewerId, "ic.viewer", "USER_READ");
storageLocationId = createStorageLocation(); storageLocationId = createStorageLocation();
@ -515,6 +520,181 @@ class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
.andExpect(status().isBadRequest()); .andExpect(status().isBadRequest());
} }
// ==================== US-6.3: Inventur abschließen ====================
@Nested
@DisplayName("US-6.3: Inventur abschließen")
class CompleteInventoryCount {
@Test
@DisplayName("Inventur abschließen → 200 mit COMPLETED Status und completedBy")
void completeInventoryCount_returns200() throws Exception {
String countId = createAndStartCountWithRecordedItems();
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(countId))
.andExpect(jsonPath("$.status").value("COMPLETED"))
.andExpect(jsonPath("$.completedBy").isNotEmpty());
}
@Test
@DisplayName("Inventur abschließen erzeugt ADJUSTMENT StockMovements")
void completeInventoryCount_createsAdjustmentMovements() throws Exception {
String countId = createAndStartCountWithRecordedItems();
// Abschließen
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isOk());
// StockMovements prüfen mindestens ein ADJUSTMENT
mockMvc.perform(get("/api/inventory/stock-movements")
.param("movementType", "ADJUSTMENT")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(org.hamcrest.Matchers.greaterThanOrEqualTo(1)))
.andExpect(jsonPath("$[0].movementType").value("ADJUSTMENT"));
}
@Test
@DisplayName("Vier-Augen-Prinzip: gleiche Person → 409 SAME_PERSON_VIOLATION")
void completeInventoryCount_samePerson_returns409() throws Exception {
String countId = createAndStartCountWithRecordedItems();
// Gleicher User, der die Inventur initiiert hat
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("SAME_PERSON_VIOLATION"));
}
@Test
@DisplayName("Inventur mit unvollständigen Items abschließen → 409 INCOMPLETE_COUNT_ITEMS")
void completeInventoryCount_incompleteItems_returns409() throws Exception {
String countId = createInventoryCountWithStock();
// Starten, aber keine Ist-Mengen erfassen
mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/start")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("INCOMPLETE_COUNT_ITEMS"));
}
@Test
@DisplayName("OPEN Inventur abschließen → 409 INVALID_STATUS_TRANSITION")
void completeInventoryCount_openStatus_returns409() throws Exception {
String countId = createInventoryCountWithStock();
// Nicht starten bleibt OPEN
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("INVALID_STATUS_TRANSITION"));
}
@Test
@DisplayName("Unbekannte ID → 404 INVENTORY_COUNT_NOT_FOUND")
void completeInventoryCount_notFound_returns404() throws Exception {
mockMvc.perform(post("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/complete")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("INVENTORY_COUNT_NOT_FOUND"));
}
@Test
@DisplayName("Ohne Token → 401")
void completeInventoryCount_noToken_returns401() throws Exception {
mockMvc.perform(post("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/complete"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Ohne INVENTORY_COUNT_WRITE → 403")
void completeInventoryCount_noPermission_returns403() throws Exception {
mockMvc.perform(post("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/complete")
.header("Authorization", "Bearer " + viewerToken))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("Bereits abgeschlossene Inventur nochmal abschließen → 409")
void completeInventoryCount_alreadyCompleted_returns409() throws Exception {
String countId = createAndStartCountWithRecordedItems();
// Erster Abschluss
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isOk());
// Zweiter Abschluss
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("INVALID_STATUS_TRANSITION"));
}
@Test
@DisplayName("Inventur ohne Abweichung → 200, keine StockMovements erzeugt")
void completeInventoryCount_noDeviation_noMovements() throws Exception {
String countId = createAndStartCountWithExactItems();
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
.header("Authorization", "Bearer " + completerToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("COMPLETED"));
}
private String createAndStartCountWithRecordedItems() throws Exception {
String countId = createInventoryCountWithStock();
mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/start")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
String itemId = getFirstCountItemId(countId);
// Abweichung: expected 25.0, actual 20.0
String body = """
{"actualQuantityAmount": "20.0", "actualQuantityUnit": "KILOGRAM"}
""";
mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/items/" + itemId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk());
return countId;
}
private String createAndStartCountWithExactItems() throws Exception {
String countId = createInventoryCountWithStock();
mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/start")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isOk());
String itemId = getFirstCountItemId(countId);
// Keine Abweichung: expected 25.0, actual 25.0
String body = """
{"actualQuantityAmount": "25.0", "actualQuantityUnit": "KILOGRAM"}
""";
mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/items/" + itemId)
.header("Authorization", "Bearer " + adminToken)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk());
return countId;
}
}
// ==================== Helpers ==================== // ==================== Helpers ====================
private String createInventoryCountWithStock() throws Exception { private String createInventoryCountWithStock() throws Exception {