mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 08:29:36 +01:00
fix(inventory): Instant.MIN/MAX, performed_at-Index, batchReference-Naming und -Validierung, Lasttest-Zeiträume
- Instant.MIN/MAX durch separate Repository-Methoden ersetzt (findAllByPerformedAtAfter/Before) - DB-Index auf performed_at für Zeitraum-Abfragen - Naming-Konsistenz: findAllByBatchId → findAllByBatchReference im Domain-Repository - batchReference-Validierung (Blank-Check) mit InvalidBatchReference-Error - Lasttest: variierende Zeiträume (7–90 Tage) statt statischem 10-Jahres-Fenster
This commit is contained in:
parent
0e5d8f7025
commit
a8bbe3a951
9 changed files with 114 additions and 25 deletions
|
|
@ -62,7 +62,11 @@ public class ListStockMovements {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (batchReference != null) {
|
if (batchReference != null) {
|
||||||
return mapResult(stockMovementRepository.findAllByBatchId(batchReference));
|
if (batchReference.isBlank()) {
|
||||||
|
return Result.failure(new StockMovementError.InvalidBatchReference(
|
||||||
|
"Batch reference must not be blank"));
|
||||||
|
}
|
||||||
|
return mapResult(stockMovementRepository.findAllByBatchReference(batchReference));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (movementType != null) {
|
if (movementType != null) {
|
||||||
|
|
@ -81,9 +85,13 @@ public class ListStockMovements {
|
||||||
return Result.failure(new StockMovementError.InvalidDateRange(
|
return Result.failure(new StockMovementError.InvalidDateRange(
|
||||||
"'from' must not be after 'to'"));
|
"'from' must not be after 'to'"));
|
||||||
}
|
}
|
||||||
Instant effectiveFrom = from != null ? from : Instant.MIN;
|
if (from != null && to != null) {
|
||||||
Instant effectiveTo = to != null ? to : Instant.MAX;
|
return mapResult(stockMovementRepository.findAllByPerformedAtBetween(from, to));
|
||||||
return mapResult(stockMovementRepository.findAllByPerformedAtBetween(effectiveFrom, effectiveTo));
|
}
|
||||||
|
if (from != null) {
|
||||||
|
return mapResult(stockMovementRepository.findAllByPerformedAtAfter(from));
|
||||||
|
}
|
||||||
|
return mapResult(stockMovementRepository.findAllByPerformedAtBefore(to));
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapResult(stockMovementRepository.findAll());
|
return mapResult(stockMovementRepository.findAll());
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,13 @@ 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>> findAllByBatchReference(String batchReference);
|
||||||
|
|
||||||
Result<RepositoryError, List<StockMovement>> findAllByPerformedAtBetween(Instant from, Instant to);
|
Result<RepositoryError, List<StockMovement>> findAllByPerformedAtBetween(Instant from, Instant to);
|
||||||
|
|
||||||
|
Result<RepositoryError, List<StockMovement>> findAllByPerformedAtAfter(Instant from);
|
||||||
|
|
||||||
|
Result<RepositoryError, List<StockMovement>> findAllByPerformedAtBefore(Instant to);
|
||||||
|
|
||||||
Result<RepositoryError, Void> save(StockMovement stockMovement);
|
Result<RepositoryError, Void> save(StockMovement stockMovement);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,14 +95,14 @@ public class JpaStockMovementRepository implements StockMovementRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result<RepositoryError, List<StockMovement>> findAllByBatchId(String batchId) {
|
public Result<RepositoryError, List<StockMovement>> findAllByBatchReference(String batchReference) {
|
||||||
try {
|
try {
|
||||||
List<StockMovement> result = jpaRepository.findAllByBatchId(batchId).stream()
|
List<StockMovement> result = jpaRepository.findAllByBatchId(batchReference).stream()
|
||||||
.map(mapper::toDomain)
|
.map(mapper::toDomain)
|
||||||
.toList();
|
.toList();
|
||||||
return Result.success(result);
|
return Result.success(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.warn("Database error in findAllByBatchId", e);
|
logger.warn("Database error in findAllByBatchReference", e);
|
||||||
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -120,6 +120,32 @@ public class JpaStockMovementRepository implements StockMovementRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<RepositoryError, List<StockMovement>> findAllByPerformedAtAfter(Instant from) {
|
||||||
|
try {
|
||||||
|
List<StockMovement> result = jpaRepository.findAllByPerformedAtGreaterThanEqual(from).stream()
|
||||||
|
.map(mapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
return Result.success(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Database error in findAllByPerformedAtAfter", e);
|
||||||
|
return Result.failure(new RepositoryError.DatabaseError(e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result<RepositoryError, List<StockMovement>> findAllByPerformedAtBefore(Instant to) {
|
||||||
|
try {
|
||||||
|
List<StockMovement> result = jpaRepository.findAllByPerformedAtLessThanEqual(to).stream()
|
||||||
|
.map(mapper::toDomain)
|
||||||
|
.toList();
|
||||||
|
return Result.success(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warn("Database error in findAllByPerformedAtBefore", 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) {
|
||||||
|
|
|
||||||
|
|
@ -17,4 +17,8 @@ public interface StockMovementJpaRepository extends JpaRepository<StockMovementE
|
||||||
List<StockMovementEntity> findAllByBatchId(String batchId);
|
List<StockMovementEntity> findAllByBatchId(String batchId);
|
||||||
|
|
||||||
List<StockMovementEntity> findAllByPerformedAtBetween(Instant from, Instant to);
|
List<StockMovementEntity> findAllByPerformedAtBetween(Instant from, Instant to);
|
||||||
|
|
||||||
|
List<StockMovementEntity> findAllByPerformedAtGreaterThanEqual(Instant from);
|
||||||
|
|
||||||
|
List<StockMovementEntity> findAllByPerformedAtLessThanEqual(Instant to);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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="031-add-performed-at-index-to-stock-movements" author="effigenix">
|
||||||
|
<createIndex indexName="idx_stock_movements_performed_at" tableName="stock_movements">
|
||||||
|
<column name="performed_at"/>
|
||||||
|
</createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
|
|
@ -35,5 +35,6 @@
|
||||||
<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"/>
|
<include file="db/changelog/changes/030-add-batch-id-index-to-stock-movements.xml"/>
|
||||||
|
<include file="db/changelog/changes/031-add-performed-at-index-to-stock-movements.xml"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@DisplayName("ListStockMovements Use Case")
|
@DisplayName("ListStockMovements Use Case")
|
||||||
|
|
@ -155,20 +157,20 @@ class ListStockMovementsTest {
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("should filter by batchReference")
|
@DisplayName("should filter by batchReference")
|
||||||
void shouldFilterByBatchReference() {
|
void shouldFilterByBatchReference() {
|
||||||
when(repository.findAllByBatchId("CHARGE-001"))
|
when(repository.findAllByBatchReference("CHARGE-001"))
|
||||||
.thenReturn(Result.success(List.of(sampleMovement)));
|
.thenReturn(Result.success(List.of(sampleMovement)));
|
||||||
|
|
||||||
var result = useCase.execute(null, null, null, "CHARGE-001", null, null, actor);
|
var result = useCase.execute(null, null, null, "CHARGE-001", null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
assertThat(result.unsafeGetValue()).hasSize(1);
|
assertThat(result.unsafeGetValue()).hasSize(1);
|
||||||
verify(repository).findAllByBatchId("CHARGE-001");
|
verify(repository).findAllByBatchReference("CHARGE-001");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("should return empty list when no movements for batch")
|
@DisplayName("should return empty list when no movements for batch")
|
||||||
void shouldReturnEmptyForUnknownBatch() {
|
void shouldReturnEmptyForUnknownBatch() {
|
||||||
when(repository.findAllByBatchId("UNKNOWN"))
|
when(repository.findAllByBatchReference("UNKNOWN"))
|
||||||
.thenReturn(Result.success(List.of()));
|
.thenReturn(Result.success(List.of()));
|
||||||
|
|
||||||
var result = useCase.execute(null, null, null, "UNKNOWN", null, null, actor);
|
var result = useCase.execute(null, null, null, "UNKNOWN", null, null, actor);
|
||||||
|
|
@ -177,10 +179,19 @@ class ListStockMovementsTest {
|
||||||
assertThat(result.unsafeGetValue()).isEmpty();
|
assertThat(result.unsafeGetValue()).isEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("should fail with InvalidBatchReference when blank")
|
||||||
|
void shouldFailWhenBatchReferenceBlank() {
|
||||||
|
var result = useCase.execute(null, null, null, " ", null, null, actor);
|
||||||
|
|
||||||
|
assertThat(result.isFailure()).isTrue();
|
||||||
|
assertThat(result.unsafeGetError()).isInstanceOf(StockMovementError.InvalidBatchReference.class);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("should fail when repository fails for batchReference")
|
@DisplayName("should fail when repository fails for batchReference")
|
||||||
void shouldFailWhenRepositoryFailsForBatch() {
|
void shouldFailWhenRepositoryFailsForBatch() {
|
||||||
when(repository.findAllByBatchId("CHARGE-001"))
|
when(repository.findAllByBatchReference("CHARGE-001"))
|
||||||
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
.thenReturn(Result.failure(new RepositoryError.DatabaseError("connection lost")));
|
||||||
|
|
||||||
var result = useCase.execute(null, null, null, "CHARGE-001", null, null, actor);
|
var result = useCase.execute(null, null, null, "CHARGE-001", null, null, actor);
|
||||||
|
|
@ -240,25 +251,25 @@ class ListStockMovementsTest {
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("should filter with only from (open-ended)")
|
@DisplayName("should filter with only from (open-ended)")
|
||||||
void shouldFilterByFromOnly() {
|
void shouldFilterByFromOnly() {
|
||||||
when(repository.findAllByPerformedAtBetween(eq(from), any()))
|
when(repository.findAllByPerformedAtAfter(from))
|
||||||
.thenReturn(Result.success(List.of(sampleMovement)));
|
.thenReturn(Result.success(List.of(sampleMovement)));
|
||||||
|
|
||||||
var result = useCase.execute(null, null, null, null, from, null, actor);
|
var result = useCase.execute(null, null, null, null, from, null, actor);
|
||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
verify(repository).findAllByPerformedAtBetween(eq(from), eq(Instant.MAX));
|
verify(repository).findAllByPerformedAtAfter(from);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("should filter with only to (open-ended)")
|
@DisplayName("should filter with only to (open-ended)")
|
||||||
void shouldFilterByToOnly() {
|
void shouldFilterByToOnly() {
|
||||||
when(repository.findAllByPerformedAtBetween(any(), eq(to)))
|
when(repository.findAllByPerformedAtBefore(to))
|
||||||
.thenReturn(Result.success(List.of(sampleMovement)));
|
.thenReturn(Result.success(List.of(sampleMovement)));
|
||||||
|
|
||||||
var result = useCase.execute(null, null, null, null, null, to, actor);
|
var result = useCase.execute(null, null, null, null, null, to, actor);
|
||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
verify(repository).findAllByPerformedAtBetween(eq(Instant.MIN), eq(to));
|
verify(repository).findAllByPerformedAtBefore(to);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -311,7 +322,7 @@ class ListStockMovementsTest {
|
||||||
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()).findAllByBatchReference(any());
|
||||||
verify(repository, never()).findAllByMovementType(any());
|
verify(repository, never()).findAllByMovementType(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -325,20 +336,20 @@ class ListStockMovementsTest {
|
||||||
|
|
||||||
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()).findAllByBatchReference(any());
|
||||||
verify(repository, never()).findAllByMovementType(any());
|
verify(repository, never()).findAllByMovementType(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("batchReference takes priority over movementType")
|
@DisplayName("batchReference takes priority over movementType")
|
||||||
void batchReferenceTakesPriorityOverMovementType() {
|
void batchReferenceTakesPriorityOverMovementType() {
|
||||||
when(repository.findAllByBatchId("CHARGE-001"))
|
when(repository.findAllByBatchReference("CHARGE-001"))
|
||||||
.thenReturn(Result.success(List.of()));
|
.thenReturn(Result.success(List.of()));
|
||||||
|
|
||||||
var result = useCase.execute(null, null, "GOODS_RECEIPT", "CHARGE-001", null, null, actor);
|
var result = useCase.execute(null, null, "GOODS_RECEIPT", "CHARGE-001", null, null, actor);
|
||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
verify(repository).findAllByBatchId("CHARGE-001");
|
verify(repository).findAllByBatchReference("CHARGE-001");
|
||||||
verify(repository, never()).findAllByMovementType(any());
|
verify(repository, never()).findAllByMovementType(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -375,7 +386,7 @@ class ListStockMovementsTest {
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("batchReference takes priority over from/to")
|
@DisplayName("batchReference takes priority over from/to")
|
||||||
void batchReferenceTakesPriorityOverDateRange() {
|
void batchReferenceTakesPriorityOverDateRange() {
|
||||||
when(repository.findAllByBatchId("CHARGE-001"))
|
when(repository.findAllByBatchReference("CHARGE-001"))
|
||||||
.thenReturn(Result.success(List.of()));
|
.thenReturn(Result.success(List.of()));
|
||||||
|
|
||||||
Instant from = Instant.parse("2026-01-01T00:00:00Z");
|
Instant from = Instant.parse("2026-01-01T00:00:00Z");
|
||||||
|
|
@ -383,7 +394,7 @@ class ListStockMovementsTest {
|
||||||
var result = useCase.execute(null, null, null, "CHARGE-001", from, to, actor);
|
var result = useCase.execute(null, null, null, "CHARGE-001", from, to, actor);
|
||||||
|
|
||||||
assertThat(result.isSuccess()).isTrue();
|
assertThat(result.isSuccess()).isTrue();
|
||||||
verify(repository).findAllByBatchId("CHARGE-001");
|
verify(repository).findAllByBatchReference("CHARGE-001");
|
||||||
verify(repository, never()).findAllByPerformedAtBetween(any(), any());
|
verify(repository, never()).findAllByPerformedAtBetween(any(), any());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -555,6 +555,16 @@ class StockMovementControllerIntegrationTest extends AbstractIntegrationTest {
|
||||||
.andExpect(jsonPath("$").isArray());
|
.andExpect(jsonPath("$").isArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Leere batchReference → 400")
|
||||||
|
void filterByBlankBatchReference_returns400() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/inventory/stock-movements")
|
||||||
|
.param("batchReference", " ")
|
||||||
|
.header("Authorization", "Bearer " + adminToken))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("INVALID_BATCH_REFERENCE"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Unbekannte batchReference → 200 mit []")
|
@DisplayName("Unbekannte batchReference → 200 mit []")
|
||||||
void filterByUnknownBatchReference_returns200Empty() throws Exception {
|
void filterByUnknownBatchReference_returns200Empty() throws Exception {
|
||||||
|
|
|
||||||
|
|
@ -106,9 +106,20 @@ public final class InventoryScenario {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ChainBuilder listStockMovementsByDateRange() {
|
public static ChainBuilder listStockMovementsByDateRange() {
|
||||||
return exec(
|
return exec(session -> {
|
||||||
|
var rnd = ThreadLocalRandom.current();
|
||||||
|
int startDay = rnd.nextInt(1, 28);
|
||||||
|
int startMonth = rnd.nextInt(1, 13);
|
||||||
|
int durationDays = rnd.nextInt(7, 90);
|
||||||
|
var from = java.time.LocalDate.of(2026, startMonth, startDay)
|
||||||
|
.atStartOfDay(java.time.ZoneOffset.UTC).toInstant();
|
||||||
|
var to = from.plus(java.time.Duration.ofDays(durationDays));
|
||||||
|
return session
|
||||||
|
.set("dateFrom", from.toString())
|
||||||
|
.set("dateTo", to.toString());
|
||||||
|
}).exec(
|
||||||
http("Bestandsbewegungen nach Zeitraum")
|
http("Bestandsbewegungen nach Zeitraum")
|
||||||
.get("/api/inventory/stock-movements?from=2020-01-01T00:00:00Z&to=2030-12-31T23:59:59Z")
|
.get("/api/inventory/stock-movements?from=#{dateFrom}&to=#{dateTo}")
|
||||||
.header("Authorization", "Bearer #{accessToken}")
|
.header("Authorization", "Bearer #{accessToken}")
|
||||||
.check(status().is(200))
|
.check(status().is(200))
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue