1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 15:49: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:
Sebastian Frick 2026-02-25 08:37:46 +01:00
parent fa6c0c2d70
commit 0e5d8f7025
13 changed files with 416 additions and 26 deletions

View file

@ -13,6 +13,7 @@ import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
@Transactional(readOnly = true)
@ -28,17 +29,18 @@ public class ListStockMovements {
/**
* 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.
*/
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)) {
return Result.failure(new StockMovementError.Unauthorized("Not authorized to list stock movements"));
}
if (stockId != null) {
StockId sid;
try {
@ -59,6 +61,10 @@ public class ListStockMovements {
return mapResult(stockMovementRepository.findAllByArticleId(aid));
}
if (batchReference != null) {
return mapResult(stockMovementRepository.findAllByBatchId(batchReference));
}
if (movementType != null) {
MovementType type;
try {
@ -70,6 +76,16 @@ public class ListStockMovements {
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());
}

View file

@ -64,6 +64,11 @@ public sealed interface StockMovementError {
@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 {
@Override public String code() { return "REPOSITORY_ERROR"; }
}

View file

@ -4,6 +4,7 @@ import de.effigenix.domain.masterdata.ArticleId;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@ -19,5 +20,9 @@ public interface StockMovementRepository {
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);
}

View file

@ -11,6 +11,7 @@ import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
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
@Transactional
public Result<RepositoryError, Void> save(StockMovement stockMovement) {

View file

@ -3,6 +3,7 @@ package de.effigenix.infrastructure.inventory.persistence.repository;
import de.effigenix.infrastructure.inventory.persistence.entity.StockMovementEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.Instant;
import java.util.List;
public interface StockMovementJpaRepository extends JpaRepository<StockMovementEntity, String> {
@ -12,4 +13,8 @@ public interface StockMovementJpaRepository extends JpaRepository<StockMovementE
List<StockMovementEntity> findAllByArticleId(String articleId);
List<StockMovementEntity> findAllByMovementType(String movementType);
List<StockMovementEntity> findAllByBatchId(String batchId);
List<StockMovementEntity> findAllByPerformedAtBetween(Instant from, Instant to);
}

View file

@ -14,12 +14,14 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.List;
@RestController
@ -73,14 +75,18 @@ public class StockMovementController {
@GetMapping
@PreAuthorize("hasAuthority('STOCK_MOVEMENT_READ')")
@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(
@RequestParam(required = false) String stockId,
@RequestParam(required = false) String articleId,
@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
) {
var result = listStockMovements.execute(stockId, articleId, movementType,
batchReference, from, to,
ActorId.of(authentication.getName()));
if (result.isFailure()) {

View file

@ -64,6 +64,7 @@ public final class InventoryErrorHttpStatusMapper {
case StockMovementError.ReasonRequired e -> 400;
case StockMovementError.ReferenceDocumentRequired e -> 400;
case StockMovementError.InvalidPerformedBy e -> 400;
case StockMovementError.InvalidDateRange e -> 400;
case StockMovementError.Unauthorized e -> 403;
case StockMovementError.RepositoryFailure e -> 500;
};

View file

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

View file

@ -34,5 +34,6 @@
<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/029-seed-stock-movement-permissions.xml"/>
<include file="db/changelog/changes/030-add-batch-id-index-to-stock-movements.xml"/>
</databaseChangeLog>