ReturnReceiptService.java
package com.ecommerce.fulfillment.application.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.ecommerce.fulfillment.application.exception.FulfillmentError;
import com.ecommerce.fulfillment.application.exception.FulfillmentException;
import com.ecommerce.fulfillment.application.model.FulfillmentModels.AfterSaleApprovedCommand;
import com.ecommerce.fulfillment.application.model.FulfillmentModels.AfterSaleApprovedItem;
import com.ecommerce.fulfillment.application.model.FulfillmentModels.ReturnItemView;
import com.ecommerce.fulfillment.application.model.FulfillmentModels.ReturnReceiptView;
import com.ecommerce.fulfillment.application.model.FulfillmentModels.SubmitReturnShipmentCommand;
import com.ecommerce.fulfillment.domain.OutboxStatus;
import com.ecommerce.fulfillment.domain.ReturnReceiptStatus;
import com.ecommerce.fulfillment.infrastructure.persistence.entity.OutboxEventEntity;
import com.ecommerce.fulfillment.infrastructure.persistence.entity.ReturnItemEntity;
import com.ecommerce.fulfillment.infrastructure.persistence.entity.ReturnReceiptEntity;
import com.ecommerce.fulfillment.infrastructure.persistence.entity.ReturnStatusHistoryEntity;
import com.ecommerce.fulfillment.infrastructure.persistence.mapper.ConsumedEventMapper;
import com.ecommerce.fulfillment.infrastructure.persistence.mapper.OutboxEventMapper;
import com.ecommerce.fulfillment.infrastructure.persistence.mapper.ReturnItemMapper;
import com.ecommerce.fulfillment.infrastructure.persistence.mapper.ReturnReceiptMapper;
import com.ecommerce.fulfillment.infrastructure.persistence.mapper.ReturnStatusHistoryMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.MDC;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
@Service
public class ReturnReceiptService {
public static final String AFTER_SALE_APPROVED_CONSUMER_GROUP = "fulfillment-after-sale-approved-v1";
private final ReturnReceiptMapper receiptMapper;
private final ReturnItemMapper itemMapper;
private final ReturnStatusHistoryMapper historyMapper;
private final ConsumedEventMapper consumedEventMapper;
private final OutboxEventMapper outboxMapper;
private final ObjectMapper objectMapper;
public ReturnReceiptService(
ReturnReceiptMapper receiptMapper,
ReturnItemMapper itemMapper,
ReturnStatusHistoryMapper historyMapper,
ConsumedEventMapper consumedEventMapper,
OutboxEventMapper outboxMapper,
ObjectMapper objectMapper) {
this.receiptMapper = receiptMapper;
this.itemMapper = itemMapper;
this.historyMapper = historyMapper;
this.consumedEventMapper = consumedEventMapper;
this.outboxMapper = outboxMapper;
this.objectMapper = objectMapper;
}
@Transactional
public ReturnReceiptView createFromAfterSaleApproved(AfterSaleApprovedCommand command) {
String payloadFingerprint = approvalFingerprint(command);
if (consumedEventMapper.insertIfAbsent(
command.eventId(),
AFTER_SALE_APPROVED_CONSUMER_GROUP,
payloadFingerprint,
receiptMapper.currentTime()) != 1) {
String storedFingerprint = consumedEventMapper.selectPayloadFingerprint(
command.eventId(), AFTER_SALE_APPROVED_CONSUMER_GROUP);
if (!ConsumedEventFingerprint.matches(storedFingerprint, payloadFingerprint)) {
throw new FulfillmentException(FulfillmentError.IDEMPOTENCY_CONFLICT);
}
ReturnReceiptEntity repeated = receiptMapper.selectByAfterSaleNoForUpdate(command.afterSaleNo());
if (repeated == null) {
throw new FulfillmentException(FulfillmentError.IDEMPOTENCY_CONFLICT);
}
requireSameApproval(repeated, command);
return view(repeated);
}
ReturnReceiptEntity existing = receiptMapper.selectByAfterSaleNoForUpdate(command.afterSaleNo());
if (existing != null) {
requireSameApproval(existing, command);
return view(existing);
}
validateApproval(command);
Instant now = receiptMapper.currentTime();
long id = IdWorker.getId();
ReturnReceiptEntity receipt = new ReturnReceiptEntity();
receipt.setId(id);
receipt.setReturnReceiptNo("RET" + id);
receipt.setAfterSaleNo(command.afterSaleNo());
receipt.setOrderNo(command.orderNo());
receipt.setUserId(command.userId());
receipt.setWarehouseId(command.warehouseId());
receipt.setReservationNo(command.reservationNo());
receipt.setStatus(ReturnReceiptStatus.WAIT_SHIPMENT.name());
receipt.setRefundAmount(command.refundAmount());
receipt.setVersion(0);
receipt.setCreatedAt(now);
receipt.setUpdatedAt(now);
receiptMapper.insert(receipt);
for (AfterSaleApprovedItem source : command.items()) {
ReturnItemEntity item = new ReturnItemEntity();
item.setId(IdWorker.getId());
item.setReturnReceiptId(id);
item.setLineNo(source.lineNo());
item.setSkuId(source.skuId());
item.setQuantity(source.quantity());
item.setRefundableAmount(source.refundableAmount());
item.setCreatedAt(now);
itemMapper.insert(item);
}
appendHistory(receipt, null, ReturnReceiptStatus.WAIT_SHIPMENT.name(),
"CREATE_RETURN_RECEIPT", null, "SYSTEM", "trade-service", now);
appendEvent(receipt, "ReturnReceiptCreated", now);
return view(receipt);
}
@Transactional
public ReturnReceiptView submitShipment(
Long userId,
String returnReceiptNo,
SubmitReturnShipmentCommand command) {
ReturnReceiptEntity receipt = requireLocked(returnReceiptNo);
requireOwner(receipt, userId);
if (ReturnReceiptStatus.RETURNING.name().equals(receipt.getStatus())
&& command.carrier().equals(receipt.getCarrier())
&& command.trackingNo().equals(receipt.getTrackingNo())) {
return view(receipt);
}
requireStatus(receipt, ReturnReceiptStatus.WAIT_SHIPMENT);
receipt.setCarrier(command.carrier());
receipt.setTrackingNo(command.trackingNo());
receipt.setShippedAt(receiptMapper.currentTime());
try {
transition(receipt, ReturnReceiptStatus.RETURNING, "SUBMIT_RETURN_SHIPMENT", null,
"CUSTOMER", userId.toString(), "ReturnShipmentSubmitted");
} catch (DataIntegrityViolationException exception) {
throw new FulfillmentException(FulfillmentError.DUPLICATE_TRACKING);
}
return view(receipt);
}
@Transactional
public ReturnReceiptView receive(String returnReceiptNo, String operatorId) {
ReturnReceiptEntity receipt = requireLocked(returnReceiptNo);
if (List.of(ReturnReceiptStatus.RECEIVED.name(), ReturnReceiptStatus.INSPECTED.name())
.contains(receipt.getStatus())) {
return view(receipt);
}
requireStatus(receipt, ReturnReceiptStatus.RETURNING);
receipt.setReceivedAt(receiptMapper.currentTime());
transition(receipt, ReturnReceiptStatus.RECEIVED, "RECEIVE_RETURN", null,
"WAREHOUSE", operatorId, "ReturnReceived");
return view(receipt);
}
@Transactional
public ReturnReceiptView inspect(String returnReceiptNo, String remark, String operatorId) {
ReturnReceiptEntity receipt = requireLocked(returnReceiptNo);
if (ReturnReceiptStatus.INSPECTED.name().equals(receipt.getStatus())) {
return view(receipt);
}
requireStatus(receipt, ReturnReceiptStatus.RECEIVED);
receipt.setInspectionRemark(remark);
receipt.setInspectedAt(receiptMapper.currentTime());
transition(receipt, ReturnReceiptStatus.INSPECTED, "INSPECT_RETURN", remark,
"WAREHOUSE", operatorId, "ReturnInspected");
return view(receipt);
}
public ReturnReceiptView getForUser(Long userId, String returnReceiptNo) {
ReturnReceiptEntity receipt = require(returnReceiptNo);
requireOwner(receipt, userId);
return view(receipt);
}
public List<ReturnReceiptView> listForUser(Long userId) {
return receiptMapper.selectList(new LambdaQueryWrapper<ReturnReceiptEntity>()
.eq(ReturnReceiptEntity::getUserId, userId)
.orderByDesc(ReturnReceiptEntity::getCreatedAt))
.stream().map(this::view).toList();
}
public ReturnReceiptView get(String returnReceiptNo) {
return view(require(returnReceiptNo));
}
public List<ReturnReceiptView> list(String status) {
LambdaQueryWrapper<ReturnReceiptEntity> query = new LambdaQueryWrapper<>();
if (status != null && !status.isBlank()) {
try {
query.eq(ReturnReceiptEntity::getStatus, ReturnReceiptStatus.valueOf(status).name());
} catch (IllegalArgumentException exception) {
throw new FulfillmentException(FulfillmentError.INVALID_STATE);
}
}
query.orderByDesc(ReturnReceiptEntity::getCreatedAt);
return receiptMapper.selectList(query).stream().map(this::view).toList();
}
private void validateApproval(AfterSaleApprovedCommand command) {
if (command.refundAmount() == null || command.refundAmount().signum() < 0 || command.items().isEmpty()) {
throw new FulfillmentException(FulfillmentError.IDEMPOTENCY_CONFLICT);
}
BigDecimal total = BigDecimal.ZERO;
for (AfterSaleApprovedItem item : command.items()) {
if (item.lineNo() <= 0 || item.skuId() == null || item.skuId() <= 0
|| item.quantity() <= 0 || item.refundableAmount() == null
|| item.refundableAmount().signum() < 0) {
throw new FulfillmentException(FulfillmentError.IDEMPOTENCY_CONFLICT);
}
total = total.add(item.refundableAmount());
}
if (total.compareTo(command.refundAmount()) != 0) {
throw new FulfillmentException(FulfillmentError.IDEMPOTENCY_CONFLICT);
}
}
private void requireSameApproval(ReturnReceiptEntity receipt, AfterSaleApprovedCommand command) {
validateApproval(command);
if (!receipt.getOrderNo().equals(command.orderNo())
|| !receipt.getUserId().equals(command.userId())
|| !receipt.getWarehouseId().equals(command.warehouseId())
|| !receipt.getReservationNo().equals(command.reservationNo())
|| receipt.getRefundAmount().compareTo(command.refundAmount()) != 0) {
throw new FulfillmentException(FulfillmentError.IDEMPOTENCY_CONFLICT);
}
List<ReturnItemEntity> storedItems = items(receipt.getId());
List<AfterSaleApprovedItem> incomingItems = command.items().stream()
.sorted(java.util.Comparator.comparingInt(AfterSaleApprovedItem::lineNo))
.toList();
if (storedItems.size() != incomingItems.size()) {
throw new FulfillmentException(FulfillmentError.IDEMPOTENCY_CONFLICT);
}
for (int index = 0; index < storedItems.size(); index++) {
ReturnItemEntity stored = storedItems.get(index);
AfterSaleApprovedItem incoming = incomingItems.get(index);
if (stored.getLineNo() != incoming.lineNo()
|| !stored.getSkuId().equals(incoming.skuId())
|| stored.getQuantity() != incoming.quantity()
|| stored.getRefundableAmount().compareTo(incoming.refundableAmount()) != 0) {
throw new FulfillmentException(FulfillmentError.IDEMPOTENCY_CONFLICT);
}
}
}
private String approvalFingerprint(AfterSaleApprovedCommand command) {
Map<String, Object> canonical = new LinkedHashMap<>();
canonical.put("eventId", command.eventId());
canonical.put("afterSaleNo", command.afterSaleNo());
canonical.put("orderNo", command.orderNo());
canonical.put("userId", command.userId());
canonical.put("warehouseId", command.warehouseId());
canonical.put("reservationNo", command.reservationNo());
canonical.put("refundAmount", command.refundAmount());
canonical.put("items", command.items().stream()
.sorted(java.util.Comparator.comparingInt(AfterSaleApprovedItem::lineNo))
.toList());
return ConsumedEventFingerprint.of(objectMapper, canonical);
}
private void transition(
ReturnReceiptEntity receipt,
ReturnReceiptStatus target,
String command,
String reason,
String operatorType,
String operatorId,
String eventType) {
String from = receipt.getStatus();
Instant now = receiptMapper.currentTime();
receipt.setStatus(target.name());
receipt.setVersion(receipt.getVersion() + 1);
receipt.setUpdatedAt(now);
requireUpdated(receiptMapper.updateById(receipt));
appendHistory(receipt, from, target.name(), command, reason, operatorType, operatorId, now);
appendEvent(receipt, eventType, now);
}
private void appendHistory(
ReturnReceiptEntity receipt,
String from,
String to,
String command,
String reason,
String operatorType,
String operatorId,
Instant now) {
ReturnStatusHistoryEntity history = new ReturnStatusHistoryEntity();
history.setId(IdWorker.getId());
history.setReturnReceiptId(receipt.getId());
history.setFromStatus(from);
history.setToStatus(to);
history.setCommand(command);
history.setReason(reason);
history.setOperatorType(operatorType);
history.setOperatorId(operatorId);
history.setCreatedAt(now);
historyMapper.insert(history);
}
private void appendEvent(ReturnReceiptEntity receipt, String eventType, Instant now) {
String eventId = UUID.randomUUID().toString();
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("returnReceiptNo", receipt.getReturnReceiptNo());
payload.put("afterSaleNo", receipt.getAfterSaleNo());
payload.put("orderNo", receipt.getOrderNo());
payload.put("userId", receipt.getUserId());
payload.put("warehouseId", receipt.getWarehouseId());
payload.put("reservationNo", receipt.getReservationNo());
payload.put("refundAmount", receipt.getRefundAmount());
payload.put("status", receipt.getStatus());
payload.put("carrier", receipt.getCarrier());
payload.put("trackingNo", receipt.getTrackingNo());
if ("ReturnInspected".equals(eventType)) {
payload.put("items", items(receipt.getId()).stream().map(item -> {
Map<String, Object> line = new LinkedHashMap<>();
line.put("lineNo", item.getLineNo());
line.put("skuId", item.getSkuId());
line.put("quantity", item.getQuantity());
line.put("refundableAmount", item.getRefundableAmount());
return line;
}).toList());
}
Map<String, Object> envelope = new LinkedHashMap<>();
envelope.put("eventId", eventId);
envelope.put("eventType", eventType);
envelope.put("aggregateType", "ReturnReceipt");
envelope.put("aggregateId", receipt.getReturnReceiptNo());
envelope.put("aggregateVersion", receipt.getVersion());
envelope.put("occurredAt", now);
envelope.put("producer", "fulfillment-service");
envelope.put("traceId", MDC.get("traceId"));
envelope.put("payloadVersion", 1);
envelope.put("payload", payload);
OutboxEventEntity event = new OutboxEventEntity();
event.setId(eventId);
event.setEventType(eventType);
event.setAggregateType("ReturnReceipt");
event.setAggregateId(receipt.getReturnReceiptNo());
event.setAggregateVersion(receipt.getVersion());
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 ReturnReceiptEntity requireLocked(String returnReceiptNo) {
ReturnReceiptEntity receipt = receiptMapper.selectByReceiptNoForUpdate(returnReceiptNo);
if (receipt == null) {
throw new FulfillmentException(FulfillmentError.RESOURCE_NOT_FOUND);
}
return receipt;
}
private ReturnReceiptEntity require(String returnReceiptNo) {
ReturnReceiptEntity receipt = receiptMapper.selectOne(new LambdaQueryWrapper<ReturnReceiptEntity>()
.eq(ReturnReceiptEntity::getReturnReceiptNo, returnReceiptNo));
if (receipt == null) {
throw new FulfillmentException(FulfillmentError.RESOURCE_NOT_FOUND);
}
return receipt;
}
private List<ReturnItemEntity> items(Long receiptId) {
return itemMapper.selectList(new LambdaQueryWrapper<ReturnItemEntity>()
.eq(ReturnItemEntity::getReturnReceiptId, receiptId)
.orderByAsc(ReturnItemEntity::getLineNo));
}
private ReturnReceiptView view(ReturnReceiptEntity receipt) {
List<ReturnItemView> itemViews = items(receipt.getId()).stream()
.map(item -> new ReturnItemView(item.getLineNo(), item.getSkuId(), item.getQuantity(),
item.getRefundableAmount()))
.toList();
return new ReturnReceiptView(
receipt.getReturnReceiptNo(), receipt.getAfterSaleNo(), receipt.getOrderNo(),
receipt.getUserId(), receipt.getWarehouseId(), receipt.getReservationNo(), receipt.getStatus(),
receipt.getRefundAmount(), receipt.getCarrier(), receipt.getTrackingNo(),
receipt.getInspectionRemark(), itemViews, receipt.getVersion(), receipt.getCreatedAt(),
receipt.getUpdatedAt(), receipt.getShippedAt(), receipt.getReceivedAt(), receipt.getInspectedAt());
}
private void requireOwner(ReturnReceiptEntity receipt, Long userId) {
if (!Objects.equals(receipt.getUserId(), userId)) {
throw new FulfillmentException(FulfillmentError.ACCESS_DENIED);
}
}
private void requireStatus(ReturnReceiptEntity receipt, ReturnReceiptStatus status) {
if (!status.name().equals(receipt.getStatus())) {
throw new FulfillmentException(FulfillmentError.INVALID_STATE);
}
}
private void requireUpdated(int rows) {
if (rows != 1) {
throw new FulfillmentException(FulfillmentError.CONCURRENT_MODIFICATION);
}
}
private String writeJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new IllegalStateException("Unable to serialize return receipt event", exception);
}
}
}