1
0
Fork 0
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:
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; 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)) { if (!authPort.can(actorId, InventoryAction.INVENTORY_COUNT_READ)) {
return Result.failure(new InventoryCountError.Unauthorized("Not authorized to view inventory counts")); 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) { if (storageLocationId != null) {
StorageLocationId locId; StorageLocationId locId;
try { try {
@ -30,7 +39,18 @@ public class ListInventoryCounts {
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
return Result.failure(new InventoryCountError.InvalidStorageLocationId(e.getMessage())); 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()); 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 * - startCounting only in status OPEN, requires non-empty countItems
* - updateCountItem only in status COUNTING * - updateCountItem only in status COUNTING
* - complete only in status COUNTING, requires all items counted, Vier-Augen-Prinzip (completedBy initiatedBy) * - 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 { public class InventoryCount {
@ -35,6 +36,7 @@ public class InventoryCount {
private final String initiatedBy; private final String initiatedBy;
private String completedBy; private String completedBy;
private InventoryCountStatus status; private InventoryCountStatus status;
private String cancellationReason;
private final Instant createdAt; private final Instant createdAt;
private final List<CountItem> countItems; private final List<CountItem> countItems;
@ -45,6 +47,7 @@ public class InventoryCount {
String initiatedBy, String initiatedBy,
String completedBy, String completedBy,
InventoryCountStatus status, InventoryCountStatus status,
String cancellationReason,
Instant createdAt, Instant createdAt,
List<CountItem> countItems List<CountItem> countItems
) { ) {
@ -54,6 +57,7 @@ public class InventoryCount {
this.initiatedBy = initiatedBy; this.initiatedBy = initiatedBy;
this.completedBy = completedBy; this.completedBy = completedBy;
this.status = status; this.status = status;
this.cancellationReason = cancellationReason;
this.createdAt = createdAt; this.createdAt = createdAt;
this.countItems = new ArrayList<>(countItems); this.countItems = new ArrayList<>(countItems);
} }
@ -94,7 +98,7 @@ public class InventoryCount {
return Result.success(new InventoryCount( return Result.success(new InventoryCount(
InventoryCountId.generate(), storageLocationId, countDate, 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 initiatedBy,
String completedBy, String completedBy,
InventoryCountStatus status, InventoryCountStatus status,
String cancellationReason,
Instant createdAt, Instant createdAt,
List<CountItem> countItems 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 ==================== // ==================== Count Item Management ====================
@ -173,6 +178,19 @@ public class InventoryCount {
return Result.success(null); 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) { public Result<InventoryCountError, Void> updateCountItem(CountItemId itemId, Quantity actualQuantity) {
if (status != InventoryCountStatus.COUNTING) { if (status != InventoryCountStatus.COUNTING) {
return Result.failure(new InventoryCountError.InvalidStatusTransition( return Result.failure(new InventoryCountError.InvalidStatusTransition(
@ -217,6 +235,7 @@ public class InventoryCount {
public String initiatedBy() { return initiatedBy; } public String initiatedBy() { return initiatedBy; }
public String completedBy() { return completedBy; } public String completedBy() { return completedBy; }
public InventoryCountStatus status() { return status; } public InventoryCountStatus status() { return status; }
public String cancellationReason() { return cancellationReason; }
public Instant createdAt() { return createdAt; } public Instant createdAt() { return createdAt; }
public List<CountItem> countItems() { return Collections.unmodifiableList(countItems); } public List<CountItem> countItems() { return Collections.unmodifiableList(countItems); }

View file

@ -90,6 +90,16 @@ public sealed interface InventoryCountError {
@Override public String message() { return "No stock found for article: " + articleId; } @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 { record Unauthorized(String message) implements InventoryCountError {
@Override public String code() { return "UNAUTHORIZED"; } @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>> findByStorageLocationId(StorageLocationId storageLocationId);
Result<RepositoryError, List<InventoryCount>> findByStatus(InventoryCountStatus status);
Result<RepositoryError, Boolean> existsActiveByStorageLocationId(StorageLocationId storageLocationId); Result<RepositoryError, Boolean> existsActiveByStorageLocationId(StorageLocationId storageLocationId);
Result<RepositoryError, Void> save(InventoryCount inventoryCount); Result<RepositoryError, Void> save(InventoryCount inventoryCount);

View file

@ -1,5 +1,6 @@
package de.effigenix.infrastructure.config; package de.effigenix.infrastructure.config;
import de.effigenix.application.inventory.CancelInventoryCount;
import de.effigenix.application.inventory.CompleteInventoryCount; import de.effigenix.application.inventory.CompleteInventoryCount;
import de.effigenix.application.inventory.CreateInventoryCount; import de.effigenix.application.inventory.CreateInventoryCount;
import de.effigenix.application.inventory.GetInventoryCount; import de.effigenix.application.inventory.GetInventoryCount;
@ -187,6 +188,11 @@ public class InventoryUseCaseConfiguration {
return new RecordCountItem(inventoryCountRepository, unitOfWork, authorizationPort); return new RecordCountItem(inventoryCountRepository, unitOfWork, authorizationPort);
} }
@Bean
public CancelInventoryCount cancelInventoryCount(InventoryCountRepository inventoryCountRepository, UnitOfWork unitOfWork, AuthorizationPort authorizationPort) {
return new CancelInventoryCount(inventoryCountRepository, authorizationPort, unitOfWork);
}
@Bean @Bean
public InventoryCountReconciliationService inventoryCountReconciliationService() { public InventoryCountReconciliationService inventoryCountReconciliationService() {
return new 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 @Override
public Result<RepositoryError, Boolean> existsActiveByStorageLocationId(StorageLocationId storageLocationId) { public Result<RepositoryError, Boolean> existsActiveByStorageLocationId(StorageLocationId storageLocationId) {
try { try {
@ -102,7 +116,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
UPDATE inventory_counts UPDATE inventory_counts
SET storage_location_id = :storageLocationId, count_date = :countDate, SET storage_location_id = :storageLocationId, count_date = :countDate,
initiated_by = :initiatedBy, completed_by = :completedBy, initiated_by = :initiatedBy, completed_by = :completedBy,
status = :status, created_at = :createdAt status = :status, cancellation_reason = :cancellationReason,
created_at = :createdAt
WHERE id = :id WHERE id = :id
""") """)
.param("id", inventoryCount.id().value()) .param("id", inventoryCount.id().value())
@ -111,8 +126,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
if (rows == 0) { if (rows == 0) {
jdbc.sql(""" jdbc.sql("""
INSERT INTO inventory_counts (id, storage_location_id, count_date, initiated_by, completed_by, status, created_at) 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, :createdAt) VALUES (:id, :storageLocationId, :countDate, :initiatedBy, :completedBy, :status, :cancellationReason, :createdAt)
""") """)
.param("id", inventoryCount.id().value()) .param("id", inventoryCount.id().value())
.params(countParams(inventoryCount)) .params(countParams(inventoryCount))
@ -137,6 +152,7 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
params.put("initiatedBy", count.initiatedBy()); params.put("initiatedBy", count.initiatedBy());
params.put("completedBy", count.completedBy()); params.put("completedBy", count.completedBy());
params.put("status", count.status().name()); params.put("status", count.status().name());
params.put("cancellationReason", count.cancellationReason());
params.put("createdAt", count.createdAt().atOffset(ZoneOffset.UTC)); params.put("createdAt", count.createdAt().atOffset(ZoneOffset.UTC));
return params; return params;
} }
@ -208,7 +224,8 @@ public class JdbcInventoryCountRepository implements InventoryCountRepository {
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
count.id(), count.storageLocationId(), count.countDate(), count.id(), count.storageLocationId(), count.countDate(),
count.initiatedBy(), count.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("initiated_by"),
rs.getString("completed_by"), rs.getString("completed_by"),
InventoryCountStatus.valueOf(rs.getString("status")), InventoryCountStatus.valueOf(rs.getString("status")),
rs.getString("cancellation_reason"),
rs.getObject("created_at", OffsetDateTime.class).toInstant(), rs.getObject("created_at", OffsetDateTime.class).toInstant(),
List.of() List.of()
); );

View file

@ -1,15 +1,18 @@
package de.effigenix.infrastructure.inventory.web.controller; package de.effigenix.infrastructure.inventory.web.controller;
import de.effigenix.application.inventory.CancelInventoryCount;
import de.effigenix.application.inventory.CompleteInventoryCount; import de.effigenix.application.inventory.CompleteInventoryCount;
import de.effigenix.application.inventory.CreateInventoryCount; import de.effigenix.application.inventory.CreateInventoryCount;
import de.effigenix.application.inventory.GetInventoryCount; import de.effigenix.application.inventory.GetInventoryCount;
import de.effigenix.application.inventory.ListInventoryCounts; import de.effigenix.application.inventory.ListInventoryCounts;
import de.effigenix.application.inventory.RecordCountItem; import de.effigenix.application.inventory.RecordCountItem;
import de.effigenix.application.inventory.StartInventoryCount; import de.effigenix.application.inventory.StartInventoryCount;
import de.effigenix.application.inventory.command.CancelInventoryCountCommand;
import de.effigenix.application.inventory.command.CompleteInventoryCountCommand; import de.effigenix.application.inventory.command.CompleteInventoryCountCommand;
import de.effigenix.application.inventory.command.CreateInventoryCountCommand; import de.effigenix.application.inventory.command.CreateInventoryCountCommand;
import de.effigenix.application.inventory.command.RecordCountItemCommand; import de.effigenix.application.inventory.command.RecordCountItemCommand;
import de.effigenix.domain.inventory.InventoryCountError; import de.effigenix.domain.inventory.InventoryCountError;
import de.effigenix.infrastructure.inventory.web.dto.CancelInventoryCountRequest;
import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest; import de.effigenix.infrastructure.inventory.web.dto.CreateInventoryCountRequest;
import de.effigenix.infrastructure.inventory.web.dto.InventoryCountResponse; import de.effigenix.infrastructure.inventory.web.dto.InventoryCountResponse;
import de.effigenix.infrastructure.inventory.web.dto.RecordCountItemRequest; import de.effigenix.infrastructure.inventory.web.dto.RecordCountItemRequest;
@ -39,6 +42,7 @@ public class InventoryCountController {
private final StartInventoryCount startInventoryCount; private final StartInventoryCount startInventoryCount;
private final RecordCountItem recordCountItem; private final RecordCountItem recordCountItem;
private final CompleteInventoryCount completeInventoryCount; private final CompleteInventoryCount completeInventoryCount;
private final CancelInventoryCount cancelInventoryCount;
private final UserLookupPort userLookup; private final UserLookupPort userLookup;
public InventoryCountController(CreateInventoryCount createInventoryCount, public InventoryCountController(CreateInventoryCount createInventoryCount,
@ -47,6 +51,7 @@ public class InventoryCountController {
StartInventoryCount startInventoryCount, StartInventoryCount startInventoryCount,
RecordCountItem recordCountItem, RecordCountItem recordCountItem,
CompleteInventoryCount completeInventoryCount, CompleteInventoryCount completeInventoryCount,
CancelInventoryCount cancelInventoryCount,
UserLookupPort userLookup) { UserLookupPort userLookup) {
this.createInventoryCount = createInventoryCount; this.createInventoryCount = createInventoryCount;
this.getInventoryCount = getInventoryCount; this.getInventoryCount = getInventoryCount;
@ -54,6 +59,7 @@ public class InventoryCountController {
this.startInventoryCount = startInventoryCount; this.startInventoryCount = startInventoryCount;
this.recordCountItem = recordCountItem; this.recordCountItem = recordCountItem;
this.completeInventoryCount = completeInventoryCount; this.completeInventoryCount = completeInventoryCount;
this.cancelInventoryCount = cancelInventoryCount;
this.userLookup = userLookup; this.userLookup = userLookup;
} }
@ -92,9 +98,10 @@ public class InventoryCountController {
@PreAuthorize("hasAuthority('INVENTORY_COUNT_READ')") @PreAuthorize("hasAuthority('INVENTORY_COUNT_READ')")
public ResponseEntity<List<InventoryCountResponse>> listInventoryCounts( public ResponseEntity<List<InventoryCountResponse>> listInventoryCounts(
@RequestParam(required = false) String storageLocationId, @RequestParam(required = false) String storageLocationId,
@RequestParam(required = false) String status,
Authentication authentication 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.Failure(var err) -> throw new InventoryCountDomainErrorException(err);
case Result.Success(var counts) -> { case Result.Success(var counts) -> {
var responses = counts.stream() 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 ==================== // ==================== Exception Wrapper ====================
public static class InventoryCountDomainErrorException extends RuntimeException { 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 initiatedBy,
String completedBy, String completedBy,
String status, String status,
String cancellationReason,
Instant createdAt, Instant createdAt,
List<CountItemResponse> countItems List<CountItemResponse> countItems
) { ) {
@ -27,6 +28,7 @@ public record InventoryCountResponse(
? userLookup.resolveUsername(count.completedBy()).orElse(count.completedBy()) ? userLookup.resolveUsername(count.completedBy()).orElse(count.completedBy())
: null, : null,
count.status().name(), count.status().name(),
count.cancellationReason(),
count.createdAt(), count.createdAt(),
count.countItems().stream() count.countItems().stream()
.map(CountItemResponse::from) .map(CountItemResponse::from)

View file

@ -72,6 +72,8 @@ public final class InventoryErrorHttpStatusMapper {
case InventoryCountError.InvalidArticleId e -> 400; case InventoryCountError.InvalidArticleId e -> 400;
case InventoryCountError.InvalidQuantity e -> 400; case InventoryCountError.InvalidQuantity e -> 400;
case InventoryCountError.InvalidInventoryCountId e -> 400; case InventoryCountError.InvalidInventoryCountId e -> 400;
case InventoryCountError.CancellationReasonRequired e -> 400;
case InventoryCountError.InvalidStatus e -> 400;
case InventoryCountError.Unauthorized e -> 403; case InventoryCountError.Unauthorized e -> 403;
case InventoryCountError.RepositoryFailure e -> 500; 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.InventoryCount;
import de.effigenix.domain.inventory.InventoryCountId; import de.effigenix.domain.inventory.InventoryCountId;
import de.effigenix.domain.inventory.InventoryCountRepository; import de.effigenix.domain.inventory.InventoryCountRepository;
import de.effigenix.domain.inventory.InventoryCountStatus;
import de.effigenix.domain.inventory.StorageLocationId; import de.effigenix.domain.inventory.StorageLocationId;
import de.effigenix.shared.common.RepositoryError; import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result; import de.effigenix.shared.common.Result;
@ -34,6 +35,11 @@ public class StubInventoryCountRepository implements InventoryCountRepository {
return Result.failure(STUB_ERROR); return Result.failure(STUB_ERROR);
} }
@Override
public Result<RepositoryError, List<InventoryCount>> findByStatus(InventoryCountStatus status) {
return Result.failure(STUB_ERROR);
}
@Override @Override
public Result<RepositoryError, Boolean> existsActiveByStorageLocationId(StorageLocationId storageLocationId) { public Result<RepositoryError, Boolean> existsActiveByStorageLocationId(StorageLocationId storageLocationId) {
return Result.failure(STUB_ERROR); 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/035-seed-inventory-count-permissions.xml"/>
<include file="db/changelog/changes/036-add-inventory-counts-composite-index.xml"/> <include file="db/changelog/changes/036-add-inventory-counts-composite-index.xml"/>
<include file="db/changelog/changes/037-add-completed-by-to-inventory-counts.xml"/> <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> </databaseChangeLog>

View file

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

View file

@ -237,7 +237,7 @@ class CompleteInventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items null, Instant.now(), items
); );
when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
@ -256,7 +256,7 @@ class CompleteInventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN, LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), new ArrayList<>() null, Instant.now(), new ArrayList<>()
); );
when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
@ -372,7 +372,7 @@ class CompleteInventoryCountTest {
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items null, Instant.now(), items
); );
} }

View file

@ -44,6 +44,7 @@ class GetInventoryCountTest {
"user-1", "user-1",
null, null,
InventoryCountStatus.OPEN, InventoryCountStatus.OPEN,
null,
Instant.now(), Instant.now(),
List.of() List.of()
); );

View file

@ -44,6 +44,7 @@ class ListInventoryCountsTest {
"user-1", "user-1",
null, null,
InventoryCountStatus.OPEN, InventoryCountStatus.OPEN,
null,
Instant.now(), Instant.now(),
List.of() List.of()
); );
@ -55,6 +56,7 @@ class ListInventoryCountsTest {
"user-1", "user-1",
null, null,
InventoryCountStatus.COMPLETED, InventoryCountStatus.COMPLETED,
null,
Instant.now(), Instant.now(),
List.of() List.of()
); );
@ -65,7 +67,7 @@ class ListInventoryCountsTest {
void shouldReturnAllCountsWhenNoFilter() { void shouldReturnAllCountsWhenNoFilter() {
when(inventoryCountRepository.findAll()).thenReturn(Result.success(List.of(count1, count2))); 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.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(2); assertThat(result.unsafeGetValue()).hasSize(2);
@ -78,7 +80,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1"))) when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.success(List.of(count1))); .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.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).hasSize(1); assertThat(result.unsafeGetValue()).hasSize(1);
@ -92,7 +94,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findAll()) when(inventoryCountRepository.findAll())
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); .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.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
@ -104,7 +106,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1"))) when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("location-1")))
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost"))); .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.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class); assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.RepositoryFailure.class);
@ -116,7 +118,7 @@ class ListInventoryCountsTest {
when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("unknown"))) when(inventoryCountRepository.findByStorageLocationId(StorageLocationId.of("unknown")))
.thenReturn(Result.success(List.of())); .thenReturn(Result.success(List.of()));
var result = listInventoryCounts.execute("unknown", actorId); var result = listInventoryCounts.execute("unknown", null, actorId);
assertThat(result.isSuccess()).isTrue(); assertThat(result.isSuccess()).isTrue();
assertThat(result.unsafeGetValue()).isEmpty(); assertThat(result.unsafeGetValue()).isEmpty();
@ -125,9 +127,57 @@ class ListInventoryCountsTest {
@Test @Test
@DisplayName("should fail with InvalidStorageLocationId for blank storageLocationId") @DisplayName("should fail with InvalidStorageLocationId for blank storageLocationId")
void shouldFailWhenBlankStorageLocationId() { void shouldFailWhenBlankStorageLocationId() {
var result = listInventoryCounts.execute(" ", actorId); var result = listInventoryCounts.execute(" ", null, actorId);
assertThat(result.isFailure()).isTrue(); assertThat(result.isFailure()).isTrue();
assertThat(result.unsafeGetError()).isInstanceOf(InventoryCountError.InvalidStorageLocationId.class); 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);
}
} }

View file

@ -56,7 +56,7 @@ class RecordCountItemTest {
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, 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"), CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null), Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null),
CountItem.reconstitute(CountItemId.of("item-2"), ArticleId.of("article-2"), CountItem.reconstitute(CountItemId.of("item-2"), ArticleId.of("article-2"),
@ -227,7 +227,7 @@ class RecordCountItemTest {
var openCount = InventoryCount.reconstitute( var openCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN, 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"), CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null) Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null)
) )

View file

@ -59,7 +59,7 @@ class StartInventoryCountTest {
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN, LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), items null, Instant.now(), items
); );
} }
@ -111,7 +111,7 @@ class StartInventoryCountTest {
var emptyCount = InventoryCount.reconstitute( var emptyCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN, LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of() null, Instant.now(), List.of()
); );
when(inventoryCountRepository.findById(InventoryCountId.of("count-1"))) when(inventoryCountRepository.findById(InventoryCountId.of("count-1")))
@ -129,7 +129,7 @@ class StartInventoryCountTest {
var countingCount = InventoryCount.reconstitute( var countingCount = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, 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"), CountItem.reconstitute(CountItemId.of("item-1"), ArticleId.of("article-1"),
Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null) Quantity.reconstitute(new BigDecimal("10.0"), UnitOfMeasure.KILOGRAM), null)
) )

View file

@ -225,7 +225,7 @@ class InventoryCountReconciliationServiceTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED, LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
Instant.now(), items null, Instant.now(), items
); );
var stocks = List.of(stockFor("article-1", "stock-1")); var stocks = List.of(stockFor("article-1", "stock-1"));
@ -241,7 +241,7 @@ class InventoryCountReconciliationServiceTest {
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED, LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
Instant.now(), new ArrayList<>(items) null, Instant.now(), new ArrayList<>(items)
); );
} }

View file

@ -260,6 +260,7 @@ class InventoryCountTest {
"user-1", "user-1",
null, null,
InventoryCountStatus.COUNTING, InventoryCountStatus.COUNTING,
null,
java.time.Instant.now(), java.time.Instant.now(),
java.util.List.of() java.util.List.of()
); );
@ -383,7 +384,7 @@ class InventoryCountTest {
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, status, LocalDate.now(), "user-1", null, status,
Instant.now(), List.of() null, Instant.now(), List.of()
); );
} }
} }
@ -454,7 +455,7 @@ class InventoryCountTest {
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, status, LocalDate.now(), "user-1", null, status,
Instant.now(), items null, Instant.now(), items
); );
} }
} }
@ -527,7 +528,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED, LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED,
Instant.now(), items null, Instant.now(), items
); );
var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM); var actualQty = Quantity.reconstitute(new BigDecimal("8.0"), UnitOfMeasure.KILOGRAM);
@ -601,7 +602,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items null, Instant.now(), items
); );
var actualQty = Quantity.reconstitute(new BigDecimal("3.0"), UnitOfMeasure.LITER); var actualQty = Quantity.reconstitute(new BigDecimal("3.0"), UnitOfMeasure.LITER);
@ -704,7 +705,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"), InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), List.of() null, Instant.now(), List.of()
); );
assertThat(count.isActive()).isTrue(); assertThat(count.isActive()).isTrue();
} }
@ -715,7 +716,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"), InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED, LocalDate.now(), "user-1", null, InventoryCountStatus.COMPLETED,
Instant.now(), List.of() null, Instant.now(), List.of()
); );
assertThat(count.isActive()).isFalse(); assertThat(count.isActive()).isFalse();
} }
@ -726,7 +727,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("c1"), StorageLocationId.of("l1"), InventoryCountId.of("c1"), StorageLocationId.of("l1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED, LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED,
Instant.now(), List.of() null, Instant.now(), List.of()
); );
assertThat(count.isActive()).isFalse(); assertThat(count.isActive()).isFalse();
} }
@ -755,7 +756,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.of(2025, 6, 15), "user-1", null, InventoryCountStatus.COUNTING, LocalDate.of(2025, 6, 15), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items null, Instant.now(), items
); );
assertThat(count.id().value()).isEqualTo("count-1"); assertThat(count.id().value()).isEqualTo("count-1");
@ -789,12 +790,12 @@ class InventoryCountTest {
var a = InventoryCount.reconstitute( var a = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-a"), InventoryCountId.of("count-1"), StorageLocationId.of("loc-a"),
LocalDate.now(), "user-a", null, InventoryCountStatus.OPEN, LocalDate.now(), "user-a", null, InventoryCountStatus.OPEN,
Instant.now(), List.of() null, Instant.now(), List.of()
); );
var b = InventoryCount.reconstitute( var b = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-b"), InventoryCountId.of("count-1"), StorageLocationId.of("loc-b"),
LocalDate.now().minusDays(1), "user-b", null, InventoryCountStatus.COMPLETED, LocalDate.now().minusDays(1), "user-b", null, InventoryCountStatus.COMPLETED,
Instant.now(), List.of() null, Instant.now(), List.of()
); );
assertThat(a).isEqualTo(b); assertThat(a).isEqualTo(b);
@ -807,12 +808,12 @@ class InventoryCountTest {
var a = InventoryCount.reconstitute( var a = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("loc-1"), InventoryCountId.of("count-1"), StorageLocationId.of("loc-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN, LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of() null, Instant.now(), List.of()
); );
var b = InventoryCount.reconstitute( var b = InventoryCount.reconstitute(
InventoryCountId.of("count-2"), StorageLocationId.of("loc-1"), InventoryCountId.of("count-2"), StorageLocationId.of("loc-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN, LocalDate.now(), "user-1", null, InventoryCountStatus.OPEN,
Instant.now(), List.of() null, Instant.now(), List.of()
); );
assertThat(a).isNotEqualTo(b); assertThat(a).isNotEqualTo(b);
@ -861,7 +862,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items null, Instant.now(), items
); );
var result = count.complete("user-2"); var result = count.complete("user-2");
@ -888,7 +889,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED, LocalDate.now(), "user-1", "user-2", InventoryCountStatus.COMPLETED,
Instant.now(), createCountedItems() null, Instant.now(), createCountedItems()
); );
var result = count.complete("user-3"); var result = count.complete("user-3");
@ -903,7 +904,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED, LocalDate.now(), "user-1", null, InventoryCountStatus.CANCELLED,
Instant.now(), createCountedItems() null, Instant.now(), createCountedItems()
); );
var result = count.complete("user-2"); var result = count.complete("user-2");
@ -940,7 +941,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), new ArrayList<>() null, Instant.now(), new ArrayList<>()
); );
var result = count.complete("user-2"); var result = count.complete("user-2");
@ -963,7 +964,7 @@ class InventoryCountTest {
var count = InventoryCount.reconstitute( var count = InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items null, Instant.now(), items
); );
var result = count.complete("user-2"); var result = count.complete("user-2");
@ -1010,7 +1011,7 @@ class InventoryCountTest {
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, 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 ==================== // ==================== Helpers ====================
private InventoryCount createOpenCount() { private InventoryCount createOpenCount() {
@ -1036,7 +1131,7 @@ class InventoryCountTest {
return InventoryCount.reconstitute( return InventoryCount.reconstitute(
InventoryCountId.of("count-1"), StorageLocationId.of("location-1"), InventoryCountId.of("count-1"), StorageLocationId.of("location-1"),
LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING, LocalDate.now(), "user-1", null, InventoryCountStatus.COUNTING,
Instant.now(), items null, Instant.now(), items
); );
} }

View file

@ -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 ==================== // ==================== Helpers ====================
private String createInventoryCountWithStock() throws Exception { private String createInventoryCountWithStock() throws Exception {

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink'; import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input'; import TextInput from 'ink-text-input';
import { useNavigation } from '../../state/navigation-context.js'; import { useNavigation } from '../../state/navigation-context.js';
@ -11,7 +11,7 @@ import { SuccessDisplay } from '../shared/SuccessDisplay.js';
import { INVENTORY_COUNT_STATUS_LABELS } from '@effigenix/api-client'; import { INVENTORY_COUNT_STATUS_LABELS } from '@effigenix/api-client';
import type { InventoryCountStatus, CountItemDTO } from '@effigenix/api-client'; import type { InventoryCountStatus, CountItemDTO } from '@effigenix/api-client';
type Mode = 'view' | 'menu' | 'record-item' | 'record-amount' | 'confirm-start' | 'confirm-complete'; type Mode = 'view' | 'menu' | 'record-item' | 'record-amount' | 'confirm-start' | 'confirm-complete' | 'cancel-reason' | 'confirm-cancel';
const STATUS_COLORS: Record<InventoryCountStatus, string> = { const STATUS_COLORS: Record<InventoryCountStatus, string> = {
OPEN: 'yellow', OPEN: 'yellow',
@ -33,6 +33,7 @@ export function InventoryCountDetailScreen() {
startInventoryCount, startInventoryCount,
recordCountItem, recordCountItem,
completeInventoryCount, completeInventoryCount,
cancelInventoryCount,
clearError, clearError,
} = useInventoryCounts(); } = useInventoryCounts();
const { articleName, locationName } = useStockNameLookup(); const { articleName, locationName } = useStockNameLookup();
@ -42,6 +43,7 @@ export function InventoryCountDetailScreen() {
const [itemIndex, setItemIndex] = useState(0); const [itemIndex, setItemIndex] = useState(0);
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [cancelReason, setCancelReason] = useState('');
const countId = params.inventoryCountId ?? ''; const countId = params.inventoryCountId ?? '';
@ -61,12 +63,14 @@ export function InventoryCountDetailScreen() {
const actions: { label: string; action: string }[] = []; const actions: { label: string; action: string }[] = [];
if (inventoryCount.status === 'OPEN') { if (inventoryCount.status === 'OPEN') {
actions.push({ label: 'Zählung starten', action: 'start' }); actions.push({ label: 'Zählung starten', action: 'start' });
actions.push({ label: 'Inventur abbrechen', action: 'cancel' });
} }
if (inventoryCount.status === 'COUNTING') { if (inventoryCount.status === 'COUNTING') {
actions.push({ label: 'Position erfassen', action: 'record' }); actions.push({ label: 'Position erfassen', action: 'record' });
if (allCounted && isDifferentUser) { if (allCounted && isDifferentUser) {
actions.push({ label: 'Inventur abschließen', action: 'complete' }); actions.push({ label: 'Inventur abschließen', action: 'complete' });
} }
actions.push({ label: 'Inventur abbrechen', action: 'cancel' });
} }
return actions; return actions;
}; };
@ -108,6 +112,16 @@ export function InventoryCountDetailScreen() {
} }
}; };
const handleCancel = async () => {
if (!cancelReason.trim()) return;
const result = await cancelInventoryCount(countId, cancelReason.trim());
if (result) {
setSuccess('Inventur abgebrochen.');
setCancelReason('');
setMode('view');
}
};
useInput((input, key) => { useInput((input, key) => {
if (loading) return; if (loading) return;
@ -116,6 +130,17 @@ export function InventoryCountDetailScreen() {
return; return;
} }
if (mode === 'cancel-reason') {
if (key.escape) { setCancelReason(''); setMode('view'); }
return;
}
if (mode === 'confirm-cancel') {
if (input.toLowerCase() === 'j') void handleCancel();
if (input.toLowerCase() === 'n' || key.escape) setMode('view');
return;
}
if (mode === 'confirm-start') { if (mode === 'confirm-start') {
if (input.toLowerCase() === 'j') void handleStart(); if (input.toLowerCase() === 'j') void handleStart();
if (input.toLowerCase() === 'n' || key.escape) setMode('view'); if (input.toLowerCase() === 'n' || key.escape) setMode('view');
@ -147,6 +172,7 @@ export function InventoryCountDetailScreen() {
if (action === 'start') setMode('confirm-start'); if (action === 'start') setMode('confirm-start');
else if (action === 'record') { setMode('record-item'); setItemIndex(0); } else if (action === 'record') { setMode('record-item'); setItemIndex(0); }
else if (action === 'complete') setMode('confirm-complete'); else if (action === 'complete') setMode('confirm-complete');
else if (action === 'cancel') { setCancelReason(''); setMode('cancel-reason'); }
} }
if (key.escape) setMode('view'); if (key.escape) setMode('view');
return; return;
@ -207,6 +233,9 @@ export function InventoryCountDetailScreen() {
{inventoryCount.completedBy && ( {inventoryCount.completedBy && (
<Box><Text color="gray">Abgeschl. von: </Text><Text>{inventoryCount.completedBy}</Text></Box> <Box><Text color="gray">Abgeschl. von: </Text><Text>{inventoryCount.completedBy}</Text></Box>
)} )}
{inventoryCount.cancellationReason && (
<Box><Text color="gray">Abbruchgrund: </Text><Text color="red">{inventoryCount.cancellationReason}</Text></Box>
)}
<Box> <Box>
<Text color="gray">Fortschritt: </Text> <Text color="gray">Fortschritt: </Text>
<Text color={allCounted ? 'green' : 'yellow'}>{progressBar} {countedCount}/{items.length} ({progressPct}%)</Text> <Text color={allCounted ? 'green' : 'yellow'}>{progressBar} {countedCount}/{items.length} ({progressPct}%)</Text>
@ -311,6 +340,33 @@ export function InventoryCountDetailScreen() {
</Box> </Box>
)} )}
{/* Cancel Reason */}
{mode === 'cancel-reason' && (
<Box flexDirection="column" borderStyle="round" borderColor="red" paddingX={1}>
<Text color="red" bold>Inventur abbrechen</Text>
<Text color="gray">Bitte Grund eingeben:</Text>
<Box>
<Text color="gray"> </Text>
<TextInput
value={cancelReason}
onChange={setCancelReason}
onSubmit={() => { if (cancelReason.trim()) setMode('confirm-cancel'); }}
focus={true}
/>
</Box>
<Text color="gray" dimColor>Enter bestätigen · Escape abbrechen</Text>
</Box>
)}
{/* Confirm Cancel */}
{mode === 'confirm-cancel' && (
<Box flexDirection="column" borderStyle="round" borderColor="red" paddingX={1}>
<Text color="red" bold>Inventur wirklich abbrechen?</Text>
<Text>Grund: {cancelReason}</Text>
<Text color="gray" dimColor>[J] Ja · [N] Nein</Text>
</Box>
)}
{/* Four-eyes hint */} {/* Four-eyes hint */}
{inventoryCount.status === 'COUNTING' && allCounted && !isDifferentUser && ( {inventoryCount.status === 'COUNTING' && allCounted && !isDifferentUser && (
<Box> <Box>

View file

@ -94,6 +94,18 @@ export function useInventoryCounts() {
} }
}, []); }, []);
const cancelInventoryCount = useCallback(async (id: string, reason: string) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const inventoryCount = await client.inventoryCounts.cancel(id, { reason });
setState((s) => ({ ...s, inventoryCount, loading: false, error: null }));
return inventoryCount;
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
return null;
}
}, []);
const clearError = useCallback(() => { const clearError = useCallback(() => {
setState((s) => ({ ...s, error: null })); setState((s) => ({ ...s, error: null }));
}, []); }, []);
@ -106,6 +118,7 @@ export function useInventoryCounts() {
startInventoryCount, startInventoryCount,
recordCountItem, recordCountItem,
completeInventoryCount, completeInventoryCount,
cancelInventoryCount,
clearError, clearError,
}; };
} }

