优化
This commit is contained in:
@@ -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) {
|
||||
}
|
||||
}
|
||||
+50
@@ -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, "内部服务令牌无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+93
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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> {
|
||||
}
|
||||
+81
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+107
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -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
-17
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user