mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 10:39:35 +01:00
feat(inventory): Bestandsbewegungen abfragen mit Zeitraum- und Chargen-Filter – Issue #16
Erweitert die StockMovement-Abfrage um batchReference- und from/to-Filter mit Filter-Priorität stockId > articleId > batchReference > movementType > from/to. Inkl. DB-Index auf batch_id, Unit-/Integrationstests und Lasttest-Szenarien.
This commit is contained in:
parent
fa6c0c2d70
commit
0e5d8f7025
13 changed files with 416 additions and 26 deletions
|
|
@ -13,6 +13,7 @@ import de.effigenix.shared.security.ActorId;
|
||||||
import de.effigenix.shared.security.AuthorizationPort;
|
import de.effigenix.shared.security.AuthorizationPort;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
|
|
@ -28,17 +29,18 @@ public class ListStockMovements {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists stock movements with optional filtering.
|
* Lists stock movements with optional filtering.
|
||||||
* Filter priority (only one filter applied): stockId > articleId > movementType.
|
* Filter priority (only one filter applied): stockId > articleId > batchReference > movementType > from/to.
|
||||||
* If multiple filters are provided, only the highest-priority filter is used.
|
* If multiple filters are provided, only the highest-priority filter is used.
|
||||||
*/
|
*/
|
||||||
public Result<StockMovementError, List<StockMovement>> execute(
|
public Result<StockMovementError, List<StockMovement>> execute(
|
||||||
String stockId, String articleId, String movementType, ActorId performedBy) {
|
String stockId, String articleId, String movementType,
|
||||||
|
String batchReference, Instant from, Instant to,
|
||||||
|
ActorId performedBy) {
|
||||||
|
|
||||||
if (!authPort.can(performedBy, InventoryAction.STOCK_MOVEMENT_READ)) {
|
if (!authPort.can(performedBy, InventoryAction.STOCK_MOVEMENT_READ)) {
|
||||||
return Result.failure(new StockMovementError.Unauthorized("Not authorized to list stock movements"));
|
return Result.failure(new StockMovementError.Unauthorized("Not authorized to list stock movements"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (stockId != null) {
|
if (stockId != null) {
|
||||||
StockId sid;
|
StockId sid;
|
||||||
try {
|
try {
|
||||||
|
|
@ -59,6 +61,10 @@ public class ListStockMovements {
|
||||||
return mapResult(stockMovementRepository.findAllByArticleId(aid));
|
return mapResult(stockMovementRepository.findAllByArticleId(aid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (batchReference != null) {
|
||||||
|
return mapResult(stockMovementRepository.findAllByBatchId(batchReference));
|
||||||
|
}
|
||||||
|
|
||||||
if (movementType != null) {
|
if (movementType != null) {
|
||||||
MovementType type;
|
MovementType type;
|
||||||
try {
|
try {
|
||||||
|
|
@ -70,6 +76,16 @@ public class ListStockMovements {
|
||||||
return mapResult(stockMovementRepository.findAllByMovementType(type));
|
return mapResult(stockMovementRepository.findAllByMovementType(type));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (from != null || to != null) {
|
||||||
|
if (from != null && to != null && from.isAfter(to)) {
|
||||||
|
return Result.failure(new StockMovementError.InvalidDateRange(
|
||||||
|
"'from' must not be after 'to'"));
|
||||||
|
}
|
||||||
|
Instant effectiveFrom = from != null ? from : Instant.MIN;
|
||||||
|
Instant effectiveTo = to != null ? to : Instant.MAX;
|
||||||
|
return mapResult(stockMovementRepository.findAllByPerformedAtBetween(effectiveFrom, effectiveTo));
|
||||||
|
}
|
||||||
|
|
||||||
return mapResult(stockMovementRepository.findAll());
|
return mapResult(stockMovementRepository.findAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,11 @@ public sealed interface StockMovementError {
|
||||||
@Override public String code() { return "UNAUTHORIZED"; }
|
@Override public String code() { return "UNAUTHORIZED"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
record InvalidDateRange(String reason) implements StockMovementError {
|
||||||
|
@Override public String code() { return "INVALID_DATE_RANGE"; }
|
||||||
|
@Override public String message() { return "Invalid date range: " + reason; }
|
||||||
|
}
|
||||||
|
|
||||||
record RepositoryFailure(String message) implements StockMovementError {
|
record RepositoryFailure(String message) implements StockMovementError {
|
||||||
@Override public String code() { return "REPOSITORY_ERROR"; }
|
@Override public String code() { return "REPOSITORY_ERROR"; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import de.effigenix.domain.masterdata.ArticleId;
|
||||||
import de.effigenix.shared.common.RepositoryError;
|
import de.effigenix.shared.common.RepositoryError;
|
||||||
import de.effigenix.shared.common.Result;
|
import de.effigenix.shared.common.Result;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
|
@ -19,5 +20,9 @@ public interface StockMovementRepository {
|
||||||
|
|
||||||
Result<RepositoryError, List<StockMovement>> findAllByMovementType(MovementType movementType);
|
Result<RepositoryError, List<StockMovement>> findAllByMovementType(MovementType movementType);
|
||||||
|
|
||||||
|
Result<RepositoryError, List<StockMovement>> findAllByBatchId(String batchId);
|
||||||
|
|
||||||
|
Result<RepositoryError, List<StockMovement>> findAllByPerformedAtBetween(Instant from, Instant to);
|
||||||
|
|
||||||
Result<RepositoryError, Void> save(StockMovement stockMovement);
|
Result<RepositoryError, Void> save(StockMovement stockMovement);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import org.springframework.context.annotation.Profile;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
|
@ -93,6 +94,32 @@ public class JpaStockMovementRepository implements StockMovementRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<RepositoryError, List<StockMovement>> findAllByBatchId(String batchId) {
|
||||||
|
try {
|
||||||
|
List<StockMovement> result = jpaRepository.findAllByBatchId(batchId).stream()
|
||||||
|
.map(mapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
return Result.success(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Database error in findAllByBatchId", e);
|
||||||
|
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<RepositoryError, List<StockMovement>> findAllByPerformedAtBetween(Instant from, Instant to) {
|
||||||
|
try {
|
||||||
|
List<StockMovement> result = jpaRepository.findAllByPerformedAtBetween(from, to).stream()
|
||||||
|
.map(mapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
return Result.success(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Database error in findAllByPerformedAtBetween", e);
|
||||||
|
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public Result<RepositoryError, Void> save(StockMovement stockMovement) {
|
public Result<RepositoryError, Void> save(StockMovement stockMovement) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package de.effigenix.infrastructure.inventory.persistence.repository;
|
||||||
import de.effigenix.infrastructure.inventory.persistence.entity.StockMovementEntity;
|
import de.effigenix.infrastructure.inventory.persistence.entity.StockMovementEntity;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public interface StockMovementJpaRepository extends JpaRepository<StockMovementEntity, String> {
|
public interface StockMovementJpaRepository extends JpaRepository<StockMovementEntity, String> {
|
||||||
|
|
@ -12,4 +13,8 @@ public interface StockMovementJpaRepository extends JpaRepository<StockMovementE
|
||||||
List<StockMovementEntity> findAllByArticleId(String articleId);
|
List<StockMovementEntity> findAllByArticleId(String articleId);
|
||||||
|
|
||||||
List<StockMovementEntity> findAllByMovementType(String movementType);
|
List<StockMovementEntity> findAllByMovementType(String movementType);
|
||||||
|
|
||||||
|
List<StockMovementEntity> findAllByBatchId(String batchId);
|
||||||
|
|
||||||
|
List<StockMovementEntity> findAllByPerformedAtBetween(Instant from, Instant to);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,14 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.format.annotation.DateTimeFormat;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.Authentication;
|
import org.springframework.security.core.Authentication;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
|
|
@ -73,14 +75,18 @@ public class StockMovementController {
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@PreAuthorize("hasAuthority('STOCK_MOVEMENT_READ')")
|
@PreAuthorize("hasAuthority('STOCK_MOVEMENT_READ')")
|
||||||
@Operation(summary = "List stock movements",
|
@Operation(summary = "List stock movements",
|
||||||
description = "Filter priority (only one filter applied): stockId > articleId > movementType")
|
description = "Filter priority (only one filter applied): stockId > articleId > batchReference > movementType > from/to")
|
||||||
public ResponseEntity<List<StockMovementResponse>> listMovements(
|
public ResponseEntity<List<StockMovementResponse>> listMovements(
|
||||||
@RequestParam(required = false) String stockId,
|
@RequestParam(required = false) String stockId,
|
||||||
@RequestParam(required = false) String articleId,
|
@RequestParam(required = false) String articleId,
|
||||||
@RequestParam(required = false) String movementType,
|
@RequestParam(required = false) String movementType,
|
||||||
|
@RequestParam(required = false) String batchReference,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from,
|
||||||
|
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to,
|
||||||
Authentication authentication
|
Authentication authentication
|
||||||
) {
|
) {
|
||||||
var result = listStockMovements.execute(stockId, articleId, movementType,
|
var result = listStockMovements.execute(stockId, articleId, movementType,
|
||||||
|
batchReference, from, to,
|
||||||
ActorId.of(authentication.getName()));
|
ActorId.of(authentication.getName()));
|
||||||
|
|
||||||
if (result.isFailure()) {
|
if (result.isFailure()) {
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ public final class InventoryErrorHttpStatusMapper {
|
||||||
case StockMovementError.ReasonRequired e -> 400;
|
case StockMovementError.ReasonRequired e -> 400;
|
||||||
case StockMovementError.ReferenceDocumentRequired e -> 400;
|
case StockMovementError.ReferenceDocumentRequired e -> 400;
|
||||||
case StockMovementError.InvalidPerformedBy e -> 400;
|
case StockMovementError.InvalidPerformedBy e -> 400;
|
||||||
|
case StockMovementError.InvalidDateRange e -> 400;
|
||||||
case StockMovementError.Unauthorized e -> 403;
|
case StockMovementError.Unauthorized e -> 403;
|
||||||
case StockMovementError.RepositoryFailure e -> 500;
|
case StockMovementError.RepositoryFailure e -> 500;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?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="030-add-batch-id-index-to-stock-movements" author="effigenix">
|
||||||
|
<createIndex indexName="idx_stock_movements_batch_id" tableName="stock_movements">
|
||||||
|
<column name="batch_id"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
|
|
@ -34,5 +34,6 @@
|
||||||
<include file="db/changelog/changes/027-add-released-status-to-production-orders.xml"/>
|
<include file="db/changelog/changes/027-add-released-status-to-production-orders.xml"/>
|
||||||
<include file="db/changelog/changes/028-create-stock-movements-table.xml"/>
|
<include file="db/changelog/changes/028-create-stock-movements-table.xml"/>
|
||||||
<include file="db/changelog/changes/029-seed-stock-movement-permissions.xml"/>
|
<include file="db/changelog/changes/029-seed-stock-movement-permissions.xml"/>
|
||||||
|
<include file="db/changelog/changes/030-add-batch-id-index-to-stock-movements.xml"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ class ListStockMovementsTest {
|
||||||
void shouldReturnAll() {
|
void shouldReturnAll() {
|
||||||
when(repository.findAll()).thenReturn(Result.success(List.of(sampleMovement)));
|
when(repository.findAll()).thenReturn(Result.success(List.of(sampleMovement)));
|
||||||
|
|
||||||
var result = useCase.execute(null, null, null, actor);
|
var result = useCase.execute(null, null, null, null, null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
assertThat(result.unsafeGetValue()).hasSize(1);
|
assertThat(result.unsafeGetValue()).hasSize(1);
|
||||||
|
|
@ -74,7 +74,7 @@ class ListStockMovementsTest {
|
||||||
void shouldReturnEmptyList() {
|
void shouldReturnEmptyList() {
|
||||||
when(repository.findAll()).thenReturn(Result.success(List.of()));
|
when(repository.findAll()).thenReturn(Result.success(List.of()));
|
||||||
|
|
||||||
var result = useCase.execute(null, null, null, actor);
|
var result = useCase.execute(null, null, null, null, null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
assertThat(result.unsafeGetValue()).isEmpty();
|
assertThat(result.unsafeGetValue()).isEmpty();
|
||||||
|
|
@ -86,7 +86,7 @@ class ListStockMovementsTest {
|
||||||
when(repository.findAll()).thenReturn(
|
when(repository.findAll()).thenReturn(
|
||||||
Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||||
|
|
||||||
var result = useCase.execute(null, null, null, actor);
|
var result = useCase.execute(null, null, null, null, null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isFailure()).isTrue();
|
assertThat(result.isFailure()).isTrue();
|
||||||
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class);
|
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class);
|
||||||
|
|
@ -103,7 +103,7 @@ class ListStockMovementsTest {
|
||||||
when(repository.findAllByStockId(StockId.of("stock-1")))
|
when(repository.findAllByStockId(StockId.of("stock-1")))
|
||||||
.thenReturn(Result.success(List.of(sampleMovement)));
|
.thenReturn(Result.success(List.of(sampleMovement)));
|
||||||
|
|
||||||
var result = useCase.execute("stock-1", null, null, actor);
|
var result = useCase.execute("stock-1", null, null, null, null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
assertThat(result.unsafeGetValue()).hasSize(1);
|
assertThat(result.unsafeGetValue()).hasSize(1);
|
||||||
|
|
@ -114,7 +114,7 @@ class ListStockMovementsTest {
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("should fail with InvalidStockId when format invalid")
|
@DisplayName("should fail with InvalidStockId when format invalid")
|
||||||
void shouldFailWhenStockIdInvalid() {
|
void shouldFailWhenStockIdInvalid() {
|
||||||
var result = useCase.execute(" ", null, null, actor);
|
var result = useCase.execute(" ", null, null, null, null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isFailure()).isTrue();
|
assertThat(result.isFailure()).isTrue();
|
||||||
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockId.class);
|
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidStockId.class);
|
||||||
|
|
@ -131,7 +131,7 @@ class ListStockMovementsTest {
|
||||||
when(repository.findAllByArticleId(ArticleId.of("article-1")))
|
when(repository.findAllByArticleId(ArticleId.of("article-1")))
|
||||||
.thenReturn(Result.success(List.of(sampleMovement)));
|
.thenReturn(Result.success(List.of(sampleMovement)));
|
||||||
|
|
||||||
var result = useCase.execute(null, "article-1", null, actor);
|
var result = useCase.execute(null, "article-1", null, null, null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
assertThat(result.unsafeGetValue()).hasSize(1);
|
assertThat(result.unsafeGetValue()).hasSize(1);
|
||||||
|
|
@ -141,13 +141,55 @@ class ListStockMovementsTest {
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("should fail with InvalidArticleId when format invalid")
|
@DisplayName("should fail with InvalidArticleId when format invalid")
|
||||||
void shouldFailWhenArticleIdInvalid() {
|
void shouldFailWhenArticleIdInvalid() {
|
||||||
var result = useCase.execute(null, " ", null, actor);
|
var result = useCase.execute(null, " ", null, null, null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isFailure()).isTrue();
|
assertThat(result.isFailure()).isTrue();
|
||||||
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidArticleId.class);
|
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidArticleId.class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Filter by batchReference")
|
||||||
|
class BatchReferenceFilter {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should filter by batchReference")
|
||||||
|
void shouldFilterByBatchReference() {
|
||||||
|
when(repository.findAllByBatchId("CHARGE-001"))
|
||||||
|
.thenReturn(Result.success(List.of(sampleMovement)));
|
||||||
|
|
||||||
|
var result = useCase.execute(null, null, null, "CHARGE-001", null, null, actor);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(result.unsafeGetValue()).hasSize(1);
|
||||||
|
verify(repository).findAllByBatchId("CHARGE-001");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should return empty list when no movements for batch")
|
||||||
|
void shouldReturnEmptyForUnknownBatch() {
|
||||||
|
when(repository.findAllByBatchId("UNKNOWN"))
|
||||||
|
.thenReturn(Result.success(List.of()));
|
||||||
|
|
||||||
|
var result = useCase.execute(null, null, null, "UNKNOWN", null, null, actor);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(result.unsafeGetValue()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when repository fails for batchReference")
|
||||||
|
void shouldFailWhenRepositoryFailsForBatch() {
|
||||||
|
when(repository.findAllByBatchId("CHARGE-001"))
|
||||||
|
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||||
|
|
||||||
|
var result = useCase.execute(null, null, null, "CHARGE-001", null, null, actor);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
@DisplayName("Filter by movementType")
|
@DisplayName("Filter by movementType")
|
||||||
class MovementTypeFilter {
|
class MovementTypeFilter {
|
||||||
|
|
@ -158,7 +200,7 @@ class ListStockMovementsTest {
|
||||||
when(repository.findAllByMovementType(MovementType.GOODS_RECEIPT))
|
when(repository.findAllByMovementType(MovementType.GOODS_RECEIPT))
|
||||||
.thenReturn(Result.success(List.of(sampleMovement)));
|
.thenReturn(Result.success(List.of(sampleMovement)));
|
||||||
|
|
||||||
var result = useCase.execute(null, null, "GOODS_RECEIPT", actor);
|
var result = useCase.execute(null, null, "GOODS_RECEIPT", null, null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
assertThat(result.unsafeGetValue()).hasSize(1);
|
assertThat(result.unsafeGetValue()).hasSize(1);
|
||||||
|
|
@ -168,43 +210,182 @@ class ListStockMovementsTest {
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("should fail with InvalidMovementType when type invalid")
|
@DisplayName("should fail with InvalidMovementType when type invalid")
|
||||||
void shouldFailWhenMovementTypeInvalid() {
|
void shouldFailWhenMovementTypeInvalid() {
|
||||||
var result = useCase.execute(null, null, "INVALID_TYPE", actor);
|
var result = useCase.execute(null, null, "INVALID_TYPE", null, null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isFailure()).isTrue();
|
assertThat(result.isFailure()).isTrue();
|
||||||
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidMovementType.class);
|
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidMovementType.class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Filter by date range")
|
||||||
|
class DateRangeFilter {
|
||||||
|
|
||||||
|
private final Instant from = Instant.parse("2026-01-01T00:00:00Z");
|
||||||
|
private final Instant to = Instant.parse("2026-12-31T23:59:59Z");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should filter by from and to")
|
||||||
|
void shouldFilterByFromAndTo() {
|
||||||
|
when(repository.findAllByPerformedAtBetween(from, to))
|
||||||
|
.thenReturn(Result.success(List.of(sampleMovement)));
|
||||||
|
|
||||||
|
var result = useCase.execute(null, null, null, null, from, to, actor);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
assertThat(result.unsafeGetValue()).hasSize(1);
|
||||||
|
verify(repository).findAllByPerformedAtBetween(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should filter with only from (open-ended)")
|
||||||
|
void shouldFilterByFromOnly() {
|
||||||
|
when(repository.findAllByPerformedAtBetween(eq(from), any()))
|
||||||
|
.thenReturn(Result.success(List.of(sampleMovement)));
|
||||||
|
|
||||||
|
var result = useCase.execute(null, null, null, null, from, null, actor);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
verify(repository).findAllByPerformedAtBetween(eq(from), eq(Instant.MAX));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should filter with only to (open-ended)")
|
||||||
|
void shouldFilterByToOnly() {
|
||||||
|
when(repository.findAllByPerformedAtBetween(any(), eq(to)))
|
||||||
|
.thenReturn(Result.success(List.of(sampleMovement)));
|
||||||
|
|
||||||
|
var result = useCase.execute(null, null, null, null, null, to, actor);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
verify(repository).findAllByPerformedAtBetween(eq(Instant.MIN), eq(to));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail with InvalidDateRange when from is after to")
|
||||||
|
void shouldFailWhenFromAfterTo() {
|
||||||
|
var result = useCase.execute(null, null, null, null, to, from, actor);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidDateRange.class);
|
||||||
|
verify(repository, never()).findAllByPerformedAtBetween(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should succeed when from equals to (same instant)")
|
||||||
|
void shouldSucceedWhenFromEqualsTo() {
|
||||||
|
when(repository.findAllByPerformedAtBetween(from, from))
|
||||||
|
.thenReturn(Result.success(List.of()));
|
||||||
|
|
||||||
|
var result = useCase.execute(null, null, null, null, from, from, actor);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
verify(repository).findAllByPerformedAtBetween(from, from);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail when repository fails for date range")
|
||||||
|
void shouldFailWhenRepositoryFailsForDateRange() {
|
||||||
|
when(repository.findAllByPerformedAtBetween(from, to))
|
||||||
|
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||||
|
|
||||||
|
var result = useCase.execute(null, null, null, null, from, to, actor);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.RepositoryFailure.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
@DisplayName("Filter priority")
|
@DisplayName("Filter priority")
|
||||||
class FilterPriority {
|
class FilterPriority {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("stockId takes priority over articleId and movementType")
|
@DisplayName("stockId takes priority over articleId, batchReference and movementType")
|
||||||
void stockIdTakesPriority() {
|
void stockIdTakesPriority() {
|
||||||
when(repository.findAllByStockId(StockId.of("stock-1")))
|
when(repository.findAllByStockId(StockId.of("stock-1")))
|
||||||
.thenReturn(Result.success(List.of()));
|
.thenReturn(Result.success(List.of()));
|
||||||
|
|
||||||
var result = useCase.execute("stock-1", "article-1", "GOODS_RECEIPT", actor);
|
var result = useCase.execute("stock-1", "article-1", "GOODS_RECEIPT", "CHARGE-001", null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
verify(repository).findAllByStockId(StockId.of("stock-1"));
|
verify(repository).findAllByStockId(StockId.of("stock-1"));
|
||||||
verify(repository, never()).findAllByArticleId(any());
|
verify(repository, never()).findAllByArticleId(any());
|
||||||
|
verify(repository, never()).findAllByBatchId(any());
|
||||||
verify(repository, never()).findAllByMovementType(any());
|
verify(repository, never()).findAllByMovementType(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("articleId takes priority over movementType")
|
@DisplayName("articleId takes priority over batchReference and movementType")
|
||||||
void articleIdTakesPriorityOverMovementType() {
|
void articleIdTakesPriorityOverBatchAndMovementType() {
|
||||||
when(repository.findAllByArticleId(ArticleId.of("article-1")))
|
when(repository.findAllByArticleId(ArticleId.of("article-1")))
|
||||||
.thenReturn(Result.success(List.of()));
|
.thenReturn(Result.success(List.of()));
|
||||||
|
|
||||||
var result = useCase.execute(null, "article-1", "GOODS_RECEIPT", actor);
|
var result = useCase.execute(null, "article-1", "GOODS_RECEIPT", "CHARGE-001", null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
verify(repository).findAllByArticleId(ArticleId.of("article-1"));
|
verify(repository).findAllByArticleId(ArticleId.of("article-1"));
|
||||||
|
verify(repository, never()).findAllByBatchId(any());
|
||||||
verify(repository, never()).findAllByMovementType(any());
|
verify(repository, never()).findAllByMovementType(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("batchReference takes priority over movementType")
|
||||||
|
void batchReferenceTakesPriorityOverMovementType() {
|
||||||
|
when(repository.findAllByBatchId("CHARGE-001"))
|
||||||
|
.thenReturn(Result.success(List.of()));
|
||||||
|
|
||||||
|
var result = useCase.execute(null, null, "GOODS_RECEIPT", "CHARGE-001", null, null, actor);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
verify(repository).findAllByBatchId("CHARGE-001");
|
||||||
|
verify(repository, never()).findAllByMovementType(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("movementType takes priority over from/to")
|
||||||
|
void movementTypeTakesPriorityOverDateRange() {
|
||||||
|
when(repository.findAllByMovementType(MovementType.GOODS_RECEIPT))
|
||||||
|
.thenReturn(Result.success(List.of()));
|
||||||
|
|
||||||
|
Instant from = Instant.parse("2026-01-01T00:00:00Z");
|
||||||
|
Instant to = Instant.parse("2026-12-31T23:59:59Z");
|
||||||
|
var result = useCase.execute(null, null, "GOODS_RECEIPT", null, from, to, actor);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
verify(repository).findAllByMovementType(MovementType.GOODS_RECEIPT);
|
||||||
|
verify(repository, never()).findAllByPerformedAtBetween(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("stockId takes priority over from/to")
|
||||||
|
void stockIdTakesPriorityOverDateRange() {
|
||||||
|
when(repository.findAllByStockId(StockId.of("stock-1")))
|
||||||
|
.thenReturn(Result.success(List.of()));
|
||||||
|
|
||||||
|
Instant from = Instant.parse("2026-01-01T00:00:00Z");
|
||||||
|
Instant to = Instant.parse("2026-12-31T23:59:59Z");
|
||||||
|
var result = useCase.execute("stock-1", null, null, null, from, to, actor);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
verify(repository).findAllByStockId(StockId.of("stock-1"));
|
||||||
|
verify(repository, never()).findAllByPerformedAtBetween(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("batchReference takes priority over from/to")
|
||||||
|
void batchReferenceTakesPriorityOverDateRange() {
|
||||||
|
when(repository.findAllByBatchId("CHARGE-001"))
|
||||||
|
.thenReturn(Result.success(List.of()));
|
||||||
|
|
||||||
|
Instant from = Instant.parse("2026-01-01T00:00:00Z");
|
||||||
|
Instant to = Instant.parse("2026-12-31T23:59:59Z");
|
||||||
|
var result = useCase.execute(null, null, null, "CHARGE-001", from, to, actor);
|
||||||
|
|
||||||
|
assertThat(result.isSuccess()).isTrue();
|
||||||
|
verify(repository).findAllByBatchId("CHARGE-001");
|
||||||
|
verify(repository, never()).findAllByPerformedAtBetween(any(), any());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
|
|
@ -216,7 +397,7 @@ class ListStockMovementsTest {
|
||||||
void shouldFailWhenUnauthorized() {
|
void shouldFailWhenUnauthorized() {
|
||||||
when(authPort.can(actor, InventoryAction.STOCK_MOVEMENT_READ)).thenReturn(false);
|
when(authPort.can(actor, InventoryAction.STOCK_MOVEMENT_READ)).thenReturn(false);
|
||||||
|
|
||||||
var result = useCase.execute(null, null, null, actor);
|
var result = useCase.execute(null, null, null, null, null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isFailure()).isTrue();
|
assertThat(result.isFailure()).isTrue();
|
||||||
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.Unauthorized.class);
|
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.Unauthorized.class);
|
||||||
|
|
|
||||||
|
|
@ -477,6 +477,110 @@ class StockMovementControllerIntegrationTest extends AbstractIntegrationTest {
|
||||||
.andExpect(jsonPath("$").isArray())
|
.andExpect(jsonPath("$").isArray())
|
||||||
.andExpect(jsonPath("$.length()").value(0));
|
.andExpect(jsonPath("$.length()").value(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Nach batchReference filtern → 200")
|
||||||
|
void filterByBatchReference_returns200() throws Exception {
|
||||||
|
recordMovement("GOODS_RECEIPT");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/inventory/stock-movements")
|
||||||
|
.param("batchReference", "CHARGE-001")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isArray())
|
||||||
|
.andExpect(jsonPath("$.length()").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Nach Zeitraum (from + to) filtern → 200")
|
||||||
|
void filterByDateRange_returns200() throws Exception {
|
||||||
|
recordMovement("GOODS_RECEIPT");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/inventory/stock-movements")
|
||||||
|
.param("from", "2020-01-01T00:00:00Z")
|
||||||
|
.param("to", "2030-12-31T23:59:59Z")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isArray())
|
||||||
|
.andExpect(jsonPath("$.length()").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Nur from (open-ended) → 200")
|
||||||
|
void filterByFromOnly_returns200() throws Exception {
|
||||||
|
recordMovement("GOODS_RECEIPT");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/inventory/stock-movements")
|
||||||
|
.param("from", "2020-01-01T00:00:00Z")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isArray())
|
||||||
|
.andExpect(jsonPath("$.length()").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Nur to (open-ended) → 200")
|
||||||
|
void filterByToOnly_returns200() throws Exception {
|
||||||
|
recordMovement("GOODS_RECEIPT");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/inventory/stock-movements")
|
||||||
|
.param("to", "2030-12-31T23:59:59Z")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isArray())
|
||||||
|
.andExpect(jsonPath("$.length()").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("from nach to → 400")
|
||||||
|
void filterByInvalidDateRange_returns400() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/inventory/stock-movements")
|
||||||
|
.param("from", "2030-01-01T00:00:00Z")
|
||||||
|
.param("to", "2020-01-01T00:00:00Z")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("INVALID_DATE_RANGE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("stockId hat Priorität über batchReference")
|
||||||
|
void stockIdPriorityOverBatchReference_returns200() throws Exception {
|
||||||
|
recordMovement("GOODS_RECEIPT");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/inventory/stock-movements")
|
||||||
|
.param("stockId", stockId)
|
||||||
|
.param("batchReference", "CHARGE-001")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Unbekannte batchReference → 200 mit []")
|
||||||
|
void filterByUnknownBatchReference_returns200Empty() throws Exception {
|
||||||
|
recordMovement("GOODS_RECEIPT");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/inventory/stock-movements")
|
||||||
|
.param("batchReference", "UNKNOWN-CHARGE")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isArray())
|
||||||
|
.andExpect(jsonPath("$.length()").value(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Zeitraum in Zukunft → 200 mit []")
|
||||||
|
void filterByFutureDateRange_returns200Empty() throws Exception {
|
||||||
|
recordMovement("GOODS_RECEIPT");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/inventory/stock-movements")
|
||||||
|
.param("from", "2099-01-01T00:00:00Z")
|
||||||
|
.param("to", "2099-12-31T23:59:59Z")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isArray())
|
||||||
|
.andExpect(jsonPath("$.length()").value(0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Einzelne Bewegung ====================
|
// ==================== Einzelne Bewegung ====================
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,27 @@ public final class InventoryScenario {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static ChainBuilder listStockMovementsByBatch() {
|
||||||
|
return exec(session -> {
|
||||||
|
int idx = ThreadLocalRandom.current().nextInt(50);
|
||||||
|
return session.set("filterBatchRef", "CHARGE-%05d".formatted(idx));
|
||||||
|
}).exec(
|
||||||
|
http("Bestandsbewegungen nach Charge")
|
||||||
|
.get("/api/inventory/stock-movements?batchReference=#{filterBatchRef}")
|
||||||
|
.header("Authorization", "Bearer #{accessToken}")
|
||||||
|
.check(status().is(200))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ChainBuilder listStockMovementsByDateRange() {
|
||||||
|
return exec(
|
||||||
|
http("Bestandsbewegungen nach Zeitraum")
|
||||||
|
.get("/api/inventory/stock-movements?from=2020-01-01T00:00:00Z&to=2030-12-31T23:59:59Z")
|
||||||
|
.header("Authorization", "Bearer #{accessToken}")
|
||||||
|
.check(status().is(200))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static ChainBuilder recordStockMovement() {
|
public static ChainBuilder recordStockMovement() {
|
||||||
return exec(session -> {
|
return exec(session -> {
|
||||||
var rnd = ThreadLocalRandom.current();
|
var rnd = ThreadLocalRandom.current();
|
||||||
|
|
@ -127,14 +148,16 @@ public final class InventoryScenario {
|
||||||
.exec(AuthenticationScenario.login("admin", "admin123"))
|
.exec(AuthenticationScenario.login("admin", "admin123"))
|
||||||
.repeat(15).on(
|
.repeat(15).on(
|
||||||
randomSwitch().on(
|
randomSwitch().on(
|
||||||
percent(20.0).then(listStocks()),
|
percent(18.0).then(listStocks()),
|
||||||
percent(15.0).then(listStorageLocations()),
|
percent(12.0).then(listStorageLocations()),
|
||||||
percent(15.0).then(getRandomStorageLocation()),
|
percent(12.0).then(getRandomStorageLocation()),
|
||||||
percent(15.0).then(listStocksByLocation()),
|
percent(12.0).then(listStocksByLocation()),
|
||||||
percent(10.0).then(listStocksBelowMinimum()),
|
percent(8.0).then(listStocksBelowMinimum()),
|
||||||
percent(10.0).then(listStockMovements()),
|
percent(8.0).then(listStockMovements()),
|
||||||
percent(5.0).then(listStockMovementsByStock()),
|
percent(5.0).then(listStockMovementsByStock()),
|
||||||
percent(10.0).then(recordStockMovement())
|
percent(5.0).then(listStockMovementsByBatch()),
|
||||||
|
percent(5.0).then(listStockMovementsByDateRange()),
|
||||||
|
percent(15.0).then(recordStockMovement())
|
||||||
).pause(1, 3)
|
).pause(1, 3)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,8 @@ public class FullWorkloadSimulation extends Simulation {
|
||||||
details("Kategorien auflisten").responseTime().mean().lt(35),
|
details("Kategorien auflisten").responseTime().mean().lt(35),
|
||||||
details("Bestandsbewegungen auflisten").responseTime().mean().lt(35),
|
details("Bestandsbewegungen auflisten").responseTime().mean().lt(35),
|
||||||
details("Bestandsbewegungen nach Bestand").responseTime().mean().lt(35),
|
details("Bestandsbewegungen nach Bestand").responseTime().mean().lt(35),
|
||||||
|
details("Bestandsbewegungen nach Charge").responseTime().mean().lt(35),
|
||||||
|
details("Bestandsbewegungen nach Zeitraum").responseTime().mean().lt(35),
|
||||||
|
|
||||||
// Listen mit viel Daten (50-300 Einträge): mean < 75ms
|
// Listen mit viel Daten (50-300 Einträge): mean < 75ms
|
||||||
details("Chargen auflisten").responseTime().mean().lt(75),
|
details("Chargen auflisten").responseTime().mean().lt(75),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue