194 lines
9.2 KiB
Java
194 lines
9.2 KiB
Java
package com.baiyue.firstleg.service;
|
|
|
|
import com.baiyue.common.core.api.ResultCode;
|
|
import com.baiyue.common.core.exception.BizException;
|
|
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.CommandResult;
|
|
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
|
|
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
|
|
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
|
|
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
|
|
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
|
|
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
|
import com.baiyue.firstleg.repository.FeeItemRepository;
|
|
import com.baiyue.firstleg.repository.FinanceFeeSourceCommandRepository;
|
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import lombok.RequiredArgsConstructor;
|
|
import org.springframework.dao.DuplicateKeyException;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
import org.springframework.util.StringUtils;
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.security.MessageDigest;
|
|
import java.security.NoSuchAlgorithmException;
|
|
import java.time.LocalDateTime;
|
|
import java.util.HexFormat;
|
|
|
|
/** Finance 仅发起命令;业务状态与费用事件始终由 Firstleg 原子产生。 */
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class FinanceFeeSourceCommandService {
|
|
private static final String ACTION_VOID = "VOID";
|
|
|
|
private final FinanceFeeSourceCommandRepository commands;
|
|
private final FeeItemRepository inboundFees;
|
|
private final FbaPricingRepository pricing;
|
|
private final ContainerFeeDetailRepository containerFees;
|
|
private final ContainerFeeDetailService containerFeeService;
|
|
private final FinanceFeeSourceOutboxService financeOutbox;
|
|
private final ObjectMapper objectMapper;
|
|
|
|
@Transactional
|
|
public CommandResult voidSource(String idempotencyKey, String clientHash, VoidRequest request) {
|
|
validateKey(idempotencyKey);
|
|
validateIdentity(request);
|
|
String requestHash = hash(request);
|
|
if (!StringUtils.hasText(clientHash) || !requestHash.equalsIgnoreCase(clientHash.trim())) {
|
|
throw bad("X-Request-Hash与费用作废请求不一致");
|
|
}
|
|
FinanceFeeSourceCommandEntity command = claim(idempotencyKey.trim(), requestHash, request);
|
|
if ("SUCCEEDED".equals(command.getStatus())) {
|
|
return result(request, command.getResultStatus(), true);
|
|
}
|
|
if ("INBOUND_FEE".equals(request.sourceType())) voidInbound(request);
|
|
else voidContainer(request);
|
|
if (commands.complete(command.getId(), "VOIDED", LocalDateTime.now()) != 1) {
|
|
throw conflict("费用作废幂等记录状态已变化");
|
|
}
|
|
return result(request, "VOIDED", false);
|
|
}
|
|
|
|
private FinanceFeeSourceCommandEntity claim(String key, String requestHash, VoidRequest request) {
|
|
FinanceFeeSourceCommandEntity value = new FinanceFeeSourceCommandEntity();
|
|
value.setCustomerId(request.customerId());
|
|
value.setOperatorId(request.operatorId());
|
|
value.setOperatorName(request.operatorName().trim());
|
|
value.setAction(ACTION_VOID);
|
|
value.setIdempotencyKey(key);
|
|
value.setRequestHash(requestHash);
|
|
value.setSourceType(request.sourceType());
|
|
value.setSourceFeeId(request.sourceFeeId());
|
|
value.setSourceVersion(request.sourceVersion());
|
|
value.setInboundId(request.inboundId());
|
|
value.setContainerId(request.containerId());
|
|
value.setReason(request.reason().trim());
|
|
value.setStatus("PROCESSING");
|
|
value.setCreatedAt(LocalDateTime.now());
|
|
try {
|
|
commands.insert(value);
|
|
return value;
|
|
} catch (DuplicateKeyException duplicate) {
|
|
FinanceFeeSourceCommandEntity existing = commands.find(request.customerId(),
|
|
request.operatorId(), ACTION_VOID, key);
|
|
if (existing == null || !requestHash.equals(existing.getRequestHash())) {
|
|
throw conflict("Idempotency-Key已用于其他费用作废请求");
|
|
}
|
|
if (!"SUCCEEDED".equals(existing.getStatus())) {
|
|
throw conflict("相同费用作废请求正在处理中");
|
|
}
|
|
return existing;
|
|
}
|
|
}
|
|
|
|
private void voidInbound(VoidRequest request) {
|
|
long inboundId = positive(request.inboundId(), "入库单ID");
|
|
if (request.containerId() != null) throw bad("入库费用不能携带柜次ID");
|
|
InboundFeeDetailEntity detail = inboundFees.findSourceVersion(inboundId,
|
|
request.sourceFeeId(), request.sourceVersion());
|
|
if (detail == null || !Long.valueOf(request.customerId()).equals(detail.getTenantId())) {
|
|
throw new BizException(ResultCode.NOT_FOUND, "指定客户的入库费用来源版本不存在");
|
|
}
|
|
if ("VOIDED".equals(detail.getPublicationStatus())) return;
|
|
if (!"APPROVED".equals(detail.getReviewStatus())
|
|
|| !"PUBLISHED".equals(detail.getPublicationStatus())) {
|
|
throw conflict("只有当前已审核发布的入库费用来源可以作废");
|
|
}
|
|
LocalDateTime now = LocalDateTime.now();
|
|
if ("FBA_PRICING".equals(detail.getSourceType())) {
|
|
if (pricing.voidSentFee(request.customerId(), inboundId, request.sourceFeeId(),
|
|
request.sourceVersion(), now) != 1
|
|
|| pricing.markInboundFeeVoided(request.customerId(), inboundId) != 1) {
|
|
throw conflict("入库计价费用状态或版本已变化");
|
|
}
|
|
} else if (!"MANUAL".equals(detail.getSourceType())) {
|
|
throw conflict("该入库费用来源类型不支持作废");
|
|
}
|
|
if (inboundFees.voidApproved(request.customerId(), inboundId, detail.getId(),
|
|
request.sourceVersion(), request.operatorId()) != 1) {
|
|
throw conflict("入库费用状态或版本已变化");
|
|
}
|
|
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.VOIDED,
|
|
request.customerId(), inboundId, request.sourceFeeId(), request.sourceVersion(), now);
|
|
}
|
|
|
|
private void voidContainer(VoidRequest request) {
|
|
long containerBookingId = positive(request.containerId(), "柜次ID");
|
|
if (request.inboundId() != null) throw bad("柜次费用不能携带入库单ID");
|
|
ContainerFeeDetailEntity detail = containerFees.findByContainerBookingId(
|
|
containerBookingId, request.sourceFeeId());
|
|
if (detail == null || !Long.valueOf(request.customerId()).equals(detail.getTenantId())) {
|
|
throw new BizException(ResultCode.NOT_FOUND, "指定客户的柜次费用来源版本不存在");
|
|
}
|
|
if (!Integer.valueOf(request.sourceVersion()).equals(detail.getSourceVersion())) {
|
|
throw conflict("柜次费用来源版本已变化");
|
|
}
|
|
if ("VOIDED".equals(detail.getSourceStatus())) return;
|
|
containerFeeService.voidApproved(request.customerId(), detail.getLoadingPlanId(),
|
|
request.sourceFeeId(), request.sourceVersion(), request.operatorId());
|
|
}
|
|
|
|
private String hash(VoidRequest request) {
|
|
try {
|
|
return sha256(objectMapper.writeValueAsString(request));
|
|
} catch (JsonProcessingException exception) {
|
|
throw new BizException(ResultCode.INTERNAL_ERROR, "费用作废请求序列化失败");
|
|
}
|
|
}
|
|
|
|
private static void validateIdentity(VoidRequest request) {
|
|
if (request == null || request.sourceFeeId() <= 0 || request.sourceVersion() <= 0
|
|
|| request.customerId() <= 0 || request.operatorId() <= 0
|
|
|| !StringUtils.hasText(request.operatorName()) || !StringUtils.hasText(request.reason())) {
|
|
throw bad("费用作废请求无效");
|
|
}
|
|
if (!"INBOUND_FEE".equals(request.sourceType())
|
|
&& !"CONTAINER_FEE".equals(request.sourceType())) {
|
|
throw bad("费用来源类型无效");
|
|
}
|
|
}
|
|
|
|
private static void validateKey(String key) {
|
|
if (!StringUtils.hasText(key) || key.trim().length() > 128) {
|
|
throw bad("Idempotency-Key不能为空且不能超过128个字符");
|
|
}
|
|
}
|
|
|
|
private static long positive(Long value, String label) {
|
|
if (value == null || value <= 0) throw bad(label + "无效");
|
|
return value;
|
|
}
|
|
|
|
private static CommandResult result(VoidRequest request, String status, boolean replayed) {
|
|
return new CommandResult(request.sourceType(), request.sourceFeeId(),
|
|
request.sourceVersion(), status, replayed);
|
|
}
|
|
|
|
private static 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 unavailable", exception);
|
|
}
|
|
}
|
|
|
|
private static BizException bad(String message) {
|
|
return new BizException(ResultCode.BAD_REQUEST, message);
|
|
}
|
|
|
|
private static BizException conflict(String message) {
|
|
return new BizException(ResultCode.CONFLICT, message);
|
|
}
|
|
}
|