This commit is contained in:
2026-08-11 18:06:26 +08:00
parent a02dd3a422
commit 192964419f
101 changed files with 8892 additions and 1952 deletions
@@ -4,6 +4,7 @@ import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 头程履约微服务启动入口,负责装配履约任务组件并扫描 MyBatis-Plus Mapper。
@@ -19,6 +20,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
"com.baiyue.firstleg.customs.mapper"
})
@EnableFeignClients(basePackages = "com.baiyue.firstleg")
@EnableScheduling
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueFirstlegApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueFirstlegApplication.class, args); }
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.dto.FinanceFeeSourceEvent;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import com.fasterxml.jackson.annotation.JsonProperty;
@FeignClient(name = "finance-service", contextId = "firstlegFinanceFeeSourceEventClient",
url = "${baiyue.clients.finance.url:}", path = "/internal/finance/v1/fee-source-events")
public interface FinanceFeeSourceEventClient {
@PostMapping
ApiResponse<Acknowledgement> receive(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestBody FinanceFeeSourceEvent event);
record Acknowledgement(@JsonProperty("feeSourceId") Long feeSourceId) {
}
}
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.controller;
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.service.FinanceFeeSourceSnapshotService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequiredArgsConstructor
@RequestMapping("/internal/firstleg/v1")
public class InternalFinanceFeeSourceController {
private final FinanceFeeSourceSnapshotService service;
@Value("${baiyue.security.internal-service-token}")
private String expectedToken;
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
public ApiResponse<FinanceFeeSourceSnapshot> inbound(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long inboundId, @PathVariable long feeId) {
authenticate(token);
return ApiResponse.success(service.inbound(inboundId, feeId));
}
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
public ApiResponse<FinanceFeeSourceSnapshot> container(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long containerId, @PathVariable long feeId) {
authenticate(token);
return ApiResponse.success(service.container(containerId, feeId));
}
private void authenticate(String token) {
if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),
expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
}
}
}
@@ -0,0 +1,93 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.AdjustRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.RejectRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.VersionRequest;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.service.ContainerFeeDetailService;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/platform/v1/loading-plans/{loadingPlanId}/fee-details")
public class PlatformContainerFeeDetailController {
private final ContainerFeeDetailService service;
@GetMapping
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:view')")
public ApiResponse<List<ContainerFeeDetailEntity>> list(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long accountId,
@PathVariable long loadingPlanId) {
return ApiResponse.success(service.list(FirstlegScopeGuard.platform(user, accountId),
loadingPlanId));
}
@PostMapping
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:edit')")
@BusinessAudit(event = "CONTAINER_FEE_DETAIL_CREATE", resourceType = "LOADING_PLAN", resourceId = "#loadingPlanId", captureBefore = false)
public ApiResponse<ContainerFeeDetailEntity> create(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long accountId,
@PathVariable long loadingPlanId, @Valid @RequestBody SaveRequest request) {
return ApiResponse.success(service.create(FirstlegScopeGuard.platform(user, accountId),
loadingPlanId, user.userId(), request));
}
@PostMapping("/{feeId}/approve")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
@BusinessAudit(event = "CONTAINER_FEE_DETAIL_APPROVE", resourceType = "CONTAINER_FEE_DETAIL", resourceId = "#feeId")
public ApiResponse<Void> approve(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long loadingPlanId,
@PathVariable long feeId) {
service.approve(FirstlegScopeGuard.platform(user, accountId), loadingPlanId, feeId,
user.userId(), user.username());
return ApiResponse.success(null);
}
@PostMapping("/{feeId}/reject")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
@BusinessAudit(event = "CONTAINER_FEE_DETAIL_REJECT", resourceType = "CONTAINER_FEE_DETAIL", resourceId = "#feeId")
public ApiResponse<Void> reject(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long loadingPlanId,
@PathVariable long feeId,
@Valid @RequestBody RejectRequest request) {
service.reject(FirstlegScopeGuard.platform(user, accountId), loadingPlanId, feeId,
user.userId(), user.username(), request.reason());
return ApiResponse.success(null);
}
@PostMapping("/{feeId}/adjust")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:edit')")
@BusinessAudit(event = "CONTAINER_FEE_DETAIL_ADJUST", resourceType = "CONTAINER_FEE_DETAIL", resourceId = "#feeId")
public ApiResponse<ContainerFeeDetailEntity> adjust(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long accountId,
@PathVariable long loadingPlanId, @PathVariable long feeId,
@Valid @RequestBody AdjustRequest request) {
return ApiResponse.success(service.adjust(FirstlegScopeGuard.platform(user, accountId),
loadingPlanId, feeId, request.expectedSourceVersion(), user.userId(), request.values()));
}
@PostMapping("/{feeId}/void")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
@BusinessAudit(event = "CONTAINER_FEE_DETAIL_VOID", resourceType = "CONTAINER_FEE_DETAIL", resourceId = "#feeId")
public ApiResponse<Void> voidApproved(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long accountId,
@PathVariable long loadingPlanId, @PathVariable long feeId,
@Valid @RequestBody VersionRequest request) {
service.voidApproved(FirstlegScopeGuard.platform(user, accountId), loadingPlanId, feeId,
request.expectedSourceVersion(), user.userId());
return ApiResponse.success(null);
}
}
@@ -5,6 +5,7 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos.RejectRequest;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos.VersionRequest;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundFeeDetailService;
@@ -104,4 +105,32 @@ public class PlatformInboundFeeDetailController {
user.userId(), user.username(), request.reason());
return ApiResponse.success(null);
}
@PostMapping("/{detailId}/adjust")
@BusinessAudit(event = "INBOUND_FEE_DETAIL_ADJUST", resourceType = "INBOUND_FEE_DETAIL", resourceId = "#detailId", description = "调整已审核入库费用明细")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:edit')")
public ApiResponse<InboundFeeDetailEntity> adjust(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long inboundId,
@PathVariable long detailId,
@RequestParam int expectedSourceVersion,
@Valid @RequestBody SaveRequest request) {
return ApiResponse.success(service.adjust(FirstlegScopeGuard.platform(user, accountId),
inboundId, detailId, expectedSourceVersion, user.userId(), request));
}
@PostMapping("/{detailId}/void")
@BusinessAudit(event = "INBOUND_FEE_DETAIL_VOID", resourceType = "INBOUND_FEE_DETAIL", resourceId = "#detailId", description = "作废已审核入库费用明细")
@PreAuthorize("hasAuthority('firstleg:inbound:fee-detail:review')")
public ApiResponse<Void> voidApproved(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long accountId,
@PathVariable long inboundId,
@PathVariable long detailId,
@Valid @RequestBody VersionRequest request) {
service.voidApproved(FirstlegScopeGuard.platform(user, accountId), inboundId, detailId,
request.expectedSourceVersion(), user.userId());
return ApiResponse.success(null);
}
}
@@ -0,0 +1,49 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.DecimalMin;
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;
import java.math.BigDecimal;
import java.time.LocalDate;
public final class ContainerFeeDetailDtos {
private ContainerFeeDetailDtos() {
}
public record SaveRequest(
@NotNull @Min(1) Long feeItemId,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@NotNull LocalDate businessDate,
@NotNull @DecimalMin("0.00") BigDecimal amount,
@NotBlank @Pattern(regexp = "CNY") String currency,
@NotNull Boolean settlementIncluded,
@Size(max = 500) String remark
) {
}
public record AdjustRequest(
@NotNull @Min(1) Integer expectedSourceVersion,
@NotNull @Min(1) Long feeItemId,
@NotBlank @Pattern(regexp = "RECEIVABLE|PAYABLE") String direction,
@NotNull LocalDate businessDate,
@NotNull @DecimalMin("0.00") BigDecimal amount,
@NotBlank @Pattern(regexp = "CNY") String currency,
@NotNull Boolean settlementIncluded,
@Size(max = 500) String remark
) {
public SaveRequest values() {
return new SaveRequest(feeItemId, direction, businessDate, amount, currency,
settlementIncluded, remark);
}
}
public record VersionRequest(@NotNull @Min(1) Integer expectedSourceVersion) {
}
public record RejectRequest(@NotBlank @Size(max = 500) String reason) {
}
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.dto;
import java.time.LocalDateTime;
/** Firstleg 向 Finance 发布的短事件信封;完整金额必须回查来源快照接口。 */
public record FinanceFeeSourceEvent(
String eventId,
String eventType,
int eventVersion,
String producer,
LocalDateTime occurredAt,
String tenantId,
String aggregateType,
String aggregateId,
int sequence,
String idempotencyKey,
String payloadHash,
Payload payload
) {
public record Payload(String inboundId, String containerId, String feeId, int feeVersion) {
}
}
@@ -0,0 +1,39 @@
package com.baiyue.firstleg.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** Finance 回查的 Firstleg 费用来源只读快照。 */
public record FinanceFeeSourceSnapshot(
long tenantId,
String sourceType,
long feeId,
int feeVersion,
Long inboundId,
String inboundCode,
Long orderId,
Long containerId,
Long containerBookingId,
String containerNo,
long customerId,
String customerName,
Long feeItemId,
String feeItemCode,
String feeItemName,
String direction,
LocalDate businessDate,
String currency,
BigDecimal totalAmount,
String reviewStatus,
String publicationStatus,
boolean settlementIncluded,
LocalDateTime publishedAt,
BigDecimal chargeableQuantity,
String chargeUnit,
BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm,
String sourceHash,
int snapshotVersion
) {
}
@@ -21,8 +21,17 @@ public final class InboundFeeDetailDtos {
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
@NotBlank @Pattern(regexp = "[A-Z]{3}") String currency,
@NotNull @DecimalMin("0.00") BigDecimal amount,
@NotNull Boolean settlementIncluded,
@Size(max = 500) String remark
) {
public SaveRequest(Long feeItemId, String direction, LocalDate businessDate,
BigDecimal unitPrice, String currency, BigDecimal amount,
String remark) {
this(feeItemId, direction, businessDate, unitPrice, currency, amount, false, remark);
}
}
public record VersionRequest(@NotNull @jakarta.validation.constraints.Min(1) Integer expectedSourceVersion) {
}
public record RejectRequest(@NotBlank @Size(max = 500) String reason) {
@@ -0,0 +1,44 @@
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.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 柜次级人工应收/应付费用来源。 */
@Getter
@Setter
@TableName("fl_container_fee_detail")
public class ContainerFeeDetailEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private Long loadingPlanId;
private Long containerBookingId;
private String containerNo;
private Long feeItemId;
private String feeItemCodeSnapshot;
private String feeItemNameSnapshot;
private String direction;
private LocalDate businessDate;
private BigDecimal amount;
private String currency;
private Boolean settlementIncluded;
private String reviewStatus;
private String sourceStatus;
private Integer sourceVersion;
private String remark;
private Long reviewerId;
private String reviewerName;
private LocalDateTime reviewedAt;
private String reviewReason;
private Long createdBy;
private LocalDateTime createdAt;
private Long updatedBy;
private LocalDateTime updatedAt;
}
@@ -19,8 +19,17 @@ public class InboundOutboxEntity {
private String aggregateType;
private Long aggregateId;
private String eventType;
private String eventKey;
private Integer eventVersion;
private Integer aggregateSequence;
private String payloadHash;
private String payloadJson;
private String publishStatus;
private Integer retryCount;
private LocalDateTime nextRetryAt;
private LocalDateTime publishedAt;
private LocalDateTime lockedAt;
private String lockedBy;
private String lastError;
private LocalDateTime createdAt;
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContainerFeeDetailMapper extends BaseMapper<ContainerFeeDetailEntity> {
}
@@ -0,0 +1,81 @@
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.ContainerFeeDetailEntity;
import com.baiyue.firstleg.mapper.ContainerFeeDetailMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class ContainerFeeDetailRepository {
private final ContainerFeeDetailMapper mapper;
public List<ContainerFeeDetailEntity> list(long tenantId, long loadingPlanId) {
return mapper.selectList(new LambdaQueryWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, tenantId)
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.orderByDesc(ContainerFeeDetailEntity::getBusinessDate)
.orderByDesc(ContainerFeeDetailEntity::getId));
}
public ContainerFeeDetailEntity find(long tenantId, long loadingPlanId, long feeId) {
return mapper.selectOne(new LambdaQueryWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, tenantId)
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getId, feeId));
}
public ContainerFeeDetailEntity find(long loadingPlanId, long feeId) {
return mapper.selectOne(new LambdaQueryWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getId, feeId));
}
public void insert(ContainerFeeDetailEntity value) {
mapper.insert(value);
}
public int review(long tenantId, long loadingPlanId, long feeId, String status,
long reviewerId, String reviewerName, String reason) {
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, tenantId)
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getId, feeId)
.eq(ContainerFeeDetailEntity::getReviewStatus, "PENDING")
.eq(ContainerFeeDetailEntity::getSourceStatus, "ACTIVE")
.set(ContainerFeeDetailEntity::getReviewStatus, status)
.set(ContainerFeeDetailEntity::getReviewerId, reviewerId)
.set(ContainerFeeDetailEntity::getReviewerName, reviewerName)
.set(ContainerFeeDetailEntity::getReviewedAt, LocalDateTime.now())
.set(ContainerFeeDetailEntity::getReviewReason, reason)
.set(ContainerFeeDetailEntity::getUpdatedBy, reviewerId));
}
public int adjust(ContainerFeeDetailEntity value, int expectedVersion) {
return mapper.update(value, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, value.getTenantId())
.eq(ContainerFeeDetailEntity::getLoadingPlanId, value.getLoadingPlanId())
.eq(ContainerFeeDetailEntity::getId, value.getId())
.eq(ContainerFeeDetailEntity::getReviewStatus, "APPROVED")
.eq(ContainerFeeDetailEntity::getSourceStatus, "ACTIVE")
.eq(ContainerFeeDetailEntity::getSourceVersion, expectedVersion));
}
public int voidApproved(long tenantId, long loadingPlanId, long feeId,
int expectedVersion, long operatorId) {
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
.eq(ContainerFeeDetailEntity::getTenantId, tenantId)
.eq(ContainerFeeDetailEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeDetailEntity::getId, feeId)
.eq(ContainerFeeDetailEntity::getReviewStatus, "APPROVED")
.eq(ContainerFeeDetailEntity::getSourceStatus, "ACTIVE")
.eq(ContainerFeeDetailEntity::getSourceVersion, expectedVersion)
.set(ContainerFeeDetailEntity::getSourceStatus, "VOIDED")
.set(ContainerFeeDetailEntity::getUpdatedBy, operatorId));
}
}
@@ -113,6 +113,18 @@ public class FeeItemRepository {
.eq(InboundFeeDetailEntity::getId, detailId));
}
public InboundFeeDetailEntity findDetail(long inboundId, long detailId) {
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
.eq(InboundFeeDetailEntity::getId, detailId));
}
public InboundFeeDetailEntity findBySourceFee(String sourceType, long sourceFeeId) {
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getSourceType, sourceType)
.eq(InboundFeeDetailEntity::getSourceFeeId, sourceFeeId));
}
public InboundFeeDetailEntity findBySourceFee(long tenantId, String sourceType, long sourceFeeId) {
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
@@ -171,6 +183,33 @@ public class FeeItemRepository {
.set(InboundFeeDetailEntity::getReviewerName, reviewerName)
.set(InboundFeeDetailEntity::getReviewedAt, LocalDateTime.now())
.set(InboundFeeDetailEntity::getReviewReason, reason)
.set("APPROVED".equals(targetStatus), InboundFeeDetailEntity::getPublicationStatus,
"PUBLISHED")
.set("APPROVED".equals(targetStatus), InboundFeeDetailEntity::getPublishedBy, reviewerId)
.set("APPROVED".equals(targetStatus), InboundFeeDetailEntity::getPublishedByName, reviewerName)
.set("APPROVED".equals(targetStatus), InboundFeeDetailEntity::getPublishedAt, LocalDateTime.now())
.set(InboundFeeDetailEntity::getUpdatedBy, reviewerId));
}
public int adjustApproved(InboundFeeDetailEntity detail, int expectedSourceVersion) {
return feeDetails.update(detail, new LambdaUpdateWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, detail.getTenantId())
.eq(InboundFeeDetailEntity::getInboundId, detail.getInboundId())
.eq(InboundFeeDetailEntity::getId, detail.getId())
.eq(InboundFeeDetailEntity::getReviewStatus, "APPROVED")
.eq(InboundFeeDetailEntity::getSourceFeeVersion, expectedSourceVersion));
}
public int voidApproved(long tenantId, long inboundId, long detailId,
int expectedSourceVersion, long operatorId) {
return feeDetails.update(null, new LambdaUpdateWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
.eq(InboundFeeDetailEntity::getId, detailId)
.eq(InboundFeeDetailEntity::getReviewStatus, "APPROVED")
.eq(InboundFeeDetailEntity::getSourceFeeVersion, expectedSourceVersion)
.ne(InboundFeeDetailEntity::getPublicationStatus, "VOIDED")
.set(InboundFeeDetailEntity::getPublicationStatus, "VOIDED")
.set(InboundFeeDetailEntity::getUpdatedBy, operatorId));
}
}
@@ -0,0 +1,107 @@
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.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
@RequiredArgsConstructor
public class FinanceFeeSourceOutboxRepository {
private static final List<String> EVENT_TYPES = List.of(
"FIRSTLEG_FEE_PUBLISHED", "FIRSTLEG_FEE_ADJUSTED", "FIRSTLEG_FEE_VOIDED");
private final InboundOutboxMapper mapper;
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now) {
return mapper.selectList(new LambdaQueryWrapper<InboundOutboxEntity>()
.in(InboundOutboxEntity::getEventType, EVENT_TYPES)
.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.lt(InboundOutboxEntity::getRetryCount, 12)
.and(value -> value.isNull(InboundOutboxEntity::getNextRetryAt)
.or().le(InboundOutboxEntity::getNextRetryAt, now))
.orderByAsc(InboundOutboxEntity::getCreatedAt)
.orderByAsc(InboundOutboxEntity::getId)
.last("LIMIT " + Math.min(Math.max(limit, 1), 100)));
}
public boolean claim(long id, String workerId, LocalDateTime now) {
return mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId, id)
.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.set(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.set(InboundOutboxEntity::getLockedAt, now)
.set(InboundOutboxEntity::getLockedBy, workerId)) == 1;
}
public InboundOutboxEntity findClaimed(long id, String workerId) {
return mapper.selectOne(new LambdaQueryWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId, id)
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.eq(InboundOutboxEntity::getLockedBy, workerId));
}
public boolean published(long id, String workerId, LocalDateTime now) {
return mapper.update(null, claimed(id, workerId)
.set(InboundOutboxEntity::getPublishStatus, "PUBLISHED")
.set(InboundOutboxEntity::getPublishedAt, now)
.set(InboundOutboxEntity::getNextRetryAt, null)
.set(InboundOutboxEntity::getLockedAt, null)
.set(InboundOutboxEntity::getLockedBy, null)
.set(InboundOutboxEntity::getLastError, null)) == 1;
}
public boolean retry(long id, String workerId, LocalDateTime nextRetryAt,
String error, boolean exhausted) {
return mapper.update(null, claimed(id, workerId)
.setSql("retry_count = retry_count + 1")
.set(InboundOutboxEntity::getPublishStatus, exhausted ? "FAILED" : "PENDING")
.set(InboundOutboxEntity::getNextRetryAt, exhausted ? null : nextRetryAt)
.set(InboundOutboxEntity::getLockedAt, null)
.set(InboundOutboxEntity::getLockedBy, null)
.set(InboundOutboxEntity::getLastError, truncate(error))) == 1;
}
public int recoverStale(LocalDateTime staleBefore, LocalDateTime retryAt, int maxRetries) {
int failed = mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
.in(InboundOutboxEntity::getEventType, EVENT_TYPES)
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.le(InboundOutboxEntity::getLockedAt, staleBefore)
.ge(InboundOutboxEntity::getRetryCount, maxRetries - 1)
.setSql("retry_count = retry_count + 1")
.set(InboundOutboxEntity::getPublishStatus, "FAILED")
.set(InboundOutboxEntity::getNextRetryAt, null)
.set(InboundOutboxEntity::getLockedAt, null)
.set(InboundOutboxEntity::getLockedBy, null)
.set(InboundOutboxEntity::getLastError, "relay lease expired"));
int pending = mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
.in(InboundOutboxEntity::getEventType, EVENT_TYPES)
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.le(InboundOutboxEntity::getLockedAt, staleBefore)
.lt(InboundOutboxEntity::getRetryCount, maxRetries - 1)
.setSql("retry_count = retry_count + 1")
.set(InboundOutboxEntity::getPublishStatus, "PENDING")
.set(InboundOutboxEntity::getNextRetryAt, retryAt)
.set(InboundOutboxEntity::getLockedAt, null)
.set(InboundOutboxEntity::getLockedBy, null)
.set(InboundOutboxEntity::getLastError, "relay lease expired"));
return failed + pending;
}
private static LambdaUpdateWrapper<InboundOutboxEntity> claimed(long id, String workerId) {
return new LambdaUpdateWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId, id)
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.eq(InboundOutboxEntity::getLockedBy, workerId);
}
private static String truncate(String value) {
if (value == null || value.isBlank()) return "unknown relay error";
String sanitized = value.replaceAll("[\\r\\n\\t]+", " ").trim();
return sanitized.length() <= 500 ? sanitized : sanitized.substring(0, 500);
}
}
@@ -0,0 +1,162 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ContainerFeeDetailService {
private final ContainerFeeDetailRepository repository;
private final SmartLoadingRepository loading;
private final FeeItemRepository feeItems;
private final FinanceFeeSourceOutboxService financeOutbox;
public List<ContainerFeeDetailEntity> list(long tenantId, long loadingPlanId) {
requireOwnedPlan(tenantId, loadingPlanId);
return repository.list(tenantId, loadingPlanId);
}
@Transactional
public ContainerFeeDetailEntity create(long tenantId, long loadingPlanId, long operatorId,
SaveRequest request) {
PlanContext plan = requireOwnedPlan(tenantId, loadingPlanId);
FeeItemEntity item = requireItem(request.feeItemId(), request.direction());
ContainerFeeDetailEntity value = new ContainerFeeDetailEntity();
value.setTenantId(tenantId);
value.setLoadingPlanId(loadingPlanId);
value.setContainerBookingId(plan.plan().getContainerBookingId());
value.setContainerNo(plan.booking() == null ? null : plan.booking().getContainerNo());
value.setSourceVersion(1);
value.setSourceStatus("ACTIVE");
value.setReviewStatus("PENDING");
value.setCreatedBy(operatorId);
value.setCreatedAt(LocalDateTime.now());
apply(value, item, request, operatorId);
repository.insert(value);
return value;
}
@Transactional
public void approve(long tenantId, long loadingPlanId, long feeId,
long reviewerId, String reviewerName) {
required(tenantId, loadingPlanId, feeId);
if (repository.review(tenantId, loadingPlanId, feeId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
throw conflict("只有待审核柜次费用可以审核通过");
}
ContainerFeeDetailEntity approved = required(tenantId, loadingPlanId, feeId);
if (Boolean.TRUE.equals(approved.getSettlementIncluded())) {
String eventType = approved.getSourceVersion() > 1
? FinanceFeeSourceOutboxService.ADJUSTED : FinanceFeeSourceOutboxService.PUBLISHED;
financeOutbox.publishContainer(eventType, tenantId, loadingPlanId, feeId,
approved.getSourceVersion(), LocalDateTime.now());
}
}
@Transactional
public void reject(long tenantId, long loadingPlanId, long feeId, long reviewerId,
String reviewerName, String reason) {
required(tenantId, loadingPlanId, feeId);
if (repository.review(tenantId, loadingPlanId, feeId, "REJECTED", reviewerId,
reviewerName, reason.trim()) != 1) {
throw conflict("只有待审核柜次费用可以驳回");
}
}
@Transactional
public ContainerFeeDetailEntity adjust(long tenantId, long loadingPlanId, long feeId,
int expectedVersion, long operatorId,
SaveRequest request) {
requireOwnedPlan(tenantId, loadingPlanId);
ContainerFeeDetailEntity value = required(tenantId, loadingPlanId, feeId);
FeeItemEntity item = requireItem(request.feeItemId(), request.direction());
apply(value, item, request, operatorId);
value.setSourceVersion(expectedVersion + 1);
value.setReviewStatus("PENDING");
value.setReviewerId(null);
value.setReviewerName(null);
value.setReviewedAt(null);
value.setReviewReason(null);
if (repository.adjust(value, expectedVersion) != 1) {
throw conflict("只有当前版本的已审核柜次费用可以调整");
}
return required(tenantId, loadingPlanId, feeId);
}
@Transactional
public void voidApproved(long tenantId, long loadingPlanId, long feeId,
int expectedVersion, long operatorId) {
ContainerFeeDetailEntity current = required(tenantId, loadingPlanId, feeId);
if (repository.voidApproved(tenantId, loadingPlanId, feeId, expectedVersion, operatorId) != 1) {
throw conflict("只有当前版本的已审核柜次费用可以作废");
}
if (Boolean.TRUE.equals(current.getSettlementIncluded())) {
financeOutbox.publishContainer(FinanceFeeSourceOutboxService.VOIDED, tenantId,
loadingPlanId, feeId, expectedVersion, LocalDateTime.now());
}
}
private PlanContext requireOwnedPlan(long tenantId, long loadingPlanId) {
LoadingPlanEntity plan = loading.plan(loadingPlanId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
boolean owned = loading.items(loadingPlanId).stream()
.anyMatch(item -> Long.valueOf(tenantId).equals(item.getAccountId()));
if (!owned) throw new BizException(ResultCode.NOT_FOUND, "柜次不属于该客户");
return new PlanContext(plan, plan.getContainerBookingId() == null
? null : loading.booking(plan.getContainerBookingId()));
}
private FeeItemEntity requireItem(long feeItemId, String direction) {
FeeItemEntity item = feeItems.findItem(feeItemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "费用项目不存在");
if (!"ACTIVE".equals(item.getStatus())) throw conflict("费用项目已停用");
boolean applicable = "RECEIVABLE".equals(direction)
? Boolean.TRUE.equals(item.getPickupReceivable()) || Boolean.TRUE.equals(item.getWarehouseReceivable())
: Boolean.TRUE.equals(item.getPickupPayable()) || Boolean.TRUE.equals(item.getWarehousePayable());
if (!applicable) throw conflict("费用项目未启用对应应收/应付方向");
return item;
}
private ContainerFeeDetailEntity required(long tenantId, long loadingPlanId, long feeId) {
ContainerFeeDetailEntity value = repository.find(tenantId, loadingPlanId, feeId);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "柜次费用明细不存在");
return value;
}
private static void apply(ContainerFeeDetailEntity value, FeeItemEntity item,
SaveRequest request, long operatorId) {
value.setFeeItemId(item.getId());
value.setFeeItemCodeSnapshot(item.getFeeCode());
value.setFeeItemNameSnapshot(item.getFeeTypeName());
value.setDirection(request.direction());
value.setBusinessDate(request.businessDate());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
value.setCurrency(request.currency());
value.setSettlementIncluded(Boolean.TRUE.equals(request.settlementIncluded()));
value.setRemark(request.remark() == null ? null : request.remark().trim());
value.setUpdatedBy(operatorId);
value.setUpdatedAt(LocalDateTime.now());
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
private record PlanContext(LoadingPlanEntity plan, ContainerBookingEntity booking) {
}
}
@@ -11,7 +11,6 @@ import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
@@ -44,6 +43,8 @@ public class FbaPricingService {
private Clock clock;
@Resource
private InboundFeeDetailService feeDetails;
@Resource
private FinanceFeeSourceOutboxService financeOutbox;
@Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
@@ -126,16 +127,8 @@ public class FbaPricingService {
throw conflict("费用未确认或入库单状态已变化,不能发送");
}
feeDetails.publishFreight(tenantId, inboundId, current.getId(), operatorId, null, now);
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
event.setAggregateId(inboundId);
event.setEventType("FIRSTLEG_FEE_SENT");
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\"" + current.getId() + "\"}");
event.setPublishStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(now);
repository.insertOutbox(event);
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED,
tenantId, inboundId, current.getId(), current.getFeeVersion(), now);
complete(token, current.getId(), now);
return response(tenantId, current.getId(), nextProcessStatus);
}
@@ -208,17 +201,8 @@ public class FbaPricingService {
repository.insertItem(extra);
}
feeDetails.publishFreight(tenantId, inboundId, adjusted.getId(), operatorId, null, now);
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
event.setAggregateId(inboundId);
event.setEventType("FIRSTLEG_FEE_ADJUSTED");
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\""
+ adjusted.getId() + "\",\"feeVersion\":" + nextVersion + "}");
event.setPublishStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(now);
repository.insertOutbox(event);
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.ADJUSTED,
tenantId, inboundId, adjusted.getId(), nextVersion, now);
complete(token, adjusted.getId(), now);
return response(tenantId, adjusted.getId(), "INBOUNDED");
}
@@ -0,0 +1,182 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.FinanceFeeSourceEventClient;
import com.baiyue.firstleg.dto.FinanceFeeSourceEvent;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.repository.FinanceFeeSourceOutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.Set;
import java.util.UUID;
/** 将 Firstleg 费用来源 Outbox 至少一次投递到 Finance Inbox。 */
@Component
@Slf4j
public class FinanceFeeSourceOutboxRelay {
private static final int MAX_RETRIES = 12;
private static final Set<String> TYPES = Set.of(
FinanceFeeSourceOutboxService.PUBLISHED,
FinanceFeeSourceOutboxService.ADJUSTED,
FinanceFeeSourceOutboxService.VOIDED);
private final FinanceFeeSourceOutboxRepository repository;
private final FinanceFeeSourceEventClient client;
private final ObjectMapper objectMapper;
private final Clock clock;
private final String workerId;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
public FinanceFeeSourceOutboxRelay(FinanceFeeSourceOutboxRepository repository,
FinanceFeeSourceEventClient client,
ObjectMapper objectMapper) {
this(repository, client, objectMapper, Clock.systemDefaultZone(), UUID.randomUUID().toString());
}
FinanceFeeSourceOutboxRelay(FinanceFeeSourceOutboxRepository repository,
FinanceFeeSourceEventClient client, ObjectMapper objectMapper,
Clock clock, String workerId) {
this.repository = repository;
this.client = client;
this.objectMapper = objectMapper;
this.clock = clock;
this.workerId = workerId;
}
@Scheduled(fixedDelayString = "${baiyue.finance.outbox-relay.fixed-delay-ms:5000}")
public void relay() {
LocalDateTime now = LocalDateTime.now(clock);
repository.recoverStale(now.minusMinutes(5), now.plusSeconds(10), MAX_RETRIES);
for (InboundOutboxEntity candidate : repository.pending(50, now)) {
if (!repository.claim(candidate.getId(), workerId, now)) continue;
InboundOutboxEntity event = repository.findClaimed(candidate.getId(), workerId);
if (event == null) continue;
deliver(event);
}
}
private void deliver(InboundOutboxEntity row) {
try {
FinanceFeeSourceEvent event = parseAndValidate(row);
ApiResponse<FinanceFeeSourceEventClient.Acknowledgement> response =
client.receive(internalToken, event.idempotencyKey(), event);
validateAcknowledgement(event, response);
if (!repository.published(row.getId(), workerId, LocalDateTime.now(clock))) {
log.warn("Finance fee event acknowledgement lost lease: eventId={}", row.getEventKey());
}
} catch (Exception exception) {
int retryCount = row.getRetryCount() == null ? 0 : row.getRetryCount();
boolean permanent = permanent(exception);
boolean exhausted = permanent || retryCount + 1 >= MAX_RETRIES;
String error = safeError(exception);
repository.retry(row.getId(), workerId,
LocalDateTime.now(clock).plusSeconds(backoffSeconds(retryCount + 1)),
error, exhausted);
log.warn("Finance fee event relay failed: eventId={}, retry={}, exhausted={}, reason={}",
row.getEventKey(), retryCount + 1, exhausted, error);
}
}
private FinanceFeeSourceEvent parseAndValidate(InboundOutboxEntity row) throws Exception {
FinanceFeeSourceEvent event = objectMapper.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class);
if (event == null || event.eventVersion() != 1 || !"firstleg-service".equals(event.producer())
|| !TYPES.contains(event.eventType()) || !event.eventType().equals(row.getEventType())
|| event.eventId() == null || !event.eventId().equals(row.getEventKey())
|| !event.idempotencyKey().equals(row.getEventKey())
|| !String.valueOf(row.getTenantId()).equals(event.tenantId())
|| !String.valueOf(row.getAggregateId()).equals(event.aggregateId())
|| !event.aggregateType().equals(row.getAggregateType())
|| event.payload() == null || event.payload().feeVersion() != event.sequence()
|| !sha256(objectMapper.writeValueAsString(event.payload())).equals(event.payloadHash())
|| !event.payloadHash().equals(row.getPayloadHash())) {
throw new InvalidEventException("费用来源事件信封、归属或哈希不一致");
}
if ("INBOUND".equals(event.aggregateType())
&& !event.aggregateId().equals(event.payload().inboundId())) {
throw new InvalidEventException("入库费用事件聚合归属不一致");
}
if ("CONTAINER".equals(event.aggregateType())
&& !event.aggregateId().equals(event.payload().containerId())) {
throw new InvalidEventException("柜次费用事件聚合归属不一致");
}
return event;
}
private static void validateAcknowledgement(
FinanceFeeSourceEvent event,
ApiResponse<FinanceFeeSourceEventClient.Acknowledgement> response) {
if (response != null && !response.successful()) {
String code = response.code();
boolean permanent = code != null && !Set.of("FIN-EVT-003", "FIN-EVT-005", "FIN-EVT-006").contains(code);
throw new FinanceRejectedException(code == null ? "FIN-EVT-UNKNOWN" : code,
response.message(), permanent);
}
if (response == null || response.data() == null
|| response.data().feeSourceId() == null || response.data().feeSourceId() <= 0) {
throw new IllegalStateException("Finance未确认费用来源事件");
}
}
private static boolean permanent(Exception exception) {
if (exception instanceof InvalidEventException) return true;
if (exception instanceof FinanceRejectedException rejected) return rejected.permanent;
if (exception instanceof FeignException feign) {
int status = feign.status();
return status >= 400 && status < 500 && status != 408 && status != 429;
}
return false;
}
private static long backoffSeconds(int retry) {
return switch (Math.min(Math.max(retry, 1), 5)) {
case 1 -> 10;
case 2 -> 30;
case 3 -> 120;
case 4 -> 600;
default -> 1800;
};
}
private String safeError(Exception exception) {
String value = exception.getMessage();
if (value == null || value.isBlank()) value = exception.getClass().getSimpleName();
if (internalToken != null && !internalToken.isBlank()) value = value.replace(internalToken, "***");
return value;
}
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 final class InvalidEventException extends RuntimeException {
private InvalidEventException(String message) {
super(message);
}
}
private static final class FinanceRejectedException extends RuntimeException {
private final boolean permanent;
private FinanceRejectedException(String code, String message, boolean permanent) {
super(code + ": " + (message == null ? "Finance拒绝费用来源事件" : message));
this.permanent = permanent;
}
}
}
@@ -0,0 +1,96 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceEvent;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
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 java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.LocalDateTime;
import java.util.HexFormat;
/** 在业务事务内记录 Finance 费用来源事件,eventKey 保证重复动作不会重复入箱。 */
@Service
@RequiredArgsConstructor
public class FinanceFeeSourceOutboxService {
public static final String PUBLISHED = "FIRSTLEG_FEE_PUBLISHED";
public static final String ADJUSTED = "FIRSTLEG_FEE_ADJUSTED";
public static final String VOIDED = "FIRSTLEG_FEE_VOIDED";
private final InboundOutboxMapper outbox;
private final ObjectMapper objectMapper;
public void publishInbound(String eventType, long tenantId, long inboundId,
long feeId, int feeVersion, LocalDateTime occurredAt) {
FinanceFeeSourceEvent.Payload payload = new FinanceFeeSourceEvent.Payload(
String.valueOf(inboundId), null, String.valueOf(feeId), feeVersion);
publish(event(eventType, tenantId, "INBOUND", inboundId, feeId, feeVersion,
payload, occurredAt));
}
public void publishContainer(String eventType, long tenantId, long containerId,
long feeId, int feeVersion, LocalDateTime occurredAt) {
FinanceFeeSourceEvent.Payload payload = new FinanceFeeSourceEvent.Payload(
null, String.valueOf(containerId), String.valueOf(feeId), feeVersion);
publish(event(eventType, tenantId, "CONTAINER", containerId, feeId, feeVersion,
payload, occurredAt));
}
private void publish(FinanceFeeSourceEvent event) {
InboundOutboxEntity value = new InboundOutboxEntity();
value.setTenantId(Long.parseLong(event.tenantId()));
value.setAggregateType(event.aggregateType());
value.setAggregateId(Long.parseLong(event.aggregateId()));
value.setEventType(event.eventType());
value.setEventKey(event.eventId());
value.setEventVersion(event.eventVersion());
value.setAggregateSequence(event.sequence());
value.setPayloadHash(event.payloadHash());
value.setPayloadJson(json(event));
value.setPublishStatus("PENDING");
value.setRetryCount(0);
value.setCreatedAt(event.occurredAt());
try {
outbox.insert(value);
} catch (DuplicateKeyException ignored) {
// 同一来源版本和状态的事件已经进入 Outbox,调用方可安全重试。
}
}
private FinanceFeeSourceEvent event(String eventType, long tenantId, String aggregateType,
long aggregateId, long feeId, int feeVersion,
FinanceFeeSourceEvent.Payload payload, LocalDateTime occurredAt) {
String idempotencyKey = "FIRSTLEG_FEE:" + aggregateType + ":" + aggregateId
+ ":" + feeId + ":" + feeVersion + ":" + eventType;
String payloadJson = json(payload);
return new FinanceFeeSourceEvent(idempotencyKey, eventType, 1, "firstleg-service",
occurredAt == null ? LocalDateTime.now() : occurredAt, String.valueOf(tenantId),
aggregateType, String.valueOf(aggregateId), feeVersion, idempotencyKey,
sha256(payloadJson), payload);
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "费用来源事件序列化失败");
}
}
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);
}
}
}
@@ -0,0 +1,87 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
@Service
@RequiredArgsConstructor
public class FinanceFeeSourceSnapshotService {
private final FeeItemRepository inboundFees;
private final ContainerFeeDetailRepository containerFees;
public FinanceFeeSourceSnapshot inbound(long inboundId, long feeId) {
InboundFeeDetailEntity detail = inboundFees.findDetail(inboundId, feeId);
if (detail == null) detail = inboundFees.findBySourceFee("FBA_PRICING", feeId);
if (detail == null || !Long.valueOf(inboundId).equals(detail.getInboundId())) {
throw new BizException(ResultCode.NOT_FOUND, "入库费用来源不存在");
}
InboundOrderEntity inbound = inboundFees.findInbound(detail.getTenantId(), inboundId);
FeeItemEntity item = inboundFees.findItem(detail.getFeeItemId());
if (inbound == null || item == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库费用来源关联快照不完整");
}
long sourceFeeId = "FBA_PRICING".equals(detail.getSourceType())
? detail.getSourceFeeId() : detail.getId();
int version = detail.getSourceFeeVersion() == null ? 1 : detail.getSourceFeeVersion();
String canonical = String.join("|", "INBOUND_FEE", String.valueOf(sourceFeeId),
String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(inboundId),
String.valueOf(inbound.getOrderId()), nullSafe(item.getFeeCode()),
nullSafe(detail.getDirection()), detail.getAmount().toPlainString(),
nullSafe(detail.getCurrency()), String.valueOf(detail.getSettlementIncluded()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "INBOUND_FEE", sourceFeeId,
version, inboundId, inbound.getInboundCode(), inbound.getOrderId(), null, null, null,
detail.getTenantId(), inbound.getCustomerName(), item.getId(), item.getFeeCode(),
detail.getFeeTypeNameSnapshot(), detail.getDirection(), detail.getBusinessDate(),
detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
detail.getPublicationStatus(), Boolean.TRUE.equals(detail.getSettlementIncluded()),
detail.getPublishedAt(), detail.getChargeableQuantity(), detail.getChargeUnit(),
detail.getActualGrossWeightKg(), detail.getActualVolumeCbm(), sha256(canonical), 1);
}
public FinanceFeeSourceSnapshot container(long containerId, long feeId) {
ContainerFeeDetailEntity detail = containerFees.find(containerId, feeId);
if (detail == null) throw new BizException(ResultCode.NOT_FOUND, "柜次费用来源不存在");
int version = detail.getSourceVersion();
String publicationStatus = "VOIDED".equals(detail.getSourceStatus()) ? "VOIDED"
: "APPROVED".equals(detail.getReviewStatus()) ? "PUBLISHED" : "DRAFT";
String canonical = String.join("|", "CONTAINER_FEE", String.valueOf(feeId),
String.valueOf(version), String.valueOf(detail.getTenantId()), String.valueOf(containerId),
nullSafe(detail.getFeeItemCodeSnapshot()), nullSafe(detail.getDirection()),
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
String.valueOf(detail.getSettlementIncluded()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
version, null, null, null, containerId, detail.getContainerBookingId(),
detail.getContainerNo(), detail.getTenantId(), null, detail.getFeeItemId(),
detail.getFeeItemCodeSnapshot(), detail.getFeeItemNameSnapshot(), detail.getDirection(),
detail.getBusinessDate(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
publicationStatus, Boolean.TRUE.equals(detail.getSettlementIncluded()),
detail.getReviewedAt(), null, null, null, null, sha256(canonical), 1);
}
private static String nullSafe(Object value) {
return value == null ? "" : String.valueOf(value);
}
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);
}
}
}
@@ -6,7 +6,6 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -29,6 +28,8 @@ public class InboundCompletionOrchestrator {
private InboundIdempotencyService idempotency;
@Resource
private InboundFeeDetailService feeDetails;
@Resource
private FinanceFeeSourceOutboxService financeOutbox;
/**
* 任务5对接方法一:锁定发送动作。允许前置状态仅为 FEE_CONFIRMING + CONFIRMED。
@@ -89,11 +90,12 @@ public class InboundCompletionOrchestrator {
if (repository.markFeeSent(tenantId, inboundId, feeId, operatorId) != 1) {
throw new BizException(ResultCode.CONFLICT, "费用快照已被并发发送或状态不正确");
}
feeDetails.publishFreight(tenantId, inboundId, feeId, operatorId, operatorName,
LocalDateTime.now());
var published = feeDetails.publishFreight(tenantId, inboundId, feeId, operatorId,
operatorName, LocalDateTime.now());
repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId,
"INBOUNDING", "INBOUNDED", "SEND_FEE_COMPLETE", null, operatorId, operatorName));
repository.insertOutbox(completedEvent(tenantId, inboundId, feeId));
financeOutbox.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED, tenantId,
inboundId, feeId, published.getSourceFeeVersion(), LocalDateTime.now());
idempotency.complete(context, inboundId);
}
@@ -139,19 +141,6 @@ public class InboundCompletionOrchestrator {
return order;
}
private static InboundOutboxEntity completedEvent(long tenantId, long inboundId, long feeId) {
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
event.setAggregateId(inboundId);
event.setEventType("FIRSTLEG_INBOUND_FEE_SENT");
event.setPayloadJson("{\"inboundId\":\"" + inboundId + "\",\"feeId\":\"" + feeId + "\"}");
event.setPublishStatus("PENDING");
event.setRetryCount(0);
event.setCreatedAt(LocalDateTime.now());
return event;
}
private static BizException concurrentConflict() {
return new BizException(ResultCode.CONFLICT, "入库单已被并发更新,请刷新后重试");
}
@@ -28,6 +28,8 @@ public class InboundFeeDetailService {
private FeeItemRepository repository;
@Resource
private FbaPricingRepository pricingRepository;
@Resource
private FinanceFeeSourceOutboxService financeOutbox;
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId) {
requiredInbound(tenantId, inboundId);
@@ -132,8 +134,8 @@ public class InboundFeeDetailService {
apply(value, inbound, item, request, operatorId);
value.setReviewStatus("PENDING");
value.setSourceType("MANUAL");
value.setSourceFeeVersion(1);
value.setPublicationStatus("DRAFT");
value.setSettlementIncluded(false);
repository.insertDetail(value);
return value;
}
@@ -168,6 +170,49 @@ public class InboundFeeDetailService {
reviewerId, reviewerName, null) != 1) {
throw conflict("只有待审核费用明细可以审核通过");
}
InboundFeeDetailEntity approved = required(tenantId, inboundId, detailId);
publishFinanceSource(approved, "AVAILABLE", LocalDateTime.now());
}
@Transactional
public InboundFeeDetailEntity adjust(long tenantId, long inboundId, long detailId,
int expectedSourceVersion, long operatorId,
SaveRequest request) {
InboundFeeDetailEntity value = required(tenantId, inboundId, detailId);
if (!"APPROVED".equals(value.getReviewStatus()) || "VOIDED".equals(value.getPublicationStatus())) {
throw conflict("只有审核通过且未作废的费用明细可以调整");
}
if (value.getSourceFeeVersion() == null || value.getSourceFeeVersion() != expectedSourceVersion) {
throw conflict("费用来源版本已变化,请刷新后重试");
}
InboundOrderEntity inbound = requiredInbound(tenantId, inboundId);
FeeItemEntity item = requiredActiveItem(request.feeItemId());
apply(value, inbound, item, request, operatorId);
value.setSourceType("MANUAL");
value.setSourceFeeVersion(expectedSourceVersion + 1);
value.setReviewStatus("PENDING");
value.setReviewerId(null);
value.setReviewerName(null);
value.setReviewedAt(null);
value.setReviewReason(null);
value.setPublicationStatus("DRAFT");
if (repository.adjustApproved(value, expectedSourceVersion) != 1) {
throw conflict("费用明细已被其他人调整,请刷新后重试");
}
return required(tenantId, inboundId, detailId);
}
@Transactional
public void voidApproved(long tenantId, long inboundId, long detailId,
int expectedSourceVersion, long operatorId) {
InboundFeeDetailEntity current = required(tenantId, inboundId, detailId);
if (repository.voidApproved(tenantId, inboundId, detailId, expectedSourceVersion, operatorId) != 1) {
throw conflict("只有当前版本的已审核费用明细可以作废");
}
if (Boolean.TRUE.equals(current.getSettlementIncluded())) {
current.setPublicationStatus("VOIDED");
publishFinanceSource(current, "VOIDED", LocalDateTime.now());
}
}
@Transactional
@@ -282,6 +327,7 @@ public class InboundFeeDetailService {
value.setUnitPrice(request.unitPrice().setScale(4, RoundingMode.HALF_UP));
value.setCurrency(request.currency());
value.setAmount(request.amount().setScale(2, RoundingMode.HALF_UP));
value.setSettlementIncluded(Boolean.TRUE.equals(request.settlementIncluded()));
value.setRemark(request.remark() == null ? null : request.remark().trim());
value.setUpdatedBy(operatorId);
}
@@ -289,4 +335,16 @@ public class InboundFeeDetailService {
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
private void publishFinanceSource(InboundFeeDetailEntity detail, String status,
LocalDateTime occurredAt) {
if (!Boolean.TRUE.equals(detail.getSettlementIncluded())) return;
if ("FBA_PRICING".equals(detail.getSourceType())) return;
int version = detail.getSourceFeeVersion() == null ? 1 : detail.getSourceFeeVersion();
String eventType = "VOIDED".equals(status) ? FinanceFeeSourceOutboxService.VOIDED
: version > 1 ? FinanceFeeSourceOutboxService.ADJUSTED
: FinanceFeeSourceOutboxService.PUBLISHED;
financeOutbox.publishInbound(eventType, detail.getTenantId(), detail.getInboundId(),
detail.getId(), version, occurredAt);
}
}