InventoryService.java
package com.ecommerce.inventory.application.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.ecommerce.inventory.application.exception.InventoryError;
import com.ecommerce.inventory.application.exception.InventoryException;
import com.ecommerce.inventory.application.model.InventoryModels.ReservationItemView;
import com.ecommerce.inventory.application.model.InventoryModels.ReservationLineCommand;
import com.ecommerce.inventory.application.model.InventoryModels.ReservationView;
import com.ecommerce.inventory.application.model.InventoryModels.ReserveInventoryCommand;
import com.ecommerce.inventory.application.model.InventoryModels.StockPosition;
import com.ecommerce.inventory.application.model.InventoryModels.StockSummary;
import com.ecommerce.inventory.application.model.InventoryModels.WarehouseView;
import com.ecommerce.inventory.domain.MovementType;
import com.ecommerce.inventory.domain.OutboxStatus;
import com.ecommerce.inventory.domain.ReservationStatus;
import com.ecommerce.inventory.domain.WarehouseStatus;
import com.ecommerce.inventory.infrastructure.persistence.entity.InventoryBalanceEntity;
import com.ecommerce.inventory.infrastructure.persistence.entity.InventoryReservationEntity;
import com.ecommerce.inventory.infrastructure.persistence.entity.InventoryReservationItemEntity;
import com.ecommerce.inventory.infrastructure.persistence.entity.OutboxEventEntity;
import com.ecommerce.inventory.infrastructure.persistence.entity.StockAdjustmentEntity;
import com.ecommerce.inventory.infrastructure.persistence.entity.StockMovementEntity;
import com.ecommerce.inventory.infrastructure.persistence.entity.WarehouseEntity;
import com.ecommerce.inventory.infrastructure.persistence.mapper.InventoryBalanceMapper;
import com.ecommerce.inventory.infrastructure.persistence.mapper.InventoryReservationItemMapper;
import com.ecommerce.inventory.infrastructure.persistence.mapper.InventoryReservationMapper;
import com.ecommerce.inventory.infrastructure.persistence.mapper.OutboxEventMapper;
import com.ecommerce.inventory.infrastructure.persistence.mapper.StockAdjustmentMapper;
import com.ecommerce.inventory.infrastructure.persistence.mapper.StockMovementMapper;
import com.ecommerce.inventory.infrastructure.persistence.mapper.WarehouseMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
public class InventoryService {
private final WarehouseMapper warehouseMapper;
private final InventoryBalanceMapper balanceMapper;
private final StockAdjustmentMapper adjustmentMapper;
private final InventoryReservationMapper reservationMapper;
private final InventoryReservationItemMapper reservationItemMapper;
private final StockMovementMapper movementMapper;
private final OutboxEventMapper outboxMapper;
private final ObjectMapper objectMapper;
public InventoryService(
WarehouseMapper warehouseMapper,
InventoryBalanceMapper balanceMapper,
StockAdjustmentMapper adjustmentMapper,
InventoryReservationMapper reservationMapper,
InventoryReservationItemMapper reservationItemMapper,
StockMovementMapper movementMapper,
OutboxEventMapper outboxMapper,
ObjectMapper objectMapper) {
this.warehouseMapper = warehouseMapper;
this.balanceMapper = balanceMapper;
this.adjustmentMapper = adjustmentMapper;
this.reservationMapper = reservationMapper;
this.reservationItemMapper = reservationItemMapper;
this.movementMapper = movementMapper;
this.outboxMapper = outboxMapper;
this.objectMapper = objectMapper;
}
@Transactional
public WarehouseView createWarehouse(String code, String name) {
Instant now = reservationMapper.currentTime();
WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setCode(code);
warehouse.setName(name);
warehouse.setStatus(WarehouseStatus.ACTIVE.name());
warehouse.setVersion(0);
warehouse.setCreatedAt(now);
warehouse.setUpdatedAt(now);
warehouseMapper.insert(warehouse);
return warehouseView(warehouse);
}
@Transactional(readOnly = true)
public List<WarehouseView> listWarehouses() {
return warehouseMapper.selectList(new LambdaQueryWrapper<WarehouseEntity>()
.orderByAsc(WarehouseEntity::getCode))
.stream().map(this::warehouseView).toList();
}
@Transactional(readOnly = true)
public WarehouseView getActiveWarehouseByCode(String code) {
WarehouseEntity warehouse = warehouseMapper.selectOne(new LambdaQueryWrapper<WarehouseEntity>()
.eq(WarehouseEntity::getCode, code)
.eq(WarehouseEntity::getStatus, WarehouseStatus.ACTIVE.name()));
if (warehouse == null) {
throw new InventoryException(InventoryError.RESOURCE_NOT_FOUND);
}
return warehouseView(warehouse);
}
@Transactional
public StockPosition adjustStock(
String movementNo,
Long warehouseId,
Long skuId,
long quantityDelta,
String reason) {
if (quantityDelta == 0) {
throw new InventoryException(InventoryError.INVALID_ADJUSTMENT);
}
requireActiveWarehouse(warehouseId);
String requestHash = sha256(String.join("|",
movementNo, warehouseId.toString(), skuId.toString(), Long.toString(quantityDelta), reason));
Instant now = reservationMapper.currentTime();
StockAdjustmentEntity candidate = new StockAdjustmentEntity();
candidate.setId(IdWorker.getId());
candidate.setMovementNo(movementNo);
candidate.setRequestHash(requestHash);
candidate.setWarehouseId(warehouseId);
candidate.setSkuId(skuId);
candidate.setQuantityDelta(quantityDelta);
candidate.setReason(reason);
candidate.setStatus("PENDING");
candidate.setCreatedAt(now);
candidate.setUpdatedAt(now);
adjustmentMapper.insertOrLockExisting(candidate);
StockAdjustmentEntity adjustment = adjustmentMapper.selectForUpdate(movementNo);
if (adjustment == null) {
throw new InventoryException(InventoryError.CONCURRENT_MODIFICATION);
}
requireSameHash(adjustment.getRequestHash(), requestHash);
if (!candidate.getId().equals(adjustment.getId())) {
if (!"APPLIED".equals(adjustment.getStatus())) {
throw new InventoryException(InventoryError.CONCURRENT_MODIFICATION);
}
return stockPosition(requireBalance(warehouseId, skuId));
}
balanceMapper.insertZeroIfAbsent(IdWorker.getId(), warehouseId, skuId, now);
if (balanceMapper.adjustOnHand(warehouseId, skuId, quantityDelta, now) != 1) {
throw new InventoryException(InventoryError.INVALID_ADJUSTMENT);
}
InventoryBalanceEntity balance = balanceMapper.selectForUpdate(warehouseId, skuId);
insertMovement(movementNo, warehouseId, skuId, null, MovementType.ADJUSTMENT,
quantityDelta, balance, reason, now);
adjustment.setStatus("APPLIED");
adjustment.setUpdatedAt(now);
requireUpdated(adjustmentMapper.updateById(adjustment));
appendOutboxEvent(
"StockAdjusted",
"InventoryBalance",
warehouseId + ":" + skuId,
balance.getVersion(),
Map.of(
"movementNo", movementNo,
"warehouseId", warehouseId,
"skuId", skuId,
"quantityDelta", quantityDelta,
"onHand", balance.getOnHand(),
"reserved", balance.getReserved()
),
now
);
return stockPosition(balance);
}
@Transactional(readOnly = true)
public StockSummary getStockSummary(Long skuId) {
List<InventoryBalanceEntity> balances = balanceMapper.selectList(
new LambdaQueryWrapper<InventoryBalanceEntity>()
.eq(InventoryBalanceEntity::getSkuId, skuId));
long onHand = balances.stream().mapToLong(InventoryBalanceEntity::getOnHand).sum();
long reserved = balances.stream().mapToLong(InventoryBalanceEntity::getReserved).sum();
return new StockSummary(skuId, onHand, reserved, onHand - reserved);
}
@Transactional(readOnly = true)
public StockPosition getStockPosition(Long warehouseId, Long skuId) {
return stockPosition(requireBalance(warehouseId, skuId));
}
@Transactional
public ReservationView reserve(ReserveInventoryCommand command) {
if (command == null || command.expiresAt() == null) {
throw new InventoryException(InventoryError.INVALID_STATE);
}
List<ReservationLineCommand> items = normalizeItems(command.items());
String requestHash = reservationHash(command, items);
requireActiveWarehouse(command.warehouseId());
Instant now = reservationMapper.currentTime();
InventoryReservationEntity candidate = new InventoryReservationEntity();
candidate.setId(IdWorker.getId());
candidate.setReservationNo(command.reservationNo());
candidate.setOrderNo(command.orderNo());
candidate.setRequestHash(requestHash);
candidate.setWarehouseId(command.warehouseId());
candidate.setStatus(ReservationStatus.PENDING.name());
candidate.setExpiresAt(command.expiresAt());
candidate.setVersion(0);
candidate.setCreatedAt(now);
candidate.setUpdatedAt(now);
reservationMapper.insertOrLockExisting(candidate);
InventoryReservationEntity reservation = reservationMapper.selectForUpdate(command.reservationNo());
if (reservation == null) {
throw new InventoryException(InventoryError.CONCURRENT_MODIFICATION);
}
requireSameHash(reservation.getRequestHash(), requestHash);
if (!candidate.getId().equals(reservation.getId())) {
return reservationView(reservation);
}
if (!reservation.getExpiresAt().isAfter(now)) {
transitionReservation(reservation, ReservationStatus.REJECTED, now);
appendReservationEvent(reservation, "InventoryReservationRejected", items, now);
return reservationView(reservation);
}
for (ReservationLineCommand item : items) {
InventoryReservationItemEntity entity = new InventoryReservationItemEntity();
entity.setReservationId(reservation.getId());
entity.setSkuId(item.skuId());
entity.setQuantity(item.quantity());
entity.setCreatedAt(now);
reservationItemMapper.insert(entity);
}
List<ReservationLineCommand> applied = new ArrayList<>();
for (ReservationLineCommand item : items) {
if (balanceMapper.reserve(command.warehouseId(), item.skuId(), item.quantity(), now) != 1) {
rollbackReserved(command.warehouseId(), applied, now);
transitionReservation(reservation, ReservationStatus.REJECTED, now);
appendReservationEvent(reservation, "InventoryReservationRejected", items, now);
return reservationView(reservation);
}
applied.add(item);
}
for (ReservationLineCommand item : items) {
InventoryBalanceEntity balance = balanceMapper.selectForUpdate(command.warehouseId(), item.skuId());
insertMovement(
movementNo(command.reservationNo(), MovementType.RESERVE, item.skuId()),
command.warehouseId(),
item.skuId(),
command.reservationNo(),
MovementType.RESERVE,
item.quantity(),
balance,
"Inventory reserved for order " + command.orderNo(),
now
);
}
transitionReservation(reservation, ReservationStatus.RESERVED, now);
appendReservationEvent(reservation, "InventoryReserved", items, now);
return reservationView(reservation);
}
@Transactional(readOnly = true)
public ReservationView getReservation(String reservationNo) {
InventoryReservationEntity reservation = reservationMapper.selectOne(
new LambdaQueryWrapper<InventoryReservationEntity>()
.eq(InventoryReservationEntity::getReservationNo, reservationNo));
if (reservation == null) {
throw new InventoryException(InventoryError.RESOURCE_NOT_FOUND);
}
return reservationView(reservation);
}
@Transactional
public ReservationView confirmReservation(String reservationNo) {
InventoryReservationEntity reservation = requireLockedReservation(reservationNo);
if (ReservationStatus.CONFIRMED.name().equals(reservation.getStatus())) {
return reservationView(reservation);
}
if (List.of(
ReservationStatus.EXPIRED.name(),
ReservationStatus.RELEASED.name(),
ReservationStatus.REJECTED.name()).contains(reservation.getStatus())) {
return reservationView(reservation);
}
requireReserved(reservation);
Instant now = reservationMapper.currentTime();
if (!reservation.getExpiresAt().isAfter(now)) {
return releaseReservation(
reservationNo,
ReservationStatus.EXPIRED,
true);
}
List<InventoryReservationItemEntity> items = reservationItems(reservation.getId());
for (InventoryReservationItemEntity item : items) {
if (balanceMapper.confirm(reservation.getWarehouseId(), item.getSkuId(), item.getQuantity(), now) != 1) {
throw new InventoryException(InventoryError.CONCURRENT_MODIFICATION);
}
InventoryBalanceEntity balance = balanceMapper.selectForUpdate(
reservation.getWarehouseId(), item.getSkuId());
insertMovement(
movementNo(reservationNo, MovementType.CONFIRM, item.getSkuId()),
reservation.getWarehouseId(), item.getSkuId(), reservationNo,
MovementType.CONFIRM, -item.getQuantity(), balance,
"Reserved inventory confirmed", now);
}
transitionReservation(reservation, ReservationStatus.CONFIRMED, now);
appendReservationEvent(reservation, "InventoryReservationConfirmed", toCommands(items), now);
return reservationView(reservation);
}
@Transactional
public ReservationView releaseReservation(String reservationNo) {
return releaseReservation(reservationNo, ReservationStatus.RELEASED, false);
}
@Transactional
public ReservationView expireReservation(String reservationNo) {
return releaseReservation(reservationNo, ReservationStatus.EXPIRED, true);
}
@Transactional(readOnly = true)
public List<String> findExpiredReservationNumbers(int limit) {
return reservationMapper.selectExpiredReservationNumbers(
reservationMapper.currentTime(), limit);
}
private ReservationView releaseReservation(
String reservationNo,
ReservationStatus targetStatus,
boolean requireExpired) {
InventoryReservationEntity reservation = requireLockedReservation(reservationNo);
if (targetStatus.name().equals(reservation.getStatus())) {
return reservationView(reservation);
}
requireReserved(reservation);
Instant now = reservationMapper.currentTime();
if (requireExpired && reservation.getExpiresAt().isAfter(now)) {
throw new InventoryException(InventoryError.INVALID_STATE);
}
List<InventoryReservationItemEntity> items = reservationItems(reservation.getId());
MovementType movementType = targetStatus == ReservationStatus.EXPIRED
? MovementType.EXPIRE : MovementType.RELEASE;
for (InventoryReservationItemEntity item : items) {
if (balanceMapper.release(reservation.getWarehouseId(), item.getSkuId(), item.getQuantity(), now) != 1) {
throw new InventoryException(InventoryError.CONCURRENT_MODIFICATION);
}
InventoryBalanceEntity balance = balanceMapper.selectForUpdate(
reservation.getWarehouseId(), item.getSkuId());
insertMovement(
movementNo(reservationNo, movementType, item.getSkuId()),
reservation.getWarehouseId(), item.getSkuId(), reservationNo,
movementType, -item.getQuantity(), balance,
targetStatus == ReservationStatus.EXPIRED
? "Reservation expired" : "Reservation released",
now);
}
transitionReservation(reservation, targetStatus, now);
appendReservationEvent(
reservation,
targetStatus == ReservationStatus.EXPIRED
? "InventoryReservationExpired" : "InventoryReservationReleased",
toCommands(items),
now
);
return reservationView(reservation);
}
private void rollbackReserved(Long warehouseId, List<ReservationLineCommand> applied, Instant now) {
for (ReservationLineCommand item : applied) {
if (balanceMapper.release(warehouseId, item.skuId(), item.quantity(), now) != 1) {
throw new InventoryException(InventoryError.CONCURRENT_MODIFICATION);
}
}
}
private void transitionReservation(
InventoryReservationEntity reservation,
ReservationStatus target,
Instant now) {
reservation.setStatus(target.name());
reservation.setUpdatedAt(now);
requireUpdated(reservationMapper.updateById(reservation));
}
private void appendReservationEvent(
InventoryReservationEntity reservation,
String eventType,
List<ReservationLineCommand> items,
Instant now) {
List<Map<String, Object>> payloadItems = items.stream()
.map(item -> Map.<String, Object>of("skuId", item.skuId(), "quantity", item.quantity()))
.toList();
appendOutboxEvent(
eventType,
"InventoryReservation",
reservation.getReservationNo(),
reservation.getVersion(),
Map.of(
"reservationNo", reservation.getReservationNo(),
"orderNo", reservation.getOrderNo(),
"warehouseId", reservation.getWarehouseId(),
"status", reservation.getStatus(),
"expiresAt", reservation.getExpiresAt(),
"items", payloadItems
),
now
);
}
private void appendOutboxEvent(
String eventType,
String aggregateType,
String aggregateId,
int aggregateVersion,
Map<String, Object> eventPayload,
Instant now) {
String eventId = UUID.randomUUID().toString();
Map<String, Object> envelope = new LinkedHashMap<>();
envelope.put("eventId", eventId);
envelope.put("eventType", eventType);
envelope.put("aggregateType", aggregateType);
envelope.put("aggregateId", aggregateId);
envelope.put("aggregateVersion", aggregateVersion);
envelope.put("occurredAt", now);
envelope.put("producer", "inventory-service");
envelope.put("payloadVersion", 1);
envelope.put("payload", eventPayload);
OutboxEventEntity event = new OutboxEventEntity();
event.setEventId(eventId);
event.setEventType(eventType);
event.setAggregateType(aggregateType);
event.setAggregateId(aggregateId);
event.setAggregateVersion(aggregateVersion);
event.setPayload(writeJson(envelope));
event.setStatus(OutboxStatus.PENDING.name());
event.setAttempts(0);
event.setNextAttemptAt(now);
event.setCreatedAt(now);
event.setUpdatedAt(now);
outboxMapper.insert(event);
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Unable to serialize inventory event", exception);
}
}
private List<ReservationLineCommand> normalizeItems(List<ReservationLineCommand> input) {
if (input.isEmpty()) {
throw new InventoryException(InventoryError.INVALID_STATE);
}
Map<Long, Long> quantities = new LinkedHashMap<>();
for (ReservationLineCommand item : input) {
if (item.skuId() == null || item.skuId() <= 0 || item.quantity() <= 0) {
throw new InventoryException(InventoryError.INVALID_STATE);
}
if (quantities.putIfAbsent(item.skuId(), item.quantity()) != null) {
throw new InventoryException(InventoryError.IDEMPOTENCY_CONFLICT);
}
}
return quantities.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(entry -> new ReservationLineCommand(entry.getKey(), entry.getValue()))
.toList();
}
private String reservationHash(ReserveInventoryCommand command, List<ReservationLineCommand> items) {
String itemText = items.stream()
.map(item -> item.skuId() + ":" + item.quantity())
.collect(Collectors.joining(","));
return sha256(String.join("|",
command.reservationNo(), command.orderNo(), command.warehouseId().toString(),
command.expiresAt().toString(), itemText));
}
private String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
private WarehouseEntity requireActiveWarehouse(Long warehouseId) {
WarehouseEntity warehouse = warehouseMapper.selectById(warehouseId);
if (warehouse == null || !WarehouseStatus.ACTIVE.name().equals(warehouse.getStatus())) {
throw new InventoryException(InventoryError.RESOURCE_NOT_FOUND);
}
return warehouse;
}
private InventoryBalanceEntity requireBalance(Long warehouseId, Long skuId) {
InventoryBalanceEntity balance = balanceMapper.selectOne(
new LambdaQueryWrapper<InventoryBalanceEntity>()
.eq(InventoryBalanceEntity::getWarehouseId, warehouseId)
.eq(InventoryBalanceEntity::getSkuId, skuId));
if (balance == null) {
throw new InventoryException(InventoryError.RESOURCE_NOT_FOUND);
}
return balance;
}
private InventoryReservationEntity requireLockedReservation(String reservationNo) {
InventoryReservationEntity reservation = reservationMapper.selectForUpdate(reservationNo);
if (reservation == null) {
throw new InventoryException(InventoryError.RESOURCE_NOT_FOUND);
}
return reservation;
}
private void requireReserved(InventoryReservationEntity reservation) {
if (!ReservationStatus.RESERVED.name().equals(reservation.getStatus())) {
throw new InventoryException(InventoryError.INVALID_STATE);
}
}
private void requireSameHash(String actualHash, String expectedHash) {
if (!expectedHash.equals(actualHash)) {
throw new InventoryException(InventoryError.IDEMPOTENCY_CONFLICT);
}
}
private void requireUpdated(int updatedRows) {
if (updatedRows != 1) {
throw new InventoryException(InventoryError.CONCURRENT_MODIFICATION);
}
}
private List<InventoryReservationItemEntity> reservationItems(Long reservationId) {
return reservationItemMapper.selectList(
new LambdaQueryWrapper<InventoryReservationItemEntity>()
.eq(InventoryReservationItemEntity::getReservationId, reservationId)
.orderByAsc(InventoryReservationItemEntity::getSkuId));
}
private List<ReservationLineCommand> toCommands(List<InventoryReservationItemEntity> items) {
return items.stream()
.map(item -> new ReservationLineCommand(item.getSkuId(), item.getQuantity()))
.toList();
}
private void insertMovement(
String movementNo,
Long warehouseId,
Long skuId,
String reservationNo,
MovementType movementType,
long quantityDelta,
InventoryBalanceEntity balance,
String reason,
Instant now) {
StockMovementEntity movement = new StockMovementEntity();
movement.setMovementNo(movementNo);
movement.setWarehouseId(warehouseId);
movement.setSkuId(skuId);
movement.setReservationNo(reservationNo);
movement.setMovementType(movementType.name());
movement.setQuantityDelta(quantityDelta);
movement.setOnHandAfter(balance.getOnHand());
movement.setReservedAfter(balance.getReserved());
movement.setReason(reason);
movement.setCreatedAt(now);
movementMapper.insert(movement);
}
private String movementNo(String reservationNo, MovementType movementType, Long skuId) {
return reservationNo + ":" + movementType.name() + ":" + skuId;
}
private WarehouseView warehouseView(WarehouseEntity warehouse) {
return new WarehouseView(warehouse.getId(), warehouse.getCode(), warehouse.getName(),
warehouse.getStatus(), warehouse.getVersion());
}
private StockPosition stockPosition(InventoryBalanceEntity balance) {
return new StockPosition(
balance.getWarehouseId(),
balance.getSkuId(),
balance.getOnHand(),
balance.getReserved(),
balance.getOnHand() - balance.getReserved(),
balance.getVersion()
);
}
private ReservationView reservationView(InventoryReservationEntity reservation) {
List<ReservationItemView> items = reservationItems(reservation.getId()).stream()
.map(item -> new ReservationItemView(item.getSkuId(), item.getQuantity()))
.toList();
return new ReservationView(
reservation.getReservationNo(),
reservation.getOrderNo(),
reservation.getWarehouseId(),
reservation.getStatus(),
reservation.getExpiresAt(),
reservation.getVersion(),
items
);
}
}