View file

@ -5,10 +5,11 @@ import type {
InventoryCountDTO, InventoryCountDTO,
CreateInventoryCountRequest, CreateInventoryCountRequest,
RecordCountItemRequest, RecordCountItemRequest,
CancelInventoryCountRequest,
InventoryCountStatus, InventoryCountStatus,
} from '@effigenix/types'; } from '@effigenix/types';
export type { InventoryCountDTO, CreateInventoryCountRequest, RecordCountItemRequest, InventoryCountStatus }; export type { InventoryCountDTO, CreateInventoryCountRequest, RecordCountItemRequest, CancelInventoryCountRequest, InventoryCountStatus };
export const INVENTORY_COUNT_STATUS_LABELS: Record<InventoryCountStatus, string> = { export const INVENTORY_COUNT_STATUS_LABELS: Record<InventoryCountStatus, string> = {
OPEN: 'Offen', OPEN: 'Offen',
@ -19,6 +20,7 @@ export const INVENTORY_COUNT_STATUS_LABELS: Record<InventoryCountStatus, string>
export interface InventoryCountFilter { export interface InventoryCountFilter {
storageLocationId?: string; storageLocationId?: string;
status?: string;
} }
const BASE = '/api/inventory/inventory-counts'; const BASE = '/api/inventory/inventory-counts';
@ -28,6 +30,7 @@ export function createInventoryCountsResource(client: AxiosInstance) {
async list(filter?: InventoryCountFilter): Promise<InventoryCountDTO[]> { async list(filter?: InventoryCountFilter): Promise<InventoryCountDTO[]> {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (filter?.storageLocationId) params.storageLocationId = filter.storageLocationId; if (filter?.storageLocationId) params.storageLocationId = filter.storageLocationId;
if (filter?.status) params.status = filter.status;
const res = await client.get<InventoryCountDTO[]>(BASE, { params }); const res = await client.get<InventoryCountDTO[]>(BASE, { params });
return res.data; return res.data;
}, },
@ -56,6 +59,11 @@ export function createInventoryCountsResource(client: AxiosInstance) {
const res = await client.post<InventoryCountDTO>(`${BASE}/${id}/complete`); const res = await client.post<InventoryCountDTO>(`${BASE}/${id}/complete`);
return res.data; return res.data;
}, },
async cancel(id: string, request: CancelInventoryCountRequest): Promise<InventoryCountDTO> {
const res = await client.post<InventoryCountDTO>(`${BASE}/${id}/cancel`, request);
return res.data;
},
}; };
} }

View file

@ -20,6 +20,7 @@ export interface InventoryCountDTO {
countDate: string; countDate: string;
initiatedBy: string; initiatedBy: string;
completedBy: string | null; completedBy: string | null;
cancellationReason: string | null;
status: InventoryCountStatus; status: InventoryCountStatus;
createdAt: string; createdAt: string;
countItems: CountItemDTO[]; countItems: CountItemDTO[];
@ -34,3 +35,7 @@ export interface RecordCountItemRequest {
actualQuantityAmount: string; actualQuantityAmount: string;
actualQuantityUnit: string; actualQuantityUnit: string;
} }
export interface CancelInventoryCountRequest {
reason: string;
}