ReturnStockService.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.ReturnInspectedCommand;
import com.ecommerce.inventory.application.model.InventoryModels.ReturnInspectedItem;
import com.ecommerce.inventory.application.model.InventoryModels.ReturnStockView;
import com.ecommerce.inventory.domain.MovementType;
import com.ecommerce.inventory.domain.OutboxStatus;
import com.ecommerce.inventory.domain.ReservationStatus;
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.InventoryReturnEntity;
import com.ecommerce.inventory.infrastructure.persistence.entity.OutboxEventEntity;
import com.ecommerce.inventory.infrastructure.persistence.entity.StockMovementEntity;
import com.ecommerce.inventory.infrastructure.persistence.mapper.ConsumedEventMapper;
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.InventoryReturnMapper;
import com.ecommerce.inventory.infrastructure.persistence.mapper.OutboxEventMapper;
import com.ecommerce.inventory.infrastructure.persistence.mapper.StockMovementMapper;
import com.ecommerce.platform.common.idempotency.PayloadFingerprint;
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.Comparator;
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 ReturnStockService {

    public static final String RETURN_INSPECTED_CONSUMER_GROUP = "inventory-return-inspected-v1";

    private final InventoryReturnMapper returnMapper;
    private final InventoryReservationMapper reservationMapper;
    private final InventoryReservationItemMapper reservationItemMapper;
    private final InventoryBalanceMapper balanceMapper;
    private final StockMovementMapper movementMapper;
    private final ConsumedEventMapper consumedEventMapper;
    private final OutboxEventMapper outboxMapper;
    private final ObjectMapper objectMapper;

    public ReturnStockService(
            InventoryReturnMapper returnMapper,
            InventoryReservationMapper reservationMapper,
            InventoryReservationItemMapper reservationItemMapper,
            InventoryBalanceMapper balanceMapper,
            StockMovementMapper movementMapper,
            ConsumedEventMapper consumedEventMapper,
            OutboxEventMapper outboxMapper,
            ObjectMapper objectMapper) {
        this.returnMapper = returnMapper;
        this.reservationMapper = reservationMapper;
        this.reservationItemMapper = reservationItemMapper;
        this.balanceMapper = balanceMapper;
        this.movementMapper = movementMapper;
        this.consumedEventMapper = consumedEventMapper;
        this.outboxMapper = outboxMapper;
        this.objectMapper = objectMapper;
    }

    @Transactional
    public ReturnStockView stock(ReturnInspectedCommand command) {
        List<ReturnInspectedItem> items = normalize(command.items());
        String requestHash = requestHash(command, items);
        if (consumedEventMapper.insertIfAbsent(
                command.eventId(),
                RETURN_INSPECTED_CONSUMER_GROUP,
                requestHash,
                reservationMapper.currentTime()) != 1) {
            String storedFingerprint = consumedEventMapper.selectPayloadFingerprint(
                    command.eventId(), RETURN_INSPECTED_CONSUMER_GROUP);
            if (!PayloadFingerprint.matches(storedFingerprint, requestHash)) {
                throw new InventoryException(InventoryError.IDEMPOTENCY_CONFLICT);
            }
            InventoryReturnEntity repeated = requireReturn(command.afterSaleNo());
            if (!PayloadFingerprint.matches(repeated.getRequestHash(), requestHash)) {
                throw new InventoryException(InventoryError.IDEMPOTENCY_CONFLICT);
            }
            return view(repeated);
        }
        Instant now = reservationMapper.currentTime();

        InventoryReturnEntity candidate = new InventoryReturnEntity();
        candidate.setId(IdWorker.getId());
        candidate.setAfterSaleNo(command.afterSaleNo());
        candidate.setReturnReceiptNo(command.returnReceiptNo());
        candidate.setOrderNo(command.orderNo());
        candidate.setUserId(command.userId());
        candidate.setWarehouseId(command.warehouseId());
        candidate.setReservationNo(command.reservationNo());
        candidate.setRequestHash(requestHash);
        candidate.setStatus("PENDING");
        candidate.setCreatedAt(now);
        returnMapper.insertOrLockExisting(candidate);

        InventoryReturnEntity inventoryReturn = returnMapper.selectByAfterSaleNoForUpdate(command.afterSaleNo());
        if (inventoryReturn == null) {
            throw new InventoryException(InventoryError.CONCURRENT_MODIFICATION);
        }
        if (!inventoryReturn.getRequestHash().equals(requestHash)) {
            throw new InventoryException(InventoryError.IDEMPOTENCY_CONFLICT);
        }
        if (!candidate.getId().equals(inventoryReturn.getId())) {
            if (!"STOCKED".equals(inventoryReturn.getStatus())) {
                throw new InventoryException(InventoryError.CONCURRENT_MODIFICATION);
            }
            return view(inventoryReturn);
        }

        InventoryReservationEntity reservation = reservationMapper.selectForUpdate(command.reservationNo());
        validateOriginalReservation(reservation, command, items);
        for (ReturnInspectedItem item : items) {
            if (balanceMapper.adjustOnHand(command.warehouseId(), item.skuId(), item.quantity(), now) != 1) {
                throw new InventoryException(InventoryError.CONCURRENT_MODIFICATION);
            }
            InventoryBalanceEntity balance = balanceMapper.selectForUpdate(command.warehouseId(), item.skuId());
            StockMovementEntity movement = new StockMovementEntity();
            movement.setMovementNo(command.afterSaleNo() + ":RETURN:" + item.skuId());
            movement.setWarehouseId(command.warehouseId());
            movement.setSkuId(item.skuId());
            movement.setReservationNo(command.reservationNo());
            movement.setMovementType(MovementType.RETURN.name());
            movement.setQuantityDelta(item.quantity());
            movement.setOnHandAfter(balance.getOnHand());
            movement.setReservedAfter(balance.getReserved());
            movement.setReason("Whole-order return inspected and stocked");
            movement.setCreatedAt(now);
            movementMapper.insert(movement);
        }

        inventoryReturn.setStatus("STOCKED");
        inventoryReturn.setStockedAt(now);
        requireUpdated(returnMapper.updateById(inventoryReturn));
        appendReturnStockedEvent(inventoryReturn, items, now);
        return view(inventoryReturn);
    }

    private void validateOriginalReservation(
            InventoryReservationEntity reservation,
            ReturnInspectedCommand command,
            List<ReturnInspectedItem> returnedItems) {
        if (reservation == null
                || !ReservationStatus.CONFIRMED.name().equals(reservation.getStatus())
                || !reservation.getOrderNo().equals(command.orderNo())
                || !reservation.getWarehouseId().equals(command.warehouseId())) {
            throw new InventoryException(InventoryError.INVALID_STATE);
        }
        List<InventoryReservationItemEntity> original = reservationItemMapper.selectList(
                new LambdaQueryWrapper<InventoryReservationItemEntity>()
                        .eq(InventoryReservationItemEntity::getReservationId, reservation.getId())
                        .orderByAsc(InventoryReservationItemEntity::getSkuId));
        if (original.size() != returnedItems.size()) {
            throw new InventoryException(InventoryError.IDEMPOTENCY_CONFLICT);
        }
        for (int index = 0; index < original.size(); index++) {
            InventoryReservationItemEntity expected = original.get(index);
            ReturnInspectedItem actual = returnedItems.get(index);
            if (!expected.getSkuId().equals(actual.skuId()) || expected.getQuantity() != actual.quantity()) {
                throw new InventoryException(InventoryError.IDEMPOTENCY_CONFLICT);
            }
        }
    }

    private List<ReturnInspectedItem> normalize(List<ReturnInspectedItem> input) {
        if (input == null || input.isEmpty()) {
            throw new InventoryException(InventoryError.INVALID_STATE);
        }
        Map<Long, ReturnInspectedItem> unique = new LinkedHashMap<>();
        for (ReturnInspectedItem item : input) {
            if (item.lineNo() <= 0 || item.skuId() == null || item.skuId() <= 0 || item.quantity() <= 0
                    || unique.putIfAbsent(item.skuId(), item) != null) {
                throw new InventoryException(InventoryError.IDEMPOTENCY_CONFLICT);
            }
        }
        return new ArrayList<>(unique.values()).stream()
                .sorted(Comparator.comparing(ReturnInspectedItem::skuId))
                .toList();
    }

    private String requestHash(ReturnInspectedCommand command, List<ReturnInspectedItem> items) {
        String itemText = items.stream()
                .map(item -> item.lineNo() + ":" + item.skuId() + ":" + item.quantity())
                .collect(Collectors.joining(","));
        return sha256(String.join("|",
                command.afterSaleNo(), command.returnReceiptNo(), command.orderNo(),
                command.userId().toString(), command.warehouseId().toString(), command.reservationNo(), itemText));
    }

    private void appendReturnStockedEvent(
            InventoryReturnEntity inventoryReturn,
            List<ReturnInspectedItem> items,
            Instant now) {
        List<Map<String, Object>> eventItems = items.stream()
                .map(item -> Map.<String, Object>of(
                        "lineNo", item.lineNo(), "skuId", item.skuId(), "quantity", item.quantity()))
                .toList();
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("afterSaleNo", inventoryReturn.getAfterSaleNo());
        payload.put("returnReceiptNo", inventoryReturn.getReturnReceiptNo());
        payload.put("orderNo", inventoryReturn.getOrderNo());
        payload.put("userId", inventoryReturn.getUserId());
        payload.put("warehouseId", inventoryReturn.getWarehouseId());
        payload.put("reservationNo", inventoryReturn.getReservationNo());
        payload.put("status", inventoryReturn.getStatus());
        payload.put("items", eventItems);

        String eventId = UUID.randomUUID().toString();
        Map<String, Object> envelope = new LinkedHashMap<>();
        envelope.put("eventId", eventId);
        envelope.put("eventType", "ReturnStocked");
        envelope.put("aggregateType", "InventoryReturn");
        envelope.put("aggregateId", inventoryReturn.getAfterSaleNo());
        envelope.put("aggregateVersion", 1);
        envelope.put("occurredAt", now);
        envelope.put("producer", "inventory-service");
        envelope.put("payloadVersion", 1);
        envelope.put("payload", payload);

        OutboxEventEntity event = new OutboxEventEntity();
        event.setEventId(eventId);
        event.setEventType("ReturnStocked");
        event.setAggregateType("InventoryReturn");
        event.setAggregateId(inventoryReturn.getAfterSaleNo());
        event.setAggregateVersion(1);
        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 InventoryReturnEntity requireReturn(String afterSaleNo) {
        InventoryReturnEntity result = returnMapper.selectOne(new LambdaQueryWrapper<InventoryReturnEntity>()
                .eq(InventoryReturnEntity::getAfterSaleNo, afterSaleNo));
        if (result == null) {
            throw new InventoryException(InventoryError.RESOURCE_NOT_FOUND);
        }
        return result;
    }

    private ReturnStockView view(InventoryReturnEntity result) {
        return new ReturnStockView(
                result.getAfterSaleNo(), result.getReturnReceiptNo(), result.getOrderNo(), result.getUserId(),
                result.getWarehouseId(), result.getReservationNo(), result.getStatus(),
                result.getCreatedAt(), result.getStockedAt());
    }

    private void requireUpdated(int rows) {
        if (rows != 1) {
            throw new InventoryException(InventoryError.CONCURRENT_MODIFICATION);
        }
    }

    private String sha256(String value) {
        try {
            return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
                    .digest(value.getBytes(StandardCharsets.UTF_8)));
        } catch (NoSuchAlgorithmException exception) {
            throw new IllegalStateException("SHA-256 is unavailable", exception);
        }
    }

    private String writeJson(Object value) {
        try {
            return objectMapper.writeValueAsString(value);
        } catch (JsonProcessingException exception) {
            throw new IllegalStateException("Unable to serialize inventory return event", exception);
        }
    }
}