mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 08:29:36 +01:00
feat(inventory): Inventur abbrechen und nach Status filtern (US-6.4)
Ermöglicht das Abbrechen von Inventuren (OPEN/COUNTING → CANCELLED) mit Pflicht-Begründung sowie das Filtern der Inventurliste nach Status.
This commit is contained in:
parent
58ed0a3810
commit
a0ebf46329
28 changed files with 798 additions and 47 deletions
|
|
@ -0,0 +1,70 @@
|
|||
package de.effigenix.application.inventory;
|
||||
|
||||
import de.effigenix.application.inventory.command.CancelInventoryCountCommand;
|
||||
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;
|
||||
|
||||
public class CancelInventoryCount {
|
||||
|
||||
private final InventoryCountRepository inventoryCountRepository;
|
||||
private final AuthorizationPort authorizationPort;
|
||||
private final UnitOfWork unitOfWork;
|
||||
|
||||
public CancelInventoryCount(
|
||||
InventoryCountRepository inventoryCountRepository,
|
||||
AuthorizationPort authorizationPort,
|
||||
UnitOfWork unitOfWork
|
||||
) {
|
||||
this.inventoryCountRepository = inventoryCountRepository;
|
||||
this.authorizationPort = authorizationPort;
|
||||
this.unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
public Result<InventoryCountError, InventoryCount> execute(CancelInventoryCountCommand cmd, ActorId actorId) {
|
||||
if (!authorizationPort.can(actorId, InventoryAction.INVENTORY_COUNT_WRITE)) {
|
||||
return Result.failure(new InventoryCountError.Unauthorized("Not authorized to cancel inventory counts"));
|
||||
}
|
||||
|
||||
if (cmd.inventoryCountId() == null || cmd.inventoryCountId().isBlank()) {
|
||||
return Result.failure(new InventoryCountError.InvalidInventoryCountId("must not be blank"));
|
||||
}
|
||||
|
||||
InventoryCountId countId;
|
||||
try {
|
||||
countId = InventoryCountId.of(cmd.inventoryCountId());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.failure(new InventoryCountError.InvalidInventoryCountId(e.getMessage()));
|
||||
}
|
||||
|
||||
InventoryCount inventoryCount;
|
||||
switch (inventoryCountRepository.findById(countId)) {
|
||||
case Result.Failure(var err) -> {
|
||||
return Result.failure(new InventoryCountError.RepositoryFailure(err.message()));
|
||||
}
|
||||
case Result.Success(var opt) -> {
|
||||
if (opt.isEmpty()) {
|
||||
return Result.failure(new InventoryCountError.InventoryCountNotFound(cmd.inventoryCountId()));
|
||||
}
|
||||
inventoryCount = opt.get();
|
||||
}
|
||||
}
|
||||
|
||||
switch (inventoryCount.cancel(cmd.reason())) {
|
||||
case Result.Failure(var err) -> { return Result.failure(err); }
|
||||
case Result.Success(var ignored) -> { }
|
||||
}
|
||||
|
||||
return unitOfWork.executeAtomically(() -> {
|
||||
switch (inventoryCountRepository.save(inventoryCount)) {
|
||||
case Result.Failure(var err) -> {
|
||||
return Result.failure(new InventoryCountError.RepositoryFailure(err.message()));
|
||||
}
|
||||
case Result.Success(var ignored) -> { }
|
||||
}
|
||||
return Result.success(inventoryCount);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -18,11 +18,20 @@ public class ListInventoryCounts {
|
|||
this.authPort = authPort;
|
||||
}
|
||||
|
||||
public Result<InventoryCountError, List<InventoryCount>> execute(String storageLocationId, ActorId actorId) {
|
||||
public Result<InventoryCountError, List<InventoryCount>> execute(String storageLocationId, String status, ActorId actorId) {
|
||||
if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_READ)) {
|
||||
return Result.failure(new InventoryCountError.Unauthorized("Not authorized to view inventory counts"));
|
||||
}
|
||||
|
||||
InventoryCountStatus parsedStatus = null;
|
||||
if (status != null) {
|
||||
try {
|
||||
parsedStatus = InventoryCountStatus.valueOf(status);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Result.failure(new InventoryCountError.InvalidStatus(status));
|
||||
}
|
||||
}
|
||||
|
||||
if (storageLocationId != null) {
|
||||
StorageLocationId locId;
|
||||
try {
|
||||
|
|
@ -30,7 +39,18 @@ public class ListInventoryCounts {
|
|||
} catch (IllegalArgumentException e) {
|
||||
return Result.failure(new InventoryCountError.InvalidStorageLocationId(e.getMessage()));
|
||||
}
|
||||
return mapResult(inventoryCountRepository.findByStorageLocationId(locId));
|
||||
var result = mapResult(inventoryCountRepository.findByStorageLocationId(locId));
|
||||
if (parsedStatus != null) {
|
||||
final InventoryCountStatus filterStatus = parsedStatus;
|
||||
return result.map(counts -> counts.stream()
|
||||
.filter(c -> c.status() == filterStatus)
|
||||
.toList());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (parsedStatus != null) {
|
||||
return mapResult(inventoryCountRepository.findByStatus(parsedStatus));
|
||||
}
|
||||
|
||||
return mapResult(inventoryCountRepository.findAll());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
package de.effigenix.application.inventory.command;
|
||||
|
||||
public record CancelInventoryCountCommand(String inventoryCountId, String reason) {}
|
||||
|
|
@ -26,6 +26,7 @@ import java.util.Objects;
|
|||
* - 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)
|
||||
* - cancel only in status OPEN or COUNTING, requires non-blank reason → CANCELLED
|
||||
*/
|
||||
public class InventoryCount {
|
||||
|
||||
|
|
@ -35,6 +36,7 @@ public class InventoryCount {
|
|||
private final String initiatedBy;
|
||||
private String completedBy;
|
||||
private InventoryCountStatus status;
|
||||
private String cancellationReason;
|
||||
private final Instant createdAt;
|
||||
private final List<CountItem> countItems;
|
||||
|
||||
|
|
@ -45,6 +47,7 @@ public class InventoryCount {
|
|||
String initiatedBy,
|
||||
String completedBy,
|
||||
InventoryCountStatus status,
|
||||
String cancellationReason,
|
||||
Instant createdAt,
|
||||
List<CountItem> countItems
|
||||
) {
|
||||
|
|
@ -54,6 +57,7 @@ public class InventoryCount {
|
|||
this.initiatedBy = initiatedBy;
|
||||
this.completedBy = completedBy;
|
||||
this.status = status;
|
||||
this.cancellationReason = cancellationReason;
|
||||
this.createdAt = createdAt;
|
||||
this.countItems = new ArrayList<>(countItems);
|
||||
}
|
||||
|
|
@ -94,7 +98,7 @@ public class InventoryCount {
|
|||
|
||||
return Result.success(new InventoryCount(
|
||||
InventoryCountId.generate(), storageLocationId, countDate,
|
||||
draft.initiatedBy(), null, InventoryCountStatus.OPEN, Instant.now(), List.of()
|
||||
draft.initiatedBy(), null, InventoryCountStatus.OPEN, null, Instant.now(), List.of()
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -108,10 +112,11 @@ public class InventoryCount {
|
|||
String initiatedBy,
|
||||
String completedBy,
|
||||
InventoryCountStatus status,
|
||||
String cancellationReason,
|
||||
Instant createdAt,
|
||||
List<CountItem> countItems
|
||||
) {
|
||||
return new InventoryCount(id, storageLocationId, countDate, initiatedBy, completedBy, status, createdAt, countItems);
|
||||
return new InventoryCount(id, storageLocationId, countDate, initiatedBy, completedBy, status, cancellationReason, createdAt, countItems);
|
||||
}
|
||||
|
||||
// ==================== Count Item Management ====================
|
||||
|
|
@ -173,6 +178,19 @@ public class InventoryCount {
|
|||
return Result.success(null);
|
||||
}
|
||||
|
||||
public Result<InventoryCountError, Void> cancel(String reason) {
|
||||
if (reason == null || reason.isBlank()) {
|
||||
return Result.failure(new InventoryCountError.CancellationReasonRequired());
|
||||
}
|
||||
if (status != InventoryCountStatus.OPEN && status != InventoryCountStatus.COUNTING) {
|
||||
return Result.failure(new InventoryCountError.InvalidStatusTransition(
|
||||
status.name(), InventoryCountStatus.CANCELLED.name()));
|
||||
}
|
||||
this.cancellationReason = reason;
|
||||
this.status = InventoryCountStatus.CANCELLED;
|
||||
return Result.success(null);
|
||||
}
|
||||
|
||||
public Result<InventoryCountError, Void> updateCountItem(CountItemId itemId, Quantity actualQuantity) {
|
||||
if (status != InventoryCountStatus.COUNTING) {
|
||||
return Result.failure(new InventoryCountError.InvalidStatusTransition(
|
||||
|
|
@ -217,6 +235,7 @@ public class InventoryCount {
|
|||
public String initiatedBy() { return initiatedBy; }
|
||||
public String completedBy() { return completedBy; }
|
||||
public InventoryCountStatus status() { return status; }
|
||||
public String cancellationReason() { return cancellationReason; }
|
||||
public Instant createdAt() { return createdAt; }
|
||||
public List<CountItem> countItems() { return Collections.unmodifiableList(countItems); }
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,16 @@ public sealed interface InventoryCountError {
|
|||
@Override public String message() { return "No stock found for article: " + articleId; }
|
||||
}
|
||||
|
||||
record CancellationReasonRequired() implements InventoryCountError {
|
||||
@Override public String code() { return "CANCELLATION_REASON_REQUIRED"; }
|
||||
@Override public String message() { return "Cancellation reason is required"; }
|
||||
}
|
||||
|
||||
record InvalidStatus(String status) implements InventoryCountError {
|
||||
@Override public String code() { return "INVALID_STATUS"; }
|
||||
@Override public String message() { return "Invalid status: " + status; }
|
||||
}
|
||||
|
||||
record Unauthorized(String message) implements InventoryCountError {
|
||||
@Override public String code() { return "UNAUTHORIZED"; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ public interface InventoryCountRepository {
|
|||
|
||||
Result<RepositoryError, List<InventoryCount>> findByStorageLocationId(StorageLocationId storageLocationId);
|
||||
|
||||
Result<RepositoryError, List<InventoryCount>> findByStatus(InventoryCountStatus status);
|
||||
|
||||
Result<RepositoryError, Boolean> existsActiveByStorageLocationId(StorageLocationId storageLocationId);
|
||||
|
||||
Result<RepositoryError, Void> save(InventoryCount inventoryCount);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package de.effigenix.infrastructure.config;
|
||||
|
||||
import de.effigenix.application.inventory.CancelInventoryCount;
|
||||
import de.effigenix.application.inventory.CompleteInventoryCount;
|
||||
import de.effigenix.application.inventory.CreateInventoryCount;
|
||||
import de.effigenix.application.inventory.GetInventoryCount;
|
||||
|
|
@ -187,6 +188,11 @@ public class InventoryUseCaseConfiguration {
|
|||
return new RecordCountItem(inventoryCountRepository, unitOfWork, authorizationPort);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CancelInventoryCount cancelInventoryCount(InventoryCountRepository inventoryCountRepository, UnitOfWork unitOfWork, AuthorizationPort authorizationPort) {
|
||||
return new CancelInventoryCount(inventoryCountRepository, authorizationPort, unitOfWork);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public InventoryCountReconciliationService inventoryCountReconciliationService() {
|
||||
return new InventoryCountReconciliationService();
|
||||
|
|
|
|||
|
|
@ -77,6 +77,20 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, List<InventoryCount>> findByStatus(InventoryCountStatus status) {
|
||||
try {
|
||||
var counts = jdbc.sql("SELECT * FROM inventory_counts WHERE status = :status ORDER BY created_at DESC")
|
||||
.param("status", status.name())
|
||||
.query(this::mapCountRow)
|
||||
.list();
|
||||
return Result.success(loadChildrenForAll(counts));
|
||||
} catch (Exception e) {
|
||||
logger.warn("Database error in findByStatus", e);
|
||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Boolean> existsActiveByStorageLocationId(StorageLocationId storageLocationId) {
|
||||
try {
|
||||
|
|
@ -102,7 +116,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
|
|||
UPDATE inventory_counts
|
||||
SET storage_location_id = :storageLocationId, count_date = :countDate,
|
||||
initiated_by = :initiatedBy, completed_by = :completedBy,
|
||||
status = :status, created_at = :createdAt
|
||||
status = :status, cancellation_reason = :cancellationReason,
|
||||
created_at = :createdAt
|
||||
WHERE id = :id
|
||||
""")
|
||||
.param("id", inventoryCount.id().value())
|
||||
|
|
@ -111,8 +126,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
|
|||
|
||||
if (rows == 0) {
|
||||
jdbc.sql("""
|
||||
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)
|
||||
INSERT INTO inventory_counts (id, storage_location_id, count_date, initiated_by, completed_by, status, cancellation_reason, created_at)
|
||||
VALUES (:id, :storageLocationId, :countDate, :initiatedBy, :completedBy, :status, :cancellationReason, :createdAt)
|
||||
""")
|
||||
.param("id", inventoryCount.id().value())
|
||||
.params(countParams(inventoryCount))
|
||||
|
|
@ -137,6 +152,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
|
|||
params.put("initiatedBy", count.initiatedBy());
|
||||
params.put("completedBy", count.completedBy());
|
||||
params.put("status", count.status().name());
|
||||
params.put("cancellationReason", count.cancellationReason());
|
||||
params.put("createdAt", count.createdAt().atOffset(ZoneOffset.UTC));
|
||||
return params;
|
||||
}
|
||||
|
|
@ -208,7 +224,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
|
|||
|
||||
return InventoryCount.reconstitute(
|
||||
count.id(), count.storageLocationId(), count.countDate(),
|
||||
count.initiatedBy(), count.completedBy(), count.status(), count.createdAt(), items
|
||||
count.initiatedBy(), count.completedBy(), count.status(),
|
||||
count.cancellationReason(), count.createdAt(), items
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +245,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
|
|||
rs.getString("initiated_by"),
|
||||
rs.getString("completed_by"),
|
||||
InventoryCountStatus.valueOf(rs.getString("status")),
|
||||
rs.getString("cancellation_reason"),
|
||||
rs.getObject("created_at", OffsetDateTime.class).toInstant(),
|
||||
List.of()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,15 +1,18 @@
|
|||
package de.effigenix.infrastructure.inventory.web.controller;
|
||||
|
||||
import de.effigenix.application.inventory.CancelInventoryCount;
|
||||
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.CancelInventoryCountCommand;
|
||||
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;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.CancelInventoryCountRequest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.InventoryCountResponse;
|
||||
import de.effigenix.infrastructure.inventory.web.dto.RecordCountItemRequest;
|
||||
|
|
@ -39,6 +42,7 @@ public class InventoryCountController {
|
|||
private final StartInventoryCount startInventoryCount;
|
||||
private final RecordCountItem recordCountItem;
|
||||
private final CompleteInventoryCount completeInventoryCount;
|
||||
private final CancelInventoryCount cancelInventoryCount;
|
||||
private final UserLookupPort userLookup;
|
||||
|
||||
public InventoryCountController(CreateInventoryCount createInventoryCount,
|
||||
|
|
@ -47,6 +51,7 @@ public class InventoryCountController {
|
|||
StartInventoryCount startInventoryCount,
|
||||
RecordCountItem recordCountItem,
|
||||
CompleteInventoryCount completeInventoryCount,
|
||||
CancelInventoryCount cancelInventoryCount,
|
||||
UserLookupPort userLookup) {
|
||||
this.createInventoryCount = createInventoryCount;
|
||||
this.getInventoryCount = getInventoryCount;
|
||||
|
|
@ -54,6 +59,7 @@ public class InventoryCountController {
|
|||
this.startInventoryCount = startInventoryCount;
|
||||
this.recordCountItem = recordCountItem;
|
||||
this.completeInventoryCount = completeInventoryCount;
|
||||
this.cancelInventoryCount = cancelInventoryCount;
|
||||
this.userLookup = userLookup;
|
||||
}
|
||||
|
||||
|
|
@ -92,9 +98,10 @@ public class InventoryCountController {
|
|||
@PreAuthorize("hasAuthority('INVENTORY_COUNT_READ')")
|
||||
public ResponseEntity<List<InventoryCountResponse>> listInventoryCounts(
|
||||
@RequestParam(required = false) String storageLocationId,
|
||||
@RequestParam(required = false) String status,
|
||||
Authentication authentication
|
||||
) {
|
||||
return switch (listInventoryCounts.execute(storageLocationId, ActorId.of(authentication.getName()))) {
|
||||
return switch (listInventoryCounts.execute(storageLocationId, status, ActorId.of(authentication.getName()))) {
|
||||
case Result.Failure(var err) -> throw new InventoryCountDomainErrorException(err);
|
||||
case Result.Success(var counts) -> {
|
||||
var responses = counts.stream()
|
||||
|
|
@ -145,6 +152,20 @@ public class InventoryCountController {
|
|||
};
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/cancel")
|
||||
@PreAuthorize("hasAuthority('INVENTORY_COUNT_WRITE')")
|
||||
public ResponseEntity<InventoryCountResponse> cancelInventoryCount(
|
||||
@PathVariable String id,
|
||||
@Valid @RequestBody CancelInventoryCountRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
var cmd = new CancelInventoryCountCommand(id, request.reason());
|
||||
return switch (cancelInventoryCount.execute(cmd, ActorId.of(authentication.getName()))) {
|
||||
case Result.Failure(var err) -> throw new InventoryCountDomainErrorException(err);
|
||||
case Result.Success(var count) -> ResponseEntity.ok(InventoryCountResponse.from(count, userLookup));
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Exception Wrapper ====================
|
||||
|
||||
public static class InventoryCountDomainErrorException extends RuntimeException {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
package de.effigenix.infrastructure.inventory.web.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record CancelInventoryCountRequest(@NotBlank String reason) {}
|
||||
|
|
@ -14,6 +14,7 @@ public record InventoryCountResponse(
|
|||
String initiatedBy,
|
||||
String completedBy,
|
||||
String status,
|
||||
String cancellationReason,
|
||||
Instant createdAt,
|
||||
List<CountItemResponse> countItems
|
||||
) {
|
||||
|
|
@ -27,6 +28,7 @@ public record InventoryCountResponse(
|
|||
? userLookup.resolveUsername(count.completedBy()).orElse(count.completedBy())
|
||||
: null,
|
||||
count.status().name(),
|
||||
count.cancellationReason(),
|
||||
count.createdAt(),
|
||||
count.countItems().stream()
|
||||
.map(CountItemResponse::from)
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ public final class InventoryErrorHttpStatusMapper {
|
|||
case InventoryCountError.InvalidArticleId e -> 400;
|
||||
case InventoryCountError.InvalidQuantity e -> 400;
|
||||
case InventoryCountError.InvalidInventoryCountId e -> 400;
|
||||
case InventoryCountError.CancellationReasonRequired e -> 400;
|
||||
case InventoryCountError.InvalidStatus e -> 400;
|
||||
case InventoryCountError.Unauthorized e -> 403;
|
||||
case InventoryCountError.RepositoryFailure e -> 500;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package de.effigenix.infrastructure.stub;
|
|||
import de.effigenix.domain.inventory.InventoryCount;
|
||||
import de.effigenix.domain.inventory.InventoryCountId;
|
||||
import de.effigenix.domain.inventory.InventoryCountRepository;
|
||||
import de.effigenix.domain.inventory.InventoryCountStatus;
|
||||
import de.effigenix.domain.inventory.StorageLocationId;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
import de.effigenix.shared.common.Result;
|
||||
|
|
@ -34,6 +35,11 @@ public class StubInventoryCountRepository implements InventoryCountRepository {
|
|||
return Result.failure(STUB_ERROR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, List<InventoryCount>> findByStatus(InventoryCountStatus status) {
|
||||
return Result.failure(STUB_ERROR);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<RepositoryError, Boolean> existsActiveByStorageLocationId(StorageLocationId storageLocationId) {
|
||||
return Result.failure(STUB_ERROR);
|
||||
|
|
|
|||
|
|
@ -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="038-add-cancellation-reason-to-inventory-counts" author="effigenix">
|
||||
<addColumn tableName="inventory_counts">
|
||||
<column name="cancellation_reason" type="VARCHAR(500)">
|
||||
<constraints nullable="true"/>
|
||||
</column>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
@ -43,5 +43,6 @@
|
|||
<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"/>
|
||||
<include file="db/changelog/changes/038-add-cancellation-reason-to-inventory-counts.xml"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,170 @@
|
|||
package de.effigenix.application.inventory;
|
||||
|
||||
import de.effigenix.application.inventory.command.CancelInventoryCountCommand;
|
||||
import de.effigenix.domain.inventory.*;
|
||||
import de.effigenix.shared.common.RepositoryError;
|
||||
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 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.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("CancelInventoryCount Use Case")
|
||||
class CancelInventoryCountTest {
|
||||
|
||||
@Mock private InventoryCountRepository inventoryCountRepository;
|
||||
@Mock private AuthorizationPort authPort;
|
||||
@Mock private UnitOfWork unitOfWork;
|
||||
|
||||
private CancelInventoryCount cancelInventoryCount;
|
||||
private ActorId performedBy;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
cancelInventoryCount = new CancelInventoryCount(inventoryCountRepository, authPort, unitOfWork);
|
||||
performedBy = ActorId.of("admin-user");
|
||||
lenient().when(unitOfWork.executeAtomically(any())).thenAnswer(inv -> ((Supplier<?>) inv.getArgument(0)).get());
|
||||
}
|
||||
|
||||
private CancelInventoryCountCommand validCommand() {
|
||||
return new CancelInventoryCountCommand("count-1", "Nicht mehr benötigt");
|
||||
}
|
||||
|
||||
private InventoryCount countWithStatus(InventoryCountStatus status) {
|
||||
return InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, status,
|
||||
null, Instant.now(), new ArrayList<>()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should cancel OPEN inventory count")
|
||||
void should_Cancel_OpenCount() {
|
||||
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
|
||||
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
|
||||
.thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.OPEN))));
|
||||
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
var result = cancelInventoryCount.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().status()).isEqualTo(InventoryCountStatus.CANCELLED);
|
||||
assertThat(result.unsafeGetValue().cancellationReason()).isEqualTo("Nicht mehr benötigt");
|
||||
verify(inventoryCountRepository).save(any(InventoryCount.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should cancel COUNTING inventory count")
|
||||
void should_Cancel_CountingCount() {
|
||||
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
|
||||
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
|
||||
.thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.COUNTING))));
|
||||
when(inventoryCountRepository.save(any())).thenReturn(Result.success(null));
|
||||
|
||||
var result = cancelInventoryCount.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue().status()).isEqualTo(InventoryCountStatus.CANCELLED);
|
||||
assertThat(result.unsafeGetValue().cancellationReason()).isEqualTo("Nicht mehr benötigt");
|
||||
verify(inventoryCountRepository).save(any(InventoryCount.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when status is COMPLETED")
|
||||
void should_Fail_When_Completed() {
|
||||
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
|
||||
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
|
||||
.thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.COMPLETED))));
|
||||
|
||||
var result = cancelInventoryCount.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
|
||||
verify(inventoryCountRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when status is already CANCELLED")
|
||||
void should_Fail_When_AlreadyCancelled() {
|
||||
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
|
||||
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
|
||||
.thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.CANCELLED))));
|
||||
|
||||
var result = cancelInventoryCount.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
|
||||
verify(inventoryCountRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when inventory count not found")
|
||||
void should_Fail_When_NotFound() {
|
||||
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
|
||||
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
|
||||
.thenReturn(Result.success(Optional.empty()));
|
||||
|
||||
var result = cancelInventoryCount.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InventoryCountNotFound.class);
|
||||
verify(inventoryCountRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when unauthorized")
|
||||
void should_Fail_When_Unauthorized() {
|
||||
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(false);
|
||||
|
||||
var result = cancelInventoryCount.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.Unauthorized.class);
|
||||
verify(inventoryCountRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when repository findById returns error")
|
||||
void should_Fail_When_RepositoryError() {
|
||||
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
|
||||
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
|
||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||
|
||||
var result = cancelInventoryCount.execute(validCommand(), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when reason is blank")
|
||||
void should_Fail_When_ReasonBlank() {
|
||||
when(authPort.can(performedBy, InventoryAction.INVENTORY_COUNT_WRITE)).thenReturn(true);
|
||||
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
|
||||
.thenReturn(Result.success(Optional.of(countWithStatus(InventoryCountStatus.OPEN))));
|
||||
|
||||
var result = cancelInventoryCount.execute(
|
||||
new CancelInventoryCountCommand("count-1", " "), performedBy);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CancellationReasonRequired.class);
|
||||
verify(inventoryCountRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
|
|
@ -237,7 +237,7 @@ class CompleteInventoryCountTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), items
|
||||
null, Instant.now(), items
|
||||
);
|
||||
|
||||
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
|
||||
|
|
@ -256,7 +256,7 @@ class CompleteInventoryCountTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
|
||||
Instant.now(), new ArrayList<>()
|
||||
null, Instant.now(), new ArrayList<>()
|
||||
);
|
||||
|
||||
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
|
||||
|
|
@ -372,7 +372,7 @@ class CompleteInventoryCountTest {
|
|||
return InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), items
|
||||
null, Instant.now(), items
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class GetInventoryCountTest {
|
|||
"user-1",
|
||||
null,
|
||||
InventoryCountStatus.OPEN,
|
||||
null,
|
||||
Instant.now(),
|
||||
List.of()
|
||||
);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class ListInventoryCountsTest {
|
|||
"user-1",
|
||||
null,
|
||||
InventoryCountStatus.OPEN,
|
||||
null,
|
||||
Instant.now(),
|
||||
List.of()
|
||||
);
|
||||
|
|
@ -55,6 +56,7 @@ class ListInventoryCountsTest {
|
|||
"user-1",
|
||||
null,
|
||||
InventoryCountStatus.COMPLETED,
|
||||
null,
|
||||
Instant.now(),
|
||||
List.of()
|
||||
);
|
||||
|
|
@ -65,7 +67,7 @@ class ListInventoryCountsTest {
|
|||
void shouldReturnAllCountsWhenNoFilter() {
|
||||
when(inventoryCountRepository.findAll()).thenReturn(Result.success(List.of(count1, count2)));
|
||||
|
||||
var result = listInventoryCounts.execute(null, actorId);
|
||||
var result = listInventoryCounts.execute(null, null, actorId);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue()).hasSize(2);
|
||||
|
|
@ -78,7 +80,7 @@ class ListInventoryCountsTest {
|
|||
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
|
||||
.thenReturn(Result.success(List.of(count1)));
|
||||
|
||||
var result = listInventoryCounts.execute("location-1", actorId);
|
||||
var result = listInventoryCounts.execute("location-1", null, actorId);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue()).hasSize(1);
|
||||
|
|
@ -92,7 +94,7 @@ class ListInventoryCountsTest {
|
|||
when(inventoryCountRepository.findAll())
|
||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||
|
||||
var result = listInventoryCounts.execute(null, actorId);
|
||||
var result = listInventoryCounts.execute(null, null, actorId);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
|
||||
|
|
@ -104,7 +106,7 @@ class ListInventoryCountsTest {
|
|||
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
|
||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||
|
||||
var result = listInventoryCounts.execute("location-1", actorId);
|
||||
var result = listInventoryCounts.execute("location-1", null, actorId);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
|
||||
|
|
@ -116,7 +118,7 @@ class ListInventoryCountsTest {
|
|||
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("unknown")))
|
||||
.thenReturn(Result.success(List.of()));
|
||||
|
||||
var result = listInventoryCounts.execute("unknown", actorId);
|
||||
var result = listInventoryCounts.execute("unknown", null, actorId);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue()).isEmpty();
|
||||
|
|
@ -125,9 +127,57 @@ class ListInventoryCountsTest {
|
|||
@Test
|
||||
@DisplayName("should fail with InvalidStorageLocationId for blank storageLocationId")
|
||||
void shouldFailWhenBlankStorageLocationId() {
|
||||
var result = listInventoryCounts.execute(" ", actorId);
|
||||
var result = listInventoryCounts.execute(" ", null, actorId);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should filter by status only")
|
||||
void shouldFilterByStatusOnly() {
|
||||
when(inventoryCountRepository.findByStatus(InventoryCountStatus.OPEN))
|
||||
.thenReturn(Result.success(List.of(count1)));
|
||||
|
||||
var result = listInventoryCounts.execute(null, "OPEN", actorId);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue()).hasSize(1);
|
||||
verify(inventoryCountRepository).findByStatus(InventoryCountStatus.OPEN);
|
||||
verify(inventoryCountRepository, never()).findAll();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should filter by storageLocationId and status")
|
||||
void shouldFilterByStorageLocationIdAndStatus() {
|
||||
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
|
||||
.thenReturn(Result.success(List.of(count1, count2)));
|
||||
|
||||
var result = listInventoryCounts.execute("location-1", "OPEN", actorId);
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(result.unsafeGetValue()).hasSize(1);
|
||||
assertThat(result.unsafeGetValue().getFirst().status()).isEqualTo(InventoryCountStatus.OPEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail with InvalidStatus for invalid status string")
|
||||
void shouldFailWhenInvalidStatus() {
|
||||
var result = listInventoryCounts.execute(null, "INVALID", actorId);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatus.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when unauthorized")
|
||||
void shouldFailWhenUnauthorized() {
|
||||
reset(authPort);
|
||||
when(authPort.can(any(ActorId.class), any())).thenReturn(false);
|
||||
|
||||
var result = listInventoryCounts.execute(null, null, actorId);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.Unauthorized.class);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ class RecordCountItemTest {
|
|||
return InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), List.of(
|
||||
null, Instant.now(), List.of(
|
||||
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
|
||||
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null),
|
||||
CountItem.reconstitute(CountItemId.of("item-2"), ArticleId.of("article-2"),
|
||||
|
|
@ -227,7 +227,7 @@ class RecordCountItemTest {
|
|||
var openCount = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
|
||||
Instant.now(), List.of(
|
||||
null, Instant.now(), List.of(
|
||||
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
|
||||
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class StartInventoryCountTest {
|
|||
return InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
|
||||
Instant.now(), items
|
||||
null, Instant.now(), items
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ class StartInventoryCountTest {
|
|||
var emptyCount = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
|
||||
Instant.now(), List.of()
|
||||
null, Instant.now(), List.of()
|
||||
);
|
||||
|
||||
when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
|
||||
|
|
@ -129,7 +129,7 @@ class StartInventoryCountTest {
|
|||
var countingCount = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), List.of(
|
||||
null, Instant.now(), List.of(
|
||||
CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
|
||||
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ class InventoryCountReconciliationServiceTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
|
||||
Instant.now(), items
|
||||
null, Instant.now(), items
|
||||
);
|
||||
var stocks = List.of(stockFor("article-1", "stock-1"));
|
||||
|
||||
|
|
@ -241,7 +241,7 @@ class InventoryCountReconciliationServiceTest {
|
|||
return InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
|
||||
Instant.now(), new ArrayList<>(items)
|
||||
null, Instant.now(), new ArrayList<>(items)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ class InventoryCountTest {
|
|||
"user-1",
|
||||
null,
|
||||
InventoryCountStatus.COUNTING,
|
||||
null,
|
||||
java.time.Instant.now(),
|
||||
java.util.List.of()
|
||||
);
|
||||
|
|
@ -383,7 +384,7 @@ class InventoryCountTest {
|
|||
return InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, status,
|
||||
Instant.now(), List.of()
|
||||
null, Instant.now(), List.of()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -454,7 +455,7 @@ class InventoryCountTest {
|
|||
return InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, status,
|
||||
Instant.now(), items
|
||||
null, Instant.now(), items
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -527,7 +528,7 @@ class InventoryCountTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED,
|
||||
Instant.now(), items
|
||||
null, Instant.now(), items
|
||||
);
|
||||
var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM);
|
||||
|
||||
|
|
@ -601,7 +602,7 @@ class InventoryCountTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), items
|
||||
null, Instant.now(), items
|
||||
);
|
||||
var actualQty = Quantity.reconstitute(new BigDecimal("3.0"), UnitOfMeasure.LITER);
|
||||
|
||||
|
|
@ -704,7 +705,7 @@ class InventoryCountTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), List.of()
|
||||
null, Instant.now(), List.of()
|
||||
);
|
||||
assertThat(count.isActive()).isTrue();
|
||||
}
|
||||
|
|
@ -715,7 +716,7 @@ class InventoryCountTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED,
|
||||
Instant.now(), List.of()
|
||||
null, Instant.now(), List.of()
|
||||
);
|
||||
assertThat(count.isActive()).isFalse();
|
||||
}
|
||||
|
|
@ -726,7 +727,7 @@ class InventoryCountTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("c1"), StorageLocationId.of("l1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED,
|
||||
Instant.now(), List.of()
|
||||
null, Instant.now(), List.of()
|
||||
);
|
||||
assertThat(count.isActive()).isFalse();
|
||||
}
|
||||
|
|
@ -755,7 +756,7 @@ class InventoryCountTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.of(2025, 6, 15), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), items
|
||||
null, Instant.now(), items
|
||||
);
|
||||
|
||||
assertThat(count.id().value()).isEqualTo("count-1");
|
||||
|
|
@ -789,12 +790,12 @@ class InventoryCountTest {
|
|||
var a = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("loc-a"),
|
||||
LocalDate.now(), "user-a", null, InventoryCountStatus.OPEN,
|
||||
Instant.now(), List.of()
|
||||
null, Instant.now(), List.of()
|
||||
);
|
||||
var b = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("loc-b"),
|
||||
LocalDate.now().minusDays(1), "user-b", null, InventoryCountStatus.COMPLETED,
|
||||
Instant.now(), List.of()
|
||||
null, Instant.now(), List.of()
|
||||
);
|
||||
|
||||
assertThat(a).isEqualTo(b);
|
||||
|
|
@ -807,12 +808,12 @@ class InventoryCountTest {
|
|||
var a = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("loc-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
|
||||
Instant.now(), List.of()
|
||||
null, Instant.now(), List.of()
|
||||
);
|
||||
var b = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-2"), StorageLocationId.of("loc-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
|
||||
Instant.now(), List.of()
|
||||
null, Instant.now(), List.of()
|
||||
);
|
||||
|
||||
assertThat(a).isNotEqualTo(b);
|
||||
|
|
@ -861,7 +862,7 @@ class InventoryCountTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), items
|
||||
null, Instant.now(), items
|
||||
);
|
||||
|
||||
var result = count.complete("user-2");
|
||||
|
|
@ -888,7 +889,7 @@ class InventoryCountTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
|
||||
Instant.now(), createCountedItems()
|
||||
null, Instant.now(), createCountedItems()
|
||||
);
|
||||
|
||||
var result = count.complete("user-3");
|
||||
|
|
@ -903,7 +904,7 @@ class InventoryCountTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED,
|
||||
Instant.now(), createCountedItems()
|
||||
null, Instant.now(), createCountedItems()
|
||||
);
|
||||
|
||||
var result = count.complete("user-2");
|
||||
|
|
@ -940,7 +941,7 @@ class InventoryCountTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), new ArrayList<>()
|
||||
null, Instant.now(), new ArrayList<>()
|
||||
);
|
||||
|
||||
var result = count.complete("user-2");
|
||||
|
|
@ -963,7 +964,7 @@ class InventoryCountTest {
|
|||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), items
|
||||
null, Instant.now(), items
|
||||
);
|
||||
|
||||
var result = count.complete("user-2");
|
||||
|
|
@ -1010,7 +1011,7 @@ class InventoryCountTest {
|
|||
return InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), items
|
||||
null, Instant.now(), items
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1023,6 +1024,100 @@ class InventoryCountTest {
|
|||
}
|
||||
}
|
||||
|
||||
// ==================== cancel ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("cancel()")
|
||||
class Cancel {
|
||||
|
||||
@Test
|
||||
@DisplayName("should cancel from OPEN status")
|
||||
void shouldCancelFromOpen() {
|
||||
var count = createOpenCount();
|
||||
|
||||
var result = count.cancel("Nicht mehr benötigt");
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(count.status()).isEqualTo(InventoryCountStatus.CANCELLED);
|
||||
assertThat(count.cancellationReason()).isEqualTo("Nicht mehr benötigt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should cancel from COUNTING status")
|
||||
void shouldCancelFromCounting() {
|
||||
var count = createCountingCount();
|
||||
|
||||
var result = count.cancel("Falsche Artikel");
|
||||
|
||||
assertThat(result.isSuccess()).isTrue();
|
||||
assertThat(count.status()).isEqualTo(InventoryCountStatus.CANCELLED);
|
||||
assertThat(count.cancellationReason()).isEqualTo("Falsche Artikel");
|
||||
}
|
||||
|
||||
@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,
|
||||
null, Instant.now(), new ArrayList<>()
|
||||
);
|
||||
|
||||
var result = count.cancel("Zu spät");
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when status is already CANCELLED")
|
||||
void shouldFailWhenAlreadyCancelled() {
|
||||
var count = InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED,
|
||||
"Alter Grund", Instant.now(), new ArrayList<>()
|
||||
);
|
||||
|
||||
var result = count.cancel("Neuer Grund");
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStatusTransition.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when reason is null")
|
||||
void shouldFailWhenReasonNull() {
|
||||
var count = createOpenCount();
|
||||
|
||||
var result = count.cancel(null);
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CancellationReasonRequired.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should fail when reason is blank")
|
||||
void shouldFailWhenReasonBlank() {
|
||||
var count = createOpenCount();
|
||||
|
||||
var result = count.cancel(" ");
|
||||
|
||||
assertThat(result.isFailure()).isTrue();
|
||||
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.CancellationReasonRequired.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should not modify status when validation fails")
|
||||
void shouldNotModifyStatusOnFailure() {
|
||||
var count = createOpenCount();
|
||||
|
||||
count.cancel(null);
|
||||
|
||||
assertThat(count.status()).isEqualTo(InventoryCountStatus.OPEN);
|
||||
assertThat(count.cancellationReason()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
private InventoryCount createOpenCount() {
|
||||
|
|
@ -1036,7 +1131,7 @@ class InventoryCountTest {
|
|||
return InventoryCount.reconstitute(
|
||||
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
|
||||
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
|
||||
Instant.now(), items
|
||||
null, Instant.now(), items
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -695,6 +695,158 @@ class InventoryCountControllerIntegrationTest extends AbstractIntegrationTest {
|
|||
}
|
||||
}
|
||||
|
||||
// ==================== Inventur abbrechen (US-6.4) ====================
|
||||
|
||||
@Nested
|
||||
@DisplayName("US-6.4 – Inventur abbrechen und abfragen")
|
||||
class CancelAndFilter {
|
||||
|
||||
@Test
|
||||
@DisplayName("Inventur abbrechen → 200 mit Grund und Status CANCELLED")
|
||||
void cancelInventoryCount_returns200() throws Exception {
|
||||
String countId = createInventoryCountWithStock();
|
||||
|
||||
String body = """
|
||||
{"reason": "Falsche Artikel ausgewählt"}
|
||||
""";
|
||||
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/cancel")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status").value("CANCELLED"))
|
||||
.andExpect(jsonPath("$.cancellationReason").value("Falsche Artikel ausgewählt"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Inventur abbrechen ohne Grund → 400")
|
||||
void cancelInventoryCount_noReason_returns400() throws Exception {
|
||||
String countId = createInventoryCountWithStock();
|
||||
|
||||
String body = """
|
||||
{"reason": ""}
|
||||
""";
|
||||
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/cancel")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Abgeschlossene Inventur abbrechen → 409")
|
||||
void cancelInventoryCount_completed_returns409() throws Exception {
|
||||
String countId = createAndCompleteCount();
|
||||
|
||||
String body = """
|
||||
{"reason": "Zu spät bemerkt"}
|
||||
""";
|
||||
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/cancel")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_STATUS_TRANSITION"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Filter nach Status OPEN → nur offene Inventuren")
|
||||
void listInventoryCounts_filterByStatus_returnsFiltered() throws Exception {
|
||||
// Create two counts: one open, one will be cancelled
|
||||
createInventoryCountWithStock();
|
||||
|
||||
// Need a second storage location for a second count
|
||||
String storageLocationId2 = createStorageLocation();
|
||||
String articleId2 = createArticleId();
|
||||
createStockWithBatch(articleId2, storageLocationId2);
|
||||
|
||||
var request2 = new CreateInventoryCountRequest(storageLocationId2, LocalDate.now().toString());
|
||||
var result2 = mockMvc.perform(post("/api/inventory/inventory-counts")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(request2)))
|
||||
.andExpect(status().isCreated())
|
||||
.andReturn();
|
||||
String countId2 = objectMapper.readTree(result2.getResponse().getContentAsString()).get("id").asText();
|
||||
|
||||
// Cancel one
|
||||
String cancelBody = """
|
||||
{"reason": "Nicht benötigt"}
|
||||
""";
|
||||
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId2 + "/cancel")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(cancelBody))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
// Filter by OPEN
|
||||
mockMvc.perform(get("/api/inventory/inventory-counts?status=OPEN")
|
||||
.header("Authorization", "Bearer " + adminToken))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.length()").value(1))
|
||||
.andExpect(jsonPath("$[0].status").value("OPEN"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Filter nach ungültigem Status → 400")
|
||||
void listInventoryCounts_invalidStatus_returns400() throws Exception {
|
||||
mockMvc.perform(get("/api/inventory/inventory-counts?status=INVALID")
|
||||
.header("Authorization", "Bearer " + adminToken))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("INVALID_STATUS"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Inventur abbrechen ohne Token → 401")
|
||||
void cancelInventoryCount_noToken_returns401() throws Exception {
|
||||
String body = """
|
||||
{"reason": "Test"}
|
||||
""";
|
||||
mockMvc.perform(post("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/cancel")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Inventur abbrechen ohne Berechtigung → 403")
|
||||
void cancelInventoryCount_noPermission_returns403() throws Exception {
|
||||
String body = """
|
||||
{"reason": "Test"}
|
||||
""";
|
||||
mockMvc.perform(post("/api/inventory/inventory-counts/" + UUID.randomUUID() + "/cancel")
|
||||
.header("Authorization", "Bearer " + viewerToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
private String createAndCompleteCount() 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);
|
||||
|
||||
String recordBody = """
|
||||
{"actualQuantityAmount": "25.0", "actualQuantityUnit": "KILOGRAM"}
|
||||
""";
|
||||
mockMvc.perform(patch("/api/inventory/inventory-counts/" + countId + "/items/" + itemId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(recordBody))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
mockMvc.perform(post("/api/inventory/inventory-counts/" + countId + "/complete")
|
||||
.header("Authorization", "Bearer " + completerToken))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
return countId;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
private String createInventoryCountWithStock() throws Exception {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue