优化
This commit is contained in:
+17
@@ -4,7 +4,11 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
|
||||
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.CommandResult;
|
||||
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
|
||||
import com.baiyue.firstleg.service.FinanceFeeSourceCommandService;
|
||||
import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -12,6 +16,8 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -22,6 +28,7 @@ import java.security.MessageDigest;
|
||||
@RequestMapping("/internal/firstleg/v1")
|
||||
public class InternalFinanceFeeSourceController {
|
||||
private final FinanceFeeSourceSnapshotService service;
|
||||
private final FinanceFeeSourceCommandService commands;
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String expectedToken;
|
||||
|
||||
@@ -43,6 +50,16 @@ public class InternalFinanceFeeSourceController {
|
||||
return ApiResponse.success(service.container(containerId, feeId, sourceVersion));
|
||||
}
|
||||
|
||||
@PostMapping("/fee-sources/commands/void")
|
||||
public ApiResponse<CommandResult> voidSource(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
||||
@RequestHeader("X-Request-Hash") String requestHash,
|
||||
@Valid @RequestBody VoidRequest request) {
|
||||
authenticate(token);
|
||||
return ApiResponse.success(commands.voidSource(idempotencyKey, requestHash, request));
|
||||
}
|
||||
|
||||
private void authenticate(String token) {
|
||||
if (token == null || expectedToken == null || expectedToken.isBlank()
|
||||
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.firstleg.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/** Finance 发起、Firstleg 执行的业务费用命令契约。 */
|
||||
public final class FinanceFeeSourceCommandDtos {
|
||||
private FinanceFeeSourceCommandDtos() {}
|
||||
|
||||
public record VoidRequest(
|
||||
@NotBlank @Pattern(regexp = "INBOUND_FEE|CONTAINER_FEE") String sourceType,
|
||||
@Min(1) long sourceFeeId,
|
||||
@Min(1) int sourceVersion,
|
||||
@Min(1) long customerId,
|
||||
Long inboundId,
|
||||
Long containerId,
|
||||
@Min(1) long operatorId,
|
||||
@NotBlank @Size(max = 64) String operatorName,
|
||||
@NotBlank @Size(max = 500) String reason
|
||||
) {}
|
||||
|
||||
public record CommandResult(
|
||||
@NotNull String sourceType,
|
||||
long sourceFeeId,
|
||||
int sourceVersion,
|
||||
@NotNull String status,
|
||||
boolean replayed
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.firstleg.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_finance_fee_source_command")
|
||||
public class FinanceFeeSourceCommandEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
private Long customerId;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
private String action;
|
||||
private String idempotencyKey;
|
||||
private String requestHash;
|
||||
private String sourceType;
|
||||
private Long sourceFeeId;
|
||||
private Integer sourceVersion;
|
||||
private Long inboundId;
|
||||
private Long containerId;
|
||||
private String reason;
|
||||
private String status;
|
||||
private String resultStatus;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime completedAt;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface FinanceFeeSourceCommandMapper extends BaseMapper<FinanceFeeSourceCommandEntity> {
|
||||
}
|
||||
@@ -258,6 +258,26 @@ public class FbaPricingRepository {
|
||||
.set(InboundFeeEntity::getSentAt, now));
|
||||
}
|
||||
|
||||
public int voidSentFee(long tenantId, long inboundId, long feeId, int feeVersion,
|
||||
LocalDateTime now) {
|
||||
return fees.update(null, new LambdaUpdateWrapper<InboundFeeEntity>()
|
||||
.eq(InboundFeeEntity::getTenantId, tenantId)
|
||||
.eq(InboundFeeEntity::getInboundId, inboundId)
|
||||
.eq(InboundFeeEntity::getId, feeId)
|
||||
.eq(InboundFeeEntity::getFeeVersion, feeVersion)
|
||||
.eq(InboundFeeEntity::getFeeStatus, "SENT")
|
||||
.set(InboundFeeEntity::getFeeStatus, "VOIDED")
|
||||
.set(InboundFeeEntity::getUpdatedAt, now));
|
||||
}
|
||||
|
||||
public int markInboundFeeVoided(long tenantId, long inboundId) {
|
||||
return inbounds.update(null, new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId)
|
||||
.eq(InboundPricingEntity::getId, inboundId)
|
||||
.eq(InboundPricingEntity::getFeeStatus, "SENT")
|
||||
.set(InboundPricingEntity::getFeeStatus, "VOIDED"));
|
||||
}
|
||||
|
||||
public int markInboundFeeSent(long tenantId, long inboundId, String processStatus) {
|
||||
LambdaUpdateWrapper<InboundPricingEntity> update = new LambdaUpdateWrapper<InboundPricingEntity>()
|
||||
.eq(InboundPricingEntity::getTenantId, tenantId).eq(InboundPricingEntity::getId, inboundId)
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
|
||||
import com.baiyue.firstleg.mapper.FinanceFeeSourceCommandMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
public class FinanceFeeSourceCommandRepository {
|
||||
private final FinanceFeeSourceCommandMapper mapper;
|
||||
|
||||
public void insert(FinanceFeeSourceCommandEntity value) {
|
||||
mapper.insert(value);
|
||||
}
|
||||
|
||||
public FinanceFeeSourceCommandEntity find(long customerId, long operatorId,
|
||||
String action, String key) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<FinanceFeeSourceCommandEntity>()
|
||||
.eq(FinanceFeeSourceCommandEntity::getCustomerId, customerId)
|
||||
.eq(FinanceFeeSourceCommandEntity::getOperatorId, operatorId)
|
||||
.eq(FinanceFeeSourceCommandEntity::getAction, action)
|
||||
.eq(FinanceFeeSourceCommandEntity::getIdempotencyKey, key));
|
||||
}
|
||||
|
||||
public int complete(long id, String resultStatus, LocalDateTime completedAt) {
|
||||
return mapper.update(null, new LambdaUpdateWrapper<FinanceFeeSourceCommandEntity>()
|
||||
.eq(FinanceFeeSourceCommandEntity::getId, id)
|
||||
.eq(FinanceFeeSourceCommandEntity::getStatus, "PROCESSING")
|
||||
.set(FinanceFeeSourceCommandEntity::getStatus, "SUCCEEDED")
|
||||
.set(FinanceFeeSourceCommandEntity::getResultStatus, resultStatus)
|
||||
.set(FinanceFeeSourceCommandEntity::getCompletedAt, completedAt));
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
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 containerId = positive(request.containerId(), "柜次ID");
|
||||
if (request.inboundId() != null) throw bad("柜次费用不能携带入库单ID");
|
||||
ContainerFeeDetailEntity detail = containerFees.find(containerId, 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(), containerId,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,32 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
|
||||
KEY idx_container_fee_snapshot_tenant (tenant_id, loading_plan_id)
|
||||
) ENGINE=InnoDB COMMENT='柜次费用不可变版本快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT 'Finance费用来源命令ID',
|
||||
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,由Firstleg费用事实再次校验',
|
||||
operator_id BIGINT UNSIGNED NOT NULL COMMENT 'Finance操作人ID',
|
||||
operator_name VARCHAR(64) NOT NULL COMMENT 'Finance操作人名称快照',
|
||||
action VARCHAR(24) NOT NULL COMMENT '命令动作;当前仅VOID',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT 'Finance透传的幂等键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256',
|
||||
source_type VARCHAR(24) NOT NULL COMMENT 'INBOUND_FEE或CONTAINER_FEE',
|
||||
source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '稳定费用来源ID',
|
||||
source_version INT UNSIGNED NOT NULL COMMENT '精确费用来源版本',
|
||||
inbound_id BIGINT UNSIGNED NULL COMMENT '入库费用所属入库单ID',
|
||||
container_id BIGINT UNSIGNED NULL COMMENT '柜次费用所属柜次ID',
|
||||
reason VARCHAR(500) NOT NULL COMMENT '操作原因',
|
||||
status VARCHAR(16) NOT NULL COMMENT 'PROCESSING或SUCCEEDED',
|
||||
result_status VARCHAR(16) NULL COMMENT '成功业务结果;当前为VOIDED',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
completed_at DATETIME(3) NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_finance_fee_command_idem (customer_id, operator_id, action, idempotency_key),
|
||||
KEY idx_finance_fee_command_source (source_type, source_fee_id, source_version, created_at),
|
||||
CONSTRAINT chk_finance_fee_command_action CHECK (action IN ('VOID')),
|
||||
CONSTRAINT chk_finance_fee_command_status CHECK (status IN ('PROCESSING', 'SUCCEEDED')),
|
||||
CONSTRAINT chk_finance_fee_command_source CHECK (source_type IN ('INBOUND_FEE', 'CONTAINER_FEE'))
|
||||
) ENGINE=InnoDB COMMENT='Finance发起、Firstleg执行业务费用命令的幂等审计记录';
|
||||
|
||||
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id',
|
||||
"BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
|
||||
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name',
|
||||
@@ -153,6 +179,10 @@ BEGIN
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用表不存在';
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_finance_fee_source_command') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:费用命令幂等审计表不存在';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
|
||||
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
|
||||
|
||||
@@ -635,7 +635,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
|
||||
subtotal_amount DECIMAL(18,2) NOT NULL COMMENT '基础费用金额',
|
||||
additional_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '附加费金额',
|
||||
total_amount DECIMAL(18,2) NOT NULL COMMENT '总金额',
|
||||
fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态:CALCULATED已计算、SUPERSEDED已被新版本替代',
|
||||
fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态:CALCULATED已计算、CONFIRMED已确认、SENT已发送、SUPERSEDED已被新版本替代、VOIDED已作废',
|
||||
review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认',
|
||||
exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否基于测量异常放行生成',
|
||||
calculated_by BIGINT UNSIGNED NOT NULL COMMENT '试算人ID',
|
||||
|
||||
@@ -94,6 +94,25 @@ paths:
|
||||
- {name: sourceVersion, in: query, required: true, schema: {type: integer, minimum: 1}}
|
||||
responses:
|
||||
'200': {description: 成功时 data 为 FinanceFeeSourceSnapshot;不存在精确版本时返回业务错误}
|
||||
/internal/firstleg/v1/fee-sources/commands/void:
|
||||
post:
|
||||
summary: Finance 发起 Firstleg 业务费用作废
|
||||
description: >-
|
||||
Finance 只发起命令,不修改或伪造业务费用状态。Firstleg 按 sourceType、sourceFeeId、sourceVersion、
|
||||
customerId 和所属入库单/柜次精确校验,原子作废业务费用并写入 FIRSTLEG_FEE_VOIDED Outbox;
|
||||
Finance 后续仅消费该事件同步本地费用池。相同幂等键与请求哈希安全重放,异参重放返回冲突。
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
parameters:
|
||||
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||
- {name: X-Request-Hash, in: header, required: true, schema: {type: string, pattern: '^[0-9a-f]{64}$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/FinanceFeeSourceVoidCommand'}
|
||||
responses:
|
||||
'200': {description: 成功时 data 为 FinanceFeeSourceCommandResult;业务失败仍按统一响应 code 返回}
|
||||
/api/platform/v1/first-leg/customer-dimension-config:
|
||||
get:
|
||||
summary: 查询客户固定四箱规尺寸配置
|
||||
@@ -1879,3 +1898,26 @@ components:
|
||||
actualVolumeCbm: {type: number, nullable: true}
|
||||
sourceHash: {type: string}
|
||||
snapshotVersion: {type: integer, minimum: 1}
|
||||
FinanceFeeSourceVoidCommand:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [sourceType, sourceFeeId, sourceVersion, customerId, operatorId, operatorName, reason]
|
||||
properties:
|
||||
sourceType: {type: string, enum: [INBOUND_FEE, CONTAINER_FEE]}
|
||||
sourceFeeId: {type: integer, format: int64, minimum: 1}
|
||||
sourceVersion: {type: integer, minimum: 1}
|
||||
customerId: {type: integer, format: int64, minimum: 1}
|
||||
inboundId: {type: integer, format: int64, minimum: 1, nullable: true, description: INBOUND_FEE必填且containerId必须为空}
|
||||
containerId: {type: integer, format: int64, minimum: 1, nullable: true, description: CONTAINER_FEE必填且inboundId必须为空}
|
||||
operatorId: {type: integer, format: int64, minimum: 1}
|
||||
operatorName: {type: string, minLength: 1, maxLength: 64}
|
||||
reason: {type: string, minLength: 1, maxLength: 500}
|
||||
FinanceFeeSourceCommandResult:
|
||||
type: object
|
||||
required: [sourceType, sourceFeeId, sourceVersion, status, replayed]
|
||||
properties:
|
||||
sourceType: {type: string, enum: [INBOUND_FEE, CONTAINER_FEE]}
|
||||
sourceFeeId: {type: integer, format: int64}
|
||||
sourceVersion: {type: integer}
|
||||
status: {type: string, enum: [VOIDED]}
|
||||
replayed: {type: boolean}
|
||||
|
||||
+18
-2
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService;
|
||||
import com.baiyue.firstleg.service.FinanceFeeSourceCommandService;
|
||||
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -15,6 +17,7 @@ import static org.mockito.Mockito.verify;
|
||||
|
||||
class InternalFinanceFeeSourceControllerTest {
|
||||
private final FinanceFeeSourceSnapshotService service = mock(FinanceFeeSourceSnapshotService.class);
|
||||
private final FinanceFeeSourceCommandService commands = mock(FinanceFeeSourceCommandService.class);
|
||||
private final InternalFinanceFeeSourceController controller = controller();
|
||||
|
||||
@Test
|
||||
@@ -34,6 +37,16 @@ class InternalFinanceFeeSourceControllerTest {
|
||||
verify(service).container(41L, 51L, 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void routesAuthorizedVoidCommandWithIdempotencyAndHash() {
|
||||
VoidRequest request = new VoidRequest("INBOUND_FEE", 31L, 2, 9L,
|
||||
21L, null, 71L, "finance", "测试作废");
|
||||
|
||||
controller.voidSource("finance-token", "void-key", "a".repeat(64), request);
|
||||
|
||||
verify(commands).voidSource("void-key", "a".repeat(64), request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesFrozenInternalPaths() {
|
||||
var mapping = InternalFinanceFeeSourceController.class
|
||||
@@ -52,11 +65,14 @@ class InternalFinanceFeeSourceControllerTest {
|
||||
.contains("/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:")
|
||||
.contains("{name: sourceVersion, in: query, required: true")
|
||||
.contains("supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true")
|
||||
.contains("为空时仅要求供应商ACTIVE");
|
||||
.contains("为空时仅要求供应商ACTIVE")
|
||||
.contains("/internal/firstleg/v1/fee-sources/commands/void:")
|
||||
.contains("FIRSTLEG_FEE_VOIDED Outbox")
|
||||
.contains("FinanceFeeSourceVoidCommand:");
|
||||
}
|
||||
|
||||
private InternalFinanceFeeSourceController controller() {
|
||||
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service);
|
||||
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service, commands);
|
||||
ReflectionTestUtils.setField(value, "expectedToken", "finance-token");
|
||||
return value;
|
||||
}
|
||||
|
||||
+3
@@ -37,6 +37,9 @@ class FinanceFeeSourceMigrationContractTest {
|
||||
"source_version", "supplier_id", "supplier_name", "supplier_category",
|
||||
"CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot",
|
||||
"uk_container_fee_source_version",
|
||||
"CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command",
|
||||
"uk_finance_fee_command_idem",
|
||||
"request_hash CHAR(64) NOT NULL",
|
||||
"firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE",
|
||||
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
|
||||
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整",
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
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.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FinanceFeeSourceCommandServiceTest {
|
||||
@Mock FinanceFeeSourceCommandRepository commands;
|
||||
@Mock FeeItemRepository inboundFees;
|
||||
@Mock FbaPricingRepository pricing;
|
||||
@Mock ContainerFeeDetailRepository containerFees;
|
||||
@Mock ContainerFeeDetailService containerFeeService;
|
||||
@Mock FinanceFeeSourceOutboxService outbox;
|
||||
private ObjectMapper objectMapper;
|
||||
private FinanceFeeSourceCommandService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
service = new FinanceFeeSourceCommandService(commands, inboundFees, pricing,
|
||||
containerFees, containerFeeService, outbox, objectMapper);
|
||||
doAnswer(invocation -> {
|
||||
FinanceFeeSourceCommandEntity value = invocation.getArgument(0);
|
||||
value.setId(901L);
|
||||
return null;
|
||||
}).when(commands).insert(any());
|
||||
lenient().when(commands.complete(eq(901L), eq("VOIDED"), any())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidsManualInboundBusinessFactAndPublishesEvent() throws Exception {
|
||||
VoidRequest request = inboundRequest(700L, 2);
|
||||
InboundFeeDetailEntity detail = inboundDetail("MANUAL", 777L, 700L, 2, 9L);
|
||||
when(inboundFees.findSourceVersion(100L, 700L, 2)).thenReturn(detail);
|
||||
when(inboundFees.voidApproved(9L, 100L, 777L, 2, 71L)).thenReturn(1);
|
||||
|
||||
var result = service.voidSource("void-key", hash(request), request);
|
||||
|
||||
assertThat(result.status()).isEqualTo("VOIDED");
|
||||
assertThat(result.replayed()).isFalse();
|
||||
verify(outbox).publishInbound(org.mockito.ArgumentMatchers.eq(FinanceFeeSourceOutboxService.VOIDED),
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(100L),
|
||||
org.mockito.ArgumentMatchers.eq(700L), org.mockito.ArgumentMatchers.eq(2), any());
|
||||
verify(pricing, never()).voidSentFee(anyLong(), anyLong(), anyLong(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidsFbaPricingAndMaterializedInboundDetailAtomically() throws Exception {
|
||||
VoidRequest request = inboundRequest(700L, 3);
|
||||
when(inboundFees.findSourceVersion(100L, 700L, 3))
|
||||
.thenReturn(inboundDetail("FBA_PRICING", 778L, 700L, 3, 9L));
|
||||
when(pricing.voidSentFee(9L, 100L, 700L, 3, any())).thenReturn(1);
|
||||
when(pricing.markInboundFeeVoided(9L, 100L)).thenReturn(1);
|
||||
when(inboundFees.voidApproved(9L, 100L, 778L, 3, 71L)).thenReturn(1);
|
||||
|
||||
service.voidSource("void-fba", hash(request), request);
|
||||
|
||||
verify(pricing).voidSentFee(9L, 100L, 700L, 3, any());
|
||||
verify(pricing).markInboundFeeVoided(9L, 100L);
|
||||
verify(outbox).publishInbound(org.mockito.ArgumentMatchers.eq(FinanceFeeSourceOutboxService.VOIDED),
|
||||
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(100L),
|
||||
org.mockito.ArgumentMatchers.eq(700L), org.mockito.ArgumentMatchers.eq(3), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void routesContainerVoidThroughExistingBusinessService() throws Exception {
|
||||
VoidRequest request = new VoidRequest("CONTAINER_FEE", 800L, 4, 9L,
|
||||
null, 200L, 71L, "finance", "柜次费用作废");
|
||||
ContainerFeeDetailEntity detail = new ContainerFeeDetailEntity();
|
||||
detail.setId(800L); detail.setTenantId(9L); detail.setLoadingPlanId(200L);
|
||||
detail.setSourceVersion(4); detail.setSourceStatus("ACTIVE");
|
||||
when(containerFees.find(200L, 800L)).thenReturn(detail);
|
||||
|
||||
service.voidSource("void-container", hash(request), request);
|
||||
|
||||
verify(containerFeeService).voidApproved(9L, 200L, 800L, 4, 71L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCrossCustomerSourceBeforeBusinessMutation() throws Exception {
|
||||
VoidRequest request = inboundRequest(700L, 2);
|
||||
when(inboundFees.findSourceVersion(100L, 700L, 2))
|
||||
.thenReturn(inboundDetail("MANUAL", 777L, 700L, 2, 10L));
|
||||
|
||||
assertThatThrownBy(() -> service.voidSource("void-cross", hash(request), request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("指定客户");
|
||||
|
||||
verify(inboundFees, never()).voidApproved(anyLong(), anyLong(), anyLong(), anyInt(), anyLong());
|
||||
verify(outbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysCompletedCommandWithoutRepeatingBusinessMutation() throws Exception {
|
||||
VoidRequest request = inboundRequest(700L, 2);
|
||||
String requestHash = hash(request);
|
||||
doThrow(new DuplicateKeyException("duplicate")).when(commands).insert(any());
|
||||
FinanceFeeSourceCommandEntity existing = new FinanceFeeSourceCommandEntity();
|
||||
existing.setRequestHash(requestHash); existing.setStatus("SUCCEEDED");
|
||||
existing.setResultStatus("VOIDED");
|
||||
when(commands.find(9L, 71L, "VOID", "void-replay")).thenReturn(existing);
|
||||
|
||||
var result = service.voidSource("void-replay", requestHash, request);
|
||||
|
||||
assertThat(result.replayed()).isTrue();
|
||||
verify(inboundFees, never()).findSourceVersion(anyLong(), anyLong(), anyInt());
|
||||
}
|
||||
|
||||
private VoidRequest inboundRequest(long feeId, int version) {
|
||||
return new VoidRequest("INBOUND_FEE", feeId, version, 9L,
|
||||
100L, null, 71L, "finance", "入库费用作废");
|
||||
}
|
||||
|
||||
private static InboundFeeDetailEntity inboundDetail(String sourceType, long id,
|
||||
long sourceFeeId, int version,
|
||||
long tenantId) {
|
||||
InboundFeeDetailEntity value = new InboundFeeDetailEntity();
|
||||
value.setId(id); value.setTenantId(tenantId); value.setInboundId(100L);
|
||||
value.setSourceType(sourceType); value.setSourceFeeId(sourceFeeId);
|
||||
value.setSourceFeeVersion(version); value.setReviewStatus("APPROVED");
|
||||
value.setPublicationStatus("PUBLISHED");
|
||||
return value;
|
||||
}
|
||||
|
||||
private String hash(VoidRequest request) throws Exception {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user