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:
parent
6996a301f9
commit
e4f4537581
21 changed files with 1373 additions and 26 deletions
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package de.effigenix.application.inventory.command;
|
||||
|
||||
public record CompleteInventoryCountCommand(
|
||||
String inventoryCountId,
|
||||
String completedBy
|
||||
) {}
|
||||
|
|
@ -25,6 +25,7 @@ import java.util.Objects;
|
|||
* - addCountItem only in status OPEN
|
||||
* - startCounting only in status OPEN, requires non-empty countItems
|
||||
* - updateCountItem only in status COUNTING
|
||||
* - complete only in status COUNTING, requires all items counted, Vier-Augen-Prinzip (completedBy ≠ initiatedBy)
|
||||
*/
|
||||
public class InventoryCount {
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ public class InventoryCount {
|
|||
private final StorageLocationId storageLocationId;
|
||||
private final LocalDate countDate;
|
||||
private final String initiatedBy;
|
||||
private String completedBy;
|
||||
private InventoryCountStatus status;
|
||||
private final Instant createdAt;
|
||||
private final List<CountItem> countItems;
|
||||
|
|
@ -41,6 +43,7 @@ public class InventoryCount {
|
|||
StorageLocationId storageLocationId,
|
||||
LocalDate countDate,
|
||||
String initiatedBy,
|
||||
String completedBy,
|
||||
InventoryCountStatus status,
|
||||
Instant createdAt,
|
||||
List<CountItem> countItems
|
||||
|
|
@ -49,6 +52,7 @@ public class InventoryCount {
|
|||
this.storageLocationId = storageLocationId;
|
||||
this.countDate = countDate;
|
||||
this.initiatedBy = initiatedBy;
|
||||
this.completedBy = completedBy;
|
||||
this.status = status;
|
||||
this.createdAt = createdAt;
|
||||
this.countItems = new ArrayList<>(countItems);
|
||||
|
|
@ -90,7 +94,7 @@ public class InventoryCount {
|
|||
|
||||
return Result.success(new InventoryCount(
|
||||
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,
|
||||
LocalDate countDate,
|
||||
String initiatedBy,
|
||||
String completedBy,
|
||||
InventoryCountStatus status,
|
||||
Instant createdAt,
|
||||
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 ====================
|
||||
|
|
@ -145,6 +150,29 @@ public class InventoryCount {
|
|||
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) {
|
||||
if (status != InventoryCountStatus.COUNTING) {
|
||||
return Result.failure(new InventoryCountError.InvalidStatusTransition(
|
||||
|
|
@ -187,6 +215,7 @@ public class InventoryCount {
|
|||
public StorageLocationId storageLocationId() { return storageLocationId; }
|
||||
public LocalDate countDate() { return countDate; }
|
||||
public String initiatedBy() { return initiatedBy; }
|
||||
public String completedBy() { return completedBy; }
|
||||
public InventoryCountStatus status() { return status; }
|
||||
public Instant createdAt() { return createdAt; }
|
||||
public List<CountItem> countItems() { return Collections.unmodifiableList(countItems); }
|
||||
|
|
|
|||
|
|
@ -85,6 +85,11 @@ public sealed interface InventoryCountError {
|
|||
@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 {
|
||||
@Override public String code() { return "UNAUTHORIZED"; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package de.effigenix.infrastructure.config;
|
||||
|
||||
import de.effigenix.application.inventory.CompleteInventoryCount;
|
||||
import de.effigenix.application.inventory.CreateInventoryCount;
|
||||
import de.effigenix.application.inventory.GetInventoryCount;
|
||||
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.UpdateStorageLocation;
|
||||
import de.effigenix.application.usermanagement.AuditLogger;
|
||||
import de.effigenix.domain.inventory.InventoryCountReconciliationService;
|
||||
import de.effigenix.domain.inventory.InventoryCountRepository;
|
||||
import de.effigenix.domain.inventory.StockMovementRepository;
|
||||
import de.effigenix.domain.inventory.StockRepository;
|
||||
|
|
@ -184,4 +186,19 @@ public class InventoryUseCaseConfiguration {
|
|||
public RecordCountItem recordCountItem(InventoryCountRepository inventoryCountRepository, UnitOfWork unitOfWork, AuthorizationPort 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,7 +101,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
|
|||
int rows = jdbc.sql("""
|
||||
UPDATE inventory_counts
|
||||
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
|
||||
""")
|
||||
.param("id", inventoryCount.id().value())
|
||||
|
|
@ -110,8 +111,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
|
|||
|
||||
if (rows == 0) {
|
||||
jdbc.sql("""
|
||||
INSERT INTO inventory_counts (id, storage_location_id, count_date, initiated_by, status, created_at)
|
||||
VALUES (:id, :storageLocationId, :countDate, :initiatedBy, :status, :createdAt)
|
||||
INSERT INTO inventory_counts (id, storage_location_id, count_date, initiated_by, completed_by, status, created_at)
|
||||
VALUES (:id, :storageLocationId, :countDate, :initiatedBy, :completedBy, :status, :createdAt)
|
||||
""")
|
||||
.param("id", inventoryCount.id().value())
|
||||
.params(countParams(inventoryCount))
|
||||
|
|
@ -134,6 +135,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
|
|||
params.put("storageLocationId", count.storageLocationId().value());
|
||||
params.put("countDate", count.countDate());
|
||||
params.put("initiatedBy", count.initiatedBy());
|
||||
params.put("completedBy", count.completedBy());
|
||||
params.put("status", count.status().name());
|
||||
params.put("createdAt", count.createdAt().atOffset(ZoneOffset.UTC));
|
||||
return params;
|
||||
|
|
@ -206,7 +208,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
|
|||
|
||||
return InventoryCount.reconstitute(
|
||||
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")),
|
||||
rs.getObject("count_date", LocalDate.class),
|
||||
rs.getString("initiated_by"),
|
||||
rs.getString("completed_by"),
|
||||
InventoryCountStatus.valueOf(rs.getString("status")),
|
||||
rs.getObject("created_at", OffsetDateTime.class).toInstant(),
|
||||
List.of()
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
package de.effigenix.infrastructure.inventory.web.controller;
|
||||
|
||||
import de.effigenix.application.inventory.CompleteInventoryCount;
|
||||
import de.effigenix.application.inventory.CreateInventoryCount;
|
||||
import de.effigenix.application.inventory.GetInventoryCount;
|
||||
import de.effigenix.application.inventory.ListInventoryCounts;
|
||||
import de.effigenix.application.inventory.RecordCountItem;
|
||||
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.RecordCountItemCommand;
|
||||
import de.effigenix.domain.inventory.InventoryCountError;
|
||||
|
|
@ -34,17 +36,20 @@ public class InventoryCountController {
|
|||
private final ListInventoryCounts listInventoryCounts;
|
||||
private final StartInventoryCount startInventoryCount;
|
||||
private final RecordCountItem recordCountItem;
|
||||
private final CompleteInventoryCount completeInventoryCount;
|
||||
|
||||
public InventoryCountController(CreateInventoryCount createInventoryCount,
|
||||
GetInventoryCount getInventoryCount,
|
||||
ListInventoryCounts listInventoryCounts,
|
||||
StartInventoryCount startInventoryCount,
|
||||
RecordCountItem recordCountItem) {
|
||||
RecordCountItem recordCountItem,
|
||||
CompleteInventoryCount completeInventoryCount) {
|
||||
this.createInventoryCount = createInventoryCount;
|
||||
this.getInventoryCount = getInventoryCount;
|
||||
this.listInventoryCounts = listInventoryCounts;
|
||||
this.startInventoryCount = startInventoryCount;
|
||||
this.recordCountItem = recordCountItem;
|
||||
this.completeInventoryCount = completeInventoryCount;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
|
|
@ -135,6 +140,22 @@ public class InventoryCountController {
|
|||
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 ====================
|
||||
|
||||
public static class InventoryCountDomainErrorException extends RuntimeException {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ public record InventoryCountResponse(
|
|||
String storageLocationId,
|
||||
LocalDate countDate,
|
||||
String initiatedBy,
|
||||
String completedBy,
|
||||
String status,
|
||||
Instant createdAt,
|
||||
List<CountItemResponse> countItems
|
||||
|
|
@ -21,6 +22,7 @@ public record InventoryCountResponse(
|
|||
count.storageLocationId().value(),
|
||||
count.countDate(),
|
||||
count.initiatedBy(),
|
||||
count.completedBy(),
|
||||
count.status().name(),
|
||||
count.createdAt(),
|
||||
count.countItems().stream()
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ public final class InventoryErrorHttpStatusMapper {
|
|||
case InventoryCountError.NoCountItems e -> 409;
|
||||
case InventoryCountError.IncompleteCountItems e -> 409;
|
||||
case InventoryCountError.SamePersonViolation e -> 409;
|
||||
case InventoryCountError.StockNotFoundForArticle e -> 409;
|
||||
case InventoryCountError.CountDateInFuture e -> 400;
|
||||
case InventoryCountError.InvalidStorageLocationId e -> 400;
|
||||
case InventoryCountError.InvalidCountDate e -> 400;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -42,5 +42,6 @@
|
|||
<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/036-add-inventory-counts-composite-index.xml"/>
|
||||
<include file="db/changelog/changes/037-add-completed-by-to-inventory-counts.xml"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -42,6 +42,7 @@ class GetInventoryCountTest {
|
|||
StorageLocationId.of("location-1"),
|
||||
LocalDate.now(),
|
||||
"user-1",
|
||||
null,
|
||||
InventoryCountStatus.OPEN,
|
||||
Instant.now(),
|
||||
List.of()
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ class ListInventoryCountsTest {
|
|||
StorageLocationId.of("location-1"),
|
||||
LocalDate.now(),
|
||||
"user-1",
|
||||
null,
|
||||
InventoryCountStatus.OPEN,
|
||||
Instant.now(),
|
||||
List.of()
|
||||
|
|
@ -52,6 +53,7 @@ class ListInventoryCountsTest {
|
|||
StorageLocationId.of("location-2"),
|
||||
LocalDate.now(),
|
||||
"user-1",
|
||||
null,
|
||||
InventoryCountStatus.COMPLETED,
|
||||
Instant.now(),
|
||||
List.of()
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ class RecordCountItemTest {
|
|||
private InventoryCount createCountingCount() {
|
||||
return InventoryCount.reconstitute(
|
||||
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(
|
||||
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
|
||||
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null),
|
||||
|
|
@ -226,7 +226,7 @@ class RecordCountItemTest {
|
|||
void shouldFailWhenStatusIsOpen() {
|
||||
var openCount = InventoryCount.reconstitute(
|
||||
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(
|
||||
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
|
||||
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null)
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class StartInventoryCountTest {
|
|||
);
|
||||
return InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", InventoryCountStatus.OPEN,
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
|
||||
Instant.now(), items
|
||||
);
|
||||
}
|
||||
|
|
@ -110,7 +110,7 @@ class StartInventoryCountTest {
|
|||
void shouldFailWhenNoItems() {
|
||||
var emptyCount = InventoryCount.reconstitute(
|
||||
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()
|
||||
);
|
||||
|
||||
|
|
@ -128,7 +128,7 @@ class StartInventoryCountTest {
|
|||
void shouldFailWhenAlreadyCounting() {
|
||||
var countingCount = InventoryCount.reconstitute(
|
||||
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(
|
||||
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
|
||||
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
count.isActive();
|
||||
count.countItems();
|
||||
|
|
@ -54,6 +65,7 @@ class InventoryCountFuzzTest {
|
|||
count.countDate();
|
||||
count.status();
|
||||
count.initiatedBy();
|
||||
count.completedBy();
|
||||
count.createdAt();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -258,6 +258,7 @@ class InventoryCountTest {
|
|||
StorageLocationId.of("location-1"),
|
||||
LocalDate.now(),
|
||||
"user-1",
|
||||
null,
|
||||
InventoryCountStatus.COUNTING,
|
||||
java.time.Instant.now(),
|
||||
java.util.List.of()
|
||||
|
|
@ -381,7 +382,7 @@ class InventoryCountTest {
|
|||
private InventoryCount reconstitute(InventoryCountStatus status) {
|
||||
return InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", status,
|
||||
LocalDate.now(), "user-1", null, status,
|
||||
Instant.now(), List.of()
|
||||
);
|
||||
}
|
||||
|
|
@ -452,7 +453,7 @@ class InventoryCountTest {
|
|||
private InventoryCount reconstitute(InventoryCountStatus status, List<CountItem> items) {
|
||||
return InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", status,
|
||||
LocalDate.now(), "user-1", null, status,
|
||||
Instant.now(), items
|
||||
);
|
||||
}
|
||||
|
|
@ -525,7 +526,7 @@ class InventoryCountTest {
|
|||
var items = createCountItems();
|
||||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", InventoryCountStatus.COMPLETED,
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED,
|
||||
Instant.now(), items
|
||||
);
|
||||
var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM);
|
||||
|
|
@ -599,7 +600,7 @@ class InventoryCountTest {
|
|||
);
|
||||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING,
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), items
|
||||
);
|
||||
var actualQty = Quantity.reconstitute(new BigDecimal("3.0"), UnitOfMeasure.LITER);
|
||||
|
|
@ -702,7 +703,7 @@ class InventoryCountTest {
|
|||
void countingShouldBeActive() {
|
||||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
|
||||
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING,
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), List.of()
|
||||
);
|
||||
assertThat(count.isActive()).isTrue();
|
||||
|
|
@ -713,7 +714,7 @@ class InventoryCountTest {
|
|||
void completedShouldNotBeActive() {
|
||||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
|
||||
LocalDate.now(), "user-1", InventoryCountStatus.COMPLETED,
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED,
|
||||
Instant.now(), List.of()
|
||||
);
|
||||
assertThat(count.isActive()).isFalse();
|
||||
|
|
@ -724,7 +725,7 @@ class InventoryCountTest {
|
|||
void cancelledShouldNotBeActive() {
|
||||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
|
||||
LocalDate.now(), "user-1", InventoryCountStatus.CANCELLED,
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED,
|
||||
Instant.now(), List.of()
|
||||
);
|
||||
assertThat(count.isActive()).isFalse();
|
||||
|
|
@ -753,7 +754,7 @@ class InventoryCountTest {
|
|||
|
||||
var count = InventoryCount.reconstitute(
|
||||
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
|
||||
);
|
||||
|
||||
|
|
@ -787,12 +788,12 @@ class InventoryCountTest {
|
|||
void sameIdShouldBeEqual() {
|
||||
var a = InventoryCount.reconstitute(
|
||||
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()
|
||||
);
|
||||
var b = InventoryCount.reconstitute(
|
||||
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()
|
||||
);
|
||||
|
||||
|
|
@ -805,12 +806,12 @@ class InventoryCountTest {
|
|||
void differentIdShouldNotBeEqual() {
|
||||
var a = InventoryCount.reconstitute(
|
||||
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()
|
||||
);
|
||||
var b = InventoryCount.reconstitute(
|
||||
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()
|
||||
);
|
||||
|
||||
|
|
@ -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 ====================
|
||||
|
||||
private InventoryCount createOpenCount() {
|
||||
|
|
@ -830,7 +1035,7 @@ class InventoryCountTest {
|
|||
var items = createCountItems();
|
||||
return InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", InventoryCountStatus.COUNTING,
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), items
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import de.effigenix.infrastructure.AbstractIntegrationTest;
|
|||
import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
|
|
@ -22,11 +23,13 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||
* Abgedeckte Testfälle:
|
||||
* - US-6.1 – Inventur anlegen und Zählpositionen befüllen
|
||||
* - US-6.2 – Inventur durchführen (Ist-Mengen erfassen)
|
||||
* - US-6.3 – Inventur abschließen mit Ausgleichsbuchungen
|
||||
*/
|
||||
@DisplayName("InventoryCount Controller Integration Tests")
|
||||
class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private String adminToken;
|
||||
private String completerToken;
|
||||
private String viewerToken;
|
||||
private String storageLocationId;
|
||||
|
||||
|
|
@ -36,9 +39,11 @@ class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
|
|||
String viewerRoleId = createRole(RoleName.PRODUCTION_WORKER, "Viewer");
|
||||
|
||||
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");
|
||||
|
||||
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");
|
||||
|
||||
storageLocationId = createStorageLocation();
|
||||
|
|
@ -515,6 +520,181 @@ class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
|
|||
.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 ====================
|
||||
|
||||
private String createInventoryCountWithStock() throws Exception {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue