1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 15:59:35 +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:
Sebastian Frick 2026-03-19 11:39:56 +01:00
parent 58ed0a3810
commit a0ebf46329
28 changed files with 798 additions and 47 deletions

View file

@ -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);
});
}
}

View file

@ -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());

View file

@ -0,0 +1,3 @@
package de.effigenix.application.inventory.command;
public record CancelInventoryCountCommand(String inventoryCountId, String reason) {}

View file

@ -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); }

View file

@ -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"; }
}

View file

@ -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);

View file

@ -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();

View file

@ -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()
);

View file

@ -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 {

View file

@ -0,0 +1,5 @@
package de.effigenix.infrastructure.inventory.web.dto;
import jakarta.validation.constraints.NotBlank;
public record CancelInventoryCountRequest(@NotBlank String reason) {}

View file

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

View file

@ -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;
};

View file

@ -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);

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet id="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>

View file

@ -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>