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);
}
}
@@ -2,14 +2,23 @@ USE baiyue_firstleg;
-- 可靠审计本地 Outbox;与头程业务表位于同一数据库。
CREATE TABLE IF NOT EXISTS sys_audit_outbox (
event_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, resource_type VARCHAR(64) NOT NULL,
resource_id VARCHAR(128) NULL, service_name VARCHAR(64) NULL, payload_json JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING', attempt_count INT NOT NULL DEFAULT 0,
compensation_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL, last_error VARCHAR(1000) NULL, dead_at DATETIME(3) NULL,
sent_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
event_id VARCHAR(64) NOT NULL COMMENT '审计事件全局唯一ID',
event_type VARCHAR(64) NOT NULL COMMENT '审计事件类型',
resource_type VARCHAR(64) NOT NULL COMMENT '被审计业务资源类型',
resource_id VARCHAR(128) NULL COMMENT '被审计业务资源ID;无法确定时为空',
service_name VARCHAR(64) NULL COMMENT '产生审计事件的服务名称',
payload_json JSON NOT NULL COMMENT '待发送的审计事件完整负载快照',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD已转死信',
attempt_count INT NOT NULL DEFAULT 0 COMMENT '正常发送累计尝试次数',
compensation_count INT NOT NULL DEFAULT 0 COMMENT '人工或自动补偿累计次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '允许下次重试的最早时间',
lease_owner VARCHAR(128) NULL COMMENT '当前发送任务租约持有者标识',
lease_until DATETIME(3) NULL COMMENT '当前发送任务租约到期时间',
last_error VARCHAR(1000) NULL COMMENT '最近一次发送失败原因',
dead_at DATETIME(3) NULL COMMENT '转入死信状态的时间',
sent_at DATETIME(3) NULL COMMENT '首次成功发送时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录最后更新时间',
PRIMARY KEY (event_id), KEY idx_audit_outbox_publish (status, next_retry_at, lease_until, created_at),
KEY idx_audit_outbox_retention (status, sent_at)
) ENGINE=InnoDB COMMENT='可靠审计事件本地发送箱';
@@ -4,7 +4,7 @@ USE baiyue_firstleg;
-- 每个客户固定四条箱规尺寸配置;用于实测尺寸归档和计费体积加权,不包含价格。
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
box_type VARCHAR(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE',
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
@@ -0,0 +1,119 @@
-- Firstleg -> Finance 费用来源事件和柜次费用模型(MySQL 8,可重复执行)
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
DELIMITER $$
CREATE PROCEDURE firstleg_finance_add_column(
IN target_table VARCHAR(64), IN target_column VARCHAR(64), IN column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = target_table AND column_name = target_column
) THEN
SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `', target_column, '` ', column_definition);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_key',
"VARCHAR(160) NULL COMMENT '费用来源事件幂等键;历史事件允许为空' AFTER event_type");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_version',
"INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '事件信封版本' AFTER event_key");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'aggregate_sequence',
"INT UNSIGNED NULL COMMENT '同一费用来源的单调版本序号' AFTER event_version");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'payload_hash',
"CHAR(64) NULL COMMENT '短payload规范化JSON的SHA-256' AFTER aggregate_sequence");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'next_retry_at',
"DATETIME(3) NULL COMMENT '下一次允许投递时间' AFTER retry_count");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'published_at',
"DATETIME(3) NULL COMMENT 'Finance确认接收时间' AFTER next_retry_at");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'locked_at',
"DATETIME(3) NULL COMMENT 'Relay抢占租约时间' AFTER published_at");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'locked_by',
"VARCHAR(64) NULL COMMENT 'Relay抢占实例ID' AFTER locked_at");
CALL firstleg_finance_add_column('fl_inbound_outbox', 'last_error',
"VARCHAR(500) NULL COMMENT '最近一次投递失败摘要,不记录令牌' AFTER locked_by");
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
DROP PROCEDURE IF EXISTS firstleg_finance_add_index;
DELIMITER $$
CREATE PROCEDURE firstleg_finance_add_index(
IN target_table VARCHAR(64), IN target_index VARCHAR(64), IN index_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = target_table AND index_name = target_index
) THEN
SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD ', index_definition);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL firstleg_finance_add_index('fl_inbound_outbox', 'uk_inbound_outbox_event_key',
'UNIQUE KEY `uk_inbound_outbox_event_key` (`event_key`)');
CALL firstleg_finance_add_index('fl_inbound_outbox', 'idx_inbound_outbox_finance_relay',
'KEY `idx_inbound_outbox_finance_relay` (`publish_status`, `next_retry_at`, `created_at`, `id`)');
DROP PROCEDURE IF EXISTS firstleg_finance_add_index;
CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
id BIGINT UNSIGNED NOT NULL COMMENT '柜次费用来源ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次ID,对应fl_loading_plan.id',
container_booking_id BIGINT UNSIGNED NULL COMMENT '订舱柜ID快照',
container_no VARCHAR(64) NULL COMMENT '柜号快照',
fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共费用项目ID',
fee_item_code_snapshot VARCHAR(50) NOT NULL COMMENT '费用项目稳定编码快照',
fee_item_name_snapshot VARCHAR(128) NOT NULL COMMENT '费用项目名称快照',
direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付',
business_date DATE NOT NULL COMMENT '费用业务日期',
amount DECIMAL(18,2) NOT NULL COMMENT 'CNY费用金额',
currency CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY',
settlement_included TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与财务结算来源池',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED通过、REJECTED驳回',
source_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '来源状态:ACTIVE有效、VOIDED作废',
source_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用来源版本;调整递增,作废携带当前版本',
remark VARCHAR(500) NULL COMMENT '人工备注',
reviewer_id BIGINT UNSIGNED NULL COMMENT '审核人ID',
reviewer_name VARCHAR(64) NULL COMMENT '审核人名称快照',
reviewed_at DATETIME(3) NULL COMMENT '审核时间',
review_reason VARCHAR(500) NULL COMMENT '审核驳回原因',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后更新人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
KEY idx_container_fee_customer_plan (tenant_id, loading_plan_id, source_status),
KEY idx_container_fee_settlement (tenant_id, review_status, settlement_included, source_status),
CONSTRAINT chk_container_fee_direction CHECK (direction IN ('RECEIVABLE', 'PAYABLE')),
CONSTRAINT chk_container_fee_currency CHECK (currency = 'CNY'),
CONSTRAINT chk_container_fee_amount CHECK (amount >= 0),
CONSTRAINT chk_container_fee_review CHECK (review_status IN ('PENDING', 'APPROVED', 'REJECTED')),
CONSTRAINT chk_container_fee_source CHECK (source_status IN ('ACTIVE', 'VOIDED'))
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance';
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
DELIMITER $$
CREATE PROCEDURE firstleg_finance_assert_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_outbox'
AND column_name IN ('event_key', 'event_version', 'aggregate_sequence', 'payload_hash',
'next_retry_at', 'published_at', 'locked_at', 'locked_by', 'last_error')) <> 9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:Outbox事件信封字段不完整';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用表不存在';
END IF;
END$$
DELIMITER ;
CALL firstleg_finance_assert_schema();
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
+4 -4
View File
@@ -5,11 +5,11 @@ USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_fulfillment_task (
id BIGINT UNSIGNED NOT NULL COMMENT '履约任务ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务中的订单逻辑ID,不建立跨库外键',
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
order_snapshot_json JSON NOT NULL COMMENT '创建履约任务时所需的订单业务快照',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '履约任务当前状态',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '履约状态:PENDING_ASSIGNMENT待指派、ASSIGNED已指派、PROCESSING处理中、COMPLETED已完成、CANCELLED已取消',
assignee_id BIGINT UNSIGNED NULL COMMENT '当前负责人ID',
assignee_name VARCHAR(64) NULL COMMENT '当前负责人名称快照',
assigned_at DATETIME(3) NULL COMMENT '最近指派时间',
@@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS fl_fulfillment_task (
CREATE TABLE IF NOT EXISTS fl_assignment_history (
id BIGINT UNSIGNED NOT NULL COMMENT '指派历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
task_id BIGINT UNSIGNED NOT NULL COMMENT '所属履约任务ID',
previous_assignee_id BIGINT UNSIGNED NULL COMMENT '原负责人ID',
assignee_id BIGINT UNSIGNED NOT NULL COMMENT '新负责人ID',
@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS fl_consumed_event (
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
box_type VARCHAR(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE',
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
@@ -14,15 +14,15 @@ CREATE TABLE IF NOT EXISTS fl_fee_item (
warehouse_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应收是否参与',
allocation_fee TINYINT(1) NOT NULL DEFAULT 0 COMMENT '平摊费用是否参与',
description VARCHAR(500) NULL COMMENT '说明',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE或DISABLED',
created_by BIGINT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用',
created_by BIGINT NULL COMMENT '创建人ID;系统初始化时可为空',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT NULL COMMENT '最后修改人ID;系统初始化时可为空',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_fee_item_code (fee_code),
KEY idx_fee_item_query (tenant_id, trade_scope, category, status, deleted),
@@ -45,26 +45,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
destination_code VARCHAR(32) NULL COMMENT '目的仓编码快照',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
package_count INT NULL COMMENT '件数快照',
actual_gross_weight_kg DECIMAL(18,4) NULL COMMENT '仓库实测毛重',
actual_volume_cbm DECIMAL(18,6) NULL COMMENT '体积',
volume_weight_kg DECIMAL(18,4) NULL COMMENT '材积重',
chargeable_weight_kg DECIMAL(18,4) NULL COMMENT '计费重量',
chargeable_volume_cbm DECIMAL(18,6) NULL COMMENT '计费体积',
unit_price DECIMAL(18,4) NOT NULL COMMENT '单价',
currency CHAR(3) NOT NULL COMMENT '币种',
amount DECIMAL(18,2) NOT NULL COMMENT '金额',
actual_gross_weight_kg DECIMAL(18,4) NULL COMMENT '仓库实测毛重快照,单位千克',
actual_volume_cbm DECIMAL(18,6) NULL COMMENT '实际体积快照,单位立方米',
volume_weight_kg DECIMAL(18,4) NULL COMMENT '材积重快照,单位千克',
chargeable_weight_kg DECIMAL(18,4) NULL COMMENT '费用计算采用的计费重量,单位千克',
chargeable_volume_cbm DECIMAL(18,6) NULL COMMENT '费用计算采用的计费体积,单位立方米',
unit_price DECIMAL(18,4) NOT NULL COMMENT '费用单价;计价单位由对应费用项目规则确定',
currency CHAR(3) NOT NULL COMMENT '金额币种三字码,如CNY',
amount DECIMAL(18,2) NOT NULL COMMENT '该费用明细金额,币种由currency字段指定',
remark VARCHAR(500) NULL COMMENT '备注',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、APPROVED、REJECTED',
review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
reviewer_id BIGINT NULL COMMENT '审核人ID',
reviewer_name VARCHAR(64) NULL COMMENT '审核人名称快照',
reviewed_at DATETIME(3) NULL COMMENT '审核时间',
review_reason VARCHAR(500) NULL COMMENT '审核驳回原因',
created_by BIGINT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
created_by BIGINT NULL COMMENT '创建人或系统主体ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT NULL COMMENT '最后修改人或系统主体ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
KEY idx_inbound_fee_detail (tenant_id, inbound_id, business_date, deleted),
KEY idx_inbound_fee_review (tenant_id, review_status, business_date, deleted),
@@ -4,7 +4,7 @@ USE baiyue_firstleg;
-- 存量环境可重复执行:首次打印前冻结整单箱号;Print 服务继续保持无状态。
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
id BIGINT UNSIGNED NOT NULL COMMENT '实际箱唛头映射ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
@@ -68,7 +68,7 @@ DROP PROCEDURE IF EXISTS phase2_measure_add_column;
CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision (
id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID',
revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致',
@@ -6,17 +6,17 @@ USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_inbound_order (
id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
task_id BIGINT UNSIGNED NULL COMMENT '头程履约任务ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID,不建立跨库外键',
order_no VARCHAR(32) NOT NULL COMMENT '订单号快照',
inbound_code VARCHAR(40) NOT NULL COMMENT '租户内唯一入仓码',
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态',
inbound_code VARCHAR(40) NOT NULL COMMENT '客户内唯一入仓码',
process_status VARCHAR(24) NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、INBOUNDED已入库、WAIT_SPLIT历史待分柜',
order_status_snapshot VARCHAR(24) NOT NULL COMMENT '订单状态快照',
measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量数据审核状态',
fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用业务状态',
fee_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态',
notification_status VARCHAR(24) NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态',
measure_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回',
fee_status VARCHAR(24) NOT NULL DEFAULT 'UNPRICED' COMMENT '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废',
fee_review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认',
notification_status VARCHAR(24) NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态:NOT_CREATED未创建、PENDING待通知',
expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间',
customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
@@ -79,7 +79,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '提货信息快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
pickup_method VARCHAR(24) NULL COMMENT '提货方式',
pickup_at DATETIME(3) NULL COMMENT '提货时间',
@@ -100,7 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pickup_snapshot (
CREATE TABLE IF NOT EXISTS fl_inbound_destination_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '目的仓地址快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
destination_type VARCHAR(24) NULL COMMENT '目的仓类型',
destination_code VARCHAR(32) NULL COMMENT '目的仓编码',
@@ -120,7 +120,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_destination_snapshot (
CREATE TABLE IF NOT EXISTS fl_inbound_transport_snapshot (
id BIGINT UNSIGNED NOT NULL COMMENT '运输信息快照ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
loading_status VARCHAR(24) NULL COMMENT '配载状态快照',
container_no VARCHAR(64) NULL COMMENT '集装箱号',
@@ -142,7 +142,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_transport_snapshot (
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
id BIGINT UNSIGNED NOT NULL COMMENT '实际箱唛头映射ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单服务订单ID快照',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID,不建立跨库外键',
@@ -167,7 +167,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
source_type VARCHAR(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL',
source_code VARCHAR(64) NULL COMMENT '第三方应用或来源编码',
@@ -176,7 +176,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
device_code VARCHAR(64) NULL COMMENT '测量设备编码',
warehouse_code VARCHAR(50) NULL COMMENT '第三方回传仓库编码',
measured_at DATETIME(3) NULL COMMENT '设备测量时间',
status VARCHAR(24) NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态',
status VARCHAR(24) NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态:DRAFT草稿、RECEIVED已接收、SUBMITTED已提交、APPROVED已通过、REJECTED已驳回',
raw_payload_json JSON NULL COMMENT '第三方原始报文',
original_document_no VARCHAR(64) NULL COMMENT '原单据号码快照',
original_gross_weight_kg DECIMAL(18,3) NULL COMMENT '原单据毛重kg',
@@ -198,7 +198,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement (
CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
id BIGINT UNSIGNED NOT NULL COMMENT '测量明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
measurement_id BIGINT UNSIGNED NOT NULL COMMENT '测量批次ID',
inbound_detail_id BIGINT UNSIGNED NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空',
external_detail_no VARCHAR(128) NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空',
@@ -238,7 +238,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt (
id BIGINT UNSIGNED NOT NULL COMMENT '墨家联调收件记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
subject_id BIGINT UNSIGNED NOT NULL COMMENT '设备应用操作主体ID',
box_code VARCHAR(128) NOT NULL COMMENT '设备扫描的原始箱码',
scanned_shipment_id VARCHAR(64) NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID',
@@ -249,7 +249,7 @@ CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt (
machine_no VARCHAR(64) NOT NULL COMMENT '设备编号',
measured_at DATETIME(3) NOT NULL COMMENT '设备测量时间或服务接收时间',
image_file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务中的墨家联调图片ID',
status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '收件状态',
status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '测量回执匹配状态:UNMATCHED未匹配;当前首版仅记录未匹配回执',
match_failure_code VARCHAR(16) NOT NULL COMMENT '正式业务匹配失败码',
match_failure_reason VARCHAR(500) NOT NULL COMMENT '脱敏后的匹配失败原因',
request_id VARCHAR(128) NULL COMMENT '请求链路ID',
@@ -263,7 +263,7 @@ CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt (
CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '称重图片文件ID',
ocr_provider VARCHAR(64) NULL COMMENT 'OCR服务商编码',
@@ -274,7 +274,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
adjusted_weight_value DECIMAL(18,3) NULL COMMENT '人工修正后的原单位重量',
adjusted_weight_unit VARCHAR(8) NULL COMMENT '人工修正后的单位',
final_weight_kg DECIMAL(18,3) NULL COMMENT '确认并统一换算后的重量kg',
recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '识别和确认状态',
recognition_status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认',
adjustment_reason VARCHAR(500) NULL COMMENT '人工修正原因',
adjusted_by BIGINT UNSIGNED NULL COMMENT '修正人ID',
adjusted_at DATETIME(3) NULL COMMENT '修正时间',
@@ -294,7 +294,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision (
id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量修订记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
weight_result_id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID',
revision_no INT UNSIGNED NOT NULL COMMENT '结果版本号,与乐观锁版本一致',
@@ -317,7 +317,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision (
CREATE TABLE IF NOT EXISTS fl_measure_review_record (
id BIGINT UNSIGNED NOT NULL COMMENT '测量审核记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
measurement_id BIGINT UNSIGNED NULL COMMENT '被审核的测量批次ID',
review_result VARCHAR(24) NOT NULL COMMENT '审核结果',
@@ -337,8 +337,8 @@ CREATE TABLE IF NOT EXISTS fl_measure_review_record (
CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
id BIGINT UNSIGNED NOT NULL COMMENT '客户计费规则ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
rule_code VARCHAR(50) NOT NULL COMMENT '租户内规则编码',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
rule_code VARCHAR(50) NOT NULL COMMENT '客户内唯一计费规则编码',
rule_version INT UNSIGNED NOT NULL COMMENT '规则版本号',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户逻辑ID',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
@@ -351,7 +351,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule (
currency CHAR(3) NOT NULL COMMENT '默认币种',
effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间',
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用',
remark VARCHAR(500) NULL COMMENT '规则备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -394,7 +394,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_fba_rate (
source_destination_text VARCHAR(1000) NULL COMMENT '原始组合仓库或区域文本',
effective_from DATETIME(3) NOT NULL COMMENT '生效开始时间',
effective_to DATETIME(3) NULL COMMENT '生效结束时间',
status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态:PENDING_CONFIRMATION、ACTIVE或SUPERSEDED',
status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态:PENDING_CONFIRMATION、ACTIVE或SUPERSEDED;分别表示待确认、已生效、已被替代;FAILED表示解析失败',
remark VARCHAR(500) NULL COMMENT '价格备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -417,9 +417,9 @@ CREATE TABLE IF NOT EXISTS fl_price_channel_catalog (
price_model VARCHAR(32) NOT NULL COMMENT '默认价格模型',
source_sheet_name VARCHAR(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet',
aliases_json JSON NOT NULL COMMENT '上传名称别名数组',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_price_channel_code (channel_code),
KEY idx_price_channel_status (status, price_model)
@@ -429,9 +429,9 @@ CREATE TABLE IF NOT EXISTS fl_price_origin_alias (
id BIGINT UNSIGNED NOT NULL COMMENT '起运仓别名ID',
warehouse_code VARCHAR(64) NOT NULL COMMENT '平台仓库稳定编码,不存环境专属ID',
alias_name VARCHAR(128) NOT NULL COMMENT 'Excel起运仓名称别名',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_price_origin_alias (warehouse_code, alias_name),
KEY idx_price_origin_alias_name (alias_name, status)
@@ -444,9 +444,9 @@ CREATE TABLE IF NOT EXISTS fl_price_warehouse_mapping (
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '固定平台公共仓库ID',
warehouse_code VARCHAR(64) NOT NULL COMMENT '平台仓库稳定编码快照',
warehouse_type VARCHAR(16) NOT NULL COMMENT 'DOMESTIC或OVERSEAS',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_price_warehouse_mapping_source (source_type, source_value),
KEY idx_price_warehouse_mapping_target (warehouse_id, status)
@@ -459,20 +459,20 @@ CREATE TABLE IF NOT EXISTS fl_price_book_version (
original_file_name VARCHAR(255) NOT NULL COMMENT '原始Excel文件名',
source_file_id BIGINT UNSIGNED NULL COMMENT 'File服务原始文件ID',
source_sha256 CHAR(64) NULL COMMENT '原始文件SHA-256',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '待确认上传记录;ACTIVE/SUPERSEDED/FAILED',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、FAILED解析失败',
blocking_error_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '阻断错误数',
warning_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '警告数',
parse_issues_json JSON NULL COMMENT '上传解析问题及待确认重新校验结果',
channel_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '解析渠道数',
rate_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '解析价格数',
created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
published_by BIGINT UNSIGNED NULL,
created_by BIGINT UNSIGNED NOT NULL COMMENT '上传价格表的操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传记录创建时间',
published_by BIGINT UNSIGNED NULL COMMENT '确认并发布价格版本的操作人ID',
published_at DATETIME(3) NULL COMMENT '确认并生效时间',
superseded_at DATETIME(3) NULL COMMENT '被下一版本替换时间',
updated_by BIGINT UNSIGNED NOT NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
deleted TINYINT(1) NOT NULL DEFAULT 0,
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记:0未删除、1已删除',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_price_book_version (tenant_id, version_no),
@@ -496,9 +496,9 @@ CREATE TABLE IF NOT EXISTS fl_price_book_channel (
surcharge_description VARCHAR(2000) NULL COMMENT '附加费说明,仅展示',
remark VARCHAR(2000) NULL COMMENT '渠道备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '渠道快照创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '渠道快照最后更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
@@ -552,9 +552,9 @@ ON DUPLICATE KEY UPDATE
CREATE TABLE IF NOT EXISTS fl_inbound_fee (
id BIGINT UNSIGNED NOT NULL COMMENT '入库费用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
fee_no VARCHAR(40) NOT NULL COMMENT '租户内唯一费用单号',
fee_no VARCHAR(40) NOT NULL COMMENT '客户内唯一费用单号',
fee_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '费用快照版本',
charge_rule_id BIGINT UNSIGNED NULL COMMENT '历史客户计费规则ID;渠道价格可为空',
charge_rule_version INT UNSIGNED NULL COMMENT '历史客户规则版本快照',
@@ -594,8 +594,8 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
subtotal_amount DECIMAL(18,2) NOT NULL COMMENT '基础费用金额',
additional_amount DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '附加费金额',
total_amount DECIMAL(18,2) NOT NULL COMMENT '总金额',
fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态',
review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态',
fee_status VARCHAR(24) NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态:CALCULATED已计算、SUPERSEDED已被新版本替代',
review_status VARCHAR(24) NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认',
exception_release TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否基于测量异常放行生成',
calculated_by BIGINT UNSIGNED NOT NULL COMMENT '试算人ID',
calculated_at DATETIME(3) NOT NULL COMMENT '试算时间',
@@ -616,7 +616,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee (
CREATE TABLE IF NOT EXISTS fl_inbound_fee_item (
id BIGINT UNSIGNED NOT NULL COMMENT '费用明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
fee_id BIGINT UNSIGNED NOT NULL COMMENT '费用头ID',
item_no INT UNSIGNED NOT NULL COMMENT '费用单内序号',
fee_code VARCHAR(50) NOT NULL COMMENT '费用项目编码',
@@ -636,7 +636,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_item (
CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
id BIGINT UNSIGNED NOT NULL COMMENT '头程托盘分配引用ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
allocation_no INT UNSIGNED NOT NULL COMMENT '入库单内分配序号',
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库服务仓库ID',
@@ -650,7 +650,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
reservation_id BIGINT UNSIGNED NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘',
reservation_no VARCHAR(64) NOT NULL COMMENT '仓库库位预占单号',
allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态',
allocation_status VARCHAR(24) NOT NULL DEFAULT 'RESERVED' COMMENT '托盘库位分配状态:RESERVED已预占、CONFIRMED已确认、TRANSFERRED已转移、RELEASED已释放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
@@ -663,7 +663,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_pallet_allocation (
CREATE TABLE IF NOT EXISTS fl_inbound_file (
id BIGINT UNSIGNED NOT NULL COMMENT '入库单文件关系ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
file_id BIGINT UNSIGNED NOT NULL COMMENT '文件服务文件ID',
file_type VARCHAR(24) NOT NULL COMMENT '文件类型:RELATED、MARK、PC_ORDER、MEASURE或WEIGHT',
@@ -681,7 +681,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_file (
CREATE TABLE IF NOT EXISTS fl_inbound_hold_record (
id BIGINT UNSIGNED NOT NULL COMMENT '扣货记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
hold_action VARCHAR(16) NOT NULL COMMENT '动作:HOLD或RELEASE',
reason VARCHAR(1000) NOT NULL COMMENT '扣货或解除原因',
@@ -696,7 +696,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_hold_record (
CREATE TABLE IF NOT EXISTS fl_inbound_internal_note (
id BIGINT UNSIGNED NOT NULL COMMENT '客服内部备注ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
content VARCHAR(2000) NOT NULL COMMENT '仅内部可见备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
@@ -709,7 +709,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_internal_note (
CREATE TABLE IF NOT EXISTS fl_inbound_status_history (
id BIGINT UNSIGNED NOT NULL COMMENT '入库状态历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
from_status VARCHAR(24) NULL COMMENT '变更前状态',
to_status VARCHAR(24) NOT NULL COMMENT '变更后状态',
@@ -725,12 +725,12 @@ CREATE TABLE IF NOT EXISTS fl_inbound_status_history (
CREATE TABLE IF NOT EXISTS fl_inbound_outbox (
id BIGINT UNSIGNED NOT NULL COMMENT '领域事件全局ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '事件所属客户ID和数据隔离键',
aggregate_type VARCHAR(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型',
aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
payload_json JSON NOT NULL COMMENT '事件负载快照',
publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '发布状态',
publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '事件发布状态:PENDING待发布、PUBLISHED已发布、FAILED发布失败',
retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
next_retry_at DATETIME(3) NULL COMMENT '下次重试时间',
published_at DATETIME(3) NULL COMMENT '发布时间',
@@ -741,13 +741,13 @@ CREATE TABLE IF NOT EXISTS fl_inbound_outbox (
CREATE TABLE IF NOT EXISTS fl_inbound_idempotency (
id BIGINT UNSIGNED NOT NULL COMMENT '入库写操作幂等记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
operation VARCHAR(40) NOT NULL COMMENT '操作类型',
operator_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '操作者或外部应用ID',
inbound_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '入库单ID,创建前可为0',
idempotency_key VARCHAR(128) NOT NULL COMMENT '幂等键',
request_hash CHAR(64) NOT NULL COMMENT '请求内容摘要',
status VARCHAR(16) NOT NULL COMMENT '处理状态',
status VARCHAR(16) NOT NULL COMMENT '幂等处理状态:PROCESSING处理中、SUCCEEDED已成功、FAILED已失败',
result_json JSON NULL COMMENT '成功结果摘要',
result_resource_id BIGINT UNSIGNED NULL COMMENT '成功结果资源ID',
completed_at DATETIME(3) NULL COMMENT '完成时间',
@@ -760,7 +760,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_idempotency (
CREATE TABLE IF NOT EXISTS fl_customer_dimension_config (
id BIGINT UNSIGNED NOT NULL COMMENT '配置ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
box_type VARCHAR(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE',
inner_length_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内长(厘米)',
inner_width_cm DECIMAL(9,3) NOT NULL COMMENT '箱规内宽(厘米)',
@@ -422,7 +422,7 @@ CALL phase2_publish_add_column('fl_inbound_fee_detail', 'chargeable_quantity',
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'charge_unit',
"VARCHAR(16) NULL COMMENT 'KG或CBM' AFTER chargeable_quantity");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'publication_status',
"VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT、PUBLISHED、SUPERSEDED' AFTER review_reason");
"VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT '费用发布状态:DRAFT草稿、PUBLISHED已发布、SUPERSEDED已替代' AFTER review_reason");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'published_by',
"BIGINT NULL COMMENT '发布人ID' AFTER publication_status");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'published_by_name',
@@ -2,38 +2,38 @@ SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
id BIGINT UNSIGNED NOT NULL,
query_no VARCHAR(40) NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '当前产品客户ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NULL,
container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED,
carrier_code VARCHAR(16) NOT NULL COMMENT 'MATSON/SML/ZIM/COSCO/EMC/HEDE',
tracking_type VARCHAR(32) NOT NULL COMMENT 'BOOKING_NO/BILL_OF_LADING/CONTAINER_NO',
tracking_no VARCHAR(128) NOT NULL,
official_url VARCHAR(500) NOT NULL,
access_mode VARCHAR(32) NOT NULL COMMENT 'PUBLIC_FORM/HCAPTCHA/DYNAMIC_IFRAME',
status VARCHAR(24) NOT NULL COMMENT 'READY/MANUAL_REQUIRED/SUCCESS',
window_started_at DATETIME(3) NOT NULL,
attempt_count INT UNSIGNED NOT NULL DEFAULT 0,
next_allowed_at DATETIME(3) NOT NULL,
last_attempt_at DATETIME(3) NULL,
last_success_at DATETIME(3) NULL,
source_updated_at DATETIME(3) NULL,
expires_at DATETIME(3) NULL,
result_port_schedule_id BIGINT UNSIGNED NULL,
last_attempt_status VARCHAR(24) NULL,
manual_reason VARCHAR(1000) NULL,
last_error_code VARCHAR(64) NULL,
last_error_message VARCHAR(1000) NULL,
remark VARCHAR(1000) NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
id BIGINT UNSIGNED NOT NULL COMMENT '船司官网查询任务ID',
query_no VARCHAR(40) NOT NULL COMMENT '船司官网查询任务编号',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT '指定查询的柜次ID;整票订舱查询时为空',
container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED COMMENT '柜次唯一作用域计算值;整票订舱查询固定为0',
carrier_code VARCHAR(16) NOT NULL COMMENT '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德',
tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号',
tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码',
official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照',
access_mode VARCHAR(32) NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面',
status VARCHAR(24) NOT NULL COMMENT '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果',
window_started_at DATETIME(3) NOT NULL COMMENT '当前三天查询窗口起始时间',
attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当前查询窗口已执行次数;最多3次',
next_allowed_at DATETIME(3) NOT NULL COMMENT '频控规则允许的下次查询时间',
last_attempt_at DATETIME(3) NULL COMMENT '最近一次查询尝试时间',
last_success_at DATETIME(3) NULL COMMENT '最近一次取得有效结果时间',
source_updated_at DATETIME(3) NULL COMMENT '船公司官网展示的结果更新时间',
expires_at DATETIME(3) NULL COMMENT '当前最近有效结果过期时间;到期后仍保留但标记过期',
result_port_schedule_id BIGINT UNSIGNED NULL COMMENT '最近有效结果对应的船期记录ID',
last_attempt_status VARCHAR(24) NULL COMMENT '最近查询尝试状态快照',
manual_reason VARCHAR(1000) NULL COMMENT '需要人工查询或录入的原因',
last_error_code VARCHAR(64) NULL COMMENT '最近查询失败错误码',
last_error_message VARCHAR(1000) NULL COMMENT '最近查询失败错误说明',
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改操作人姓名快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_query_no (query_no),
UNIQUE KEY uk_carrier_schedule_query_scope
@@ -57,21 +57,21 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
) ENGINE=InnoDB COMMENT='六家船公司官网查询任务与三天窗口频控';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt (
id BIGINT UNSIGNED NOT NULL,
query_id BIGINT UNSIGNED NOT NULL,
window_attempt_no INT UNSIGNED NOT NULL,
carrier_code VARCHAR(16) NOT NULL,
access_mode VARCHAR(32) NOT NULL,
official_url VARCHAR(500) NOT NULL,
attempt_status VARCHAR(24) NOT NULL,
coverage_gap TINYINT(1) NOT NULL DEFAULT 0,
coverage_gap_code VARCHAR(64) NULL,
manual_reason VARCHAR(1000) NULL,
error_code VARCHAR(64) NULL,
error_message VARCHAR(1000) NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '船司官网查询尝试ID',
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID',
window_attempt_no INT UNSIGNED NOT NULL COMMENT '当前三天窗口内的尝试序号;取值1至3',
carrier_code VARCHAR(16) NOT NULL COMMENT '本次尝试的船公司编码快照',
access_mode VARCHAR(32) NOT NULL COMMENT '本次尝试的官网访问方式快照',
official_url VARCHAR(500) NOT NULL COMMENT '本次尝试使用的官网地址快照',
attempt_status VARCHAR(24) NOT NULL COMMENT '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败',
coverage_gap TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否存在自动化覆盖缺口:0否、1是',
coverage_gap_code VARCHAR(64) NULL COMMENT '自动化覆盖缺口编码',
manual_reason VARCHAR(1000) NULL COMMENT '转人工处理原因',
error_code VARCHAR(64) NULL COMMENT '查询失败错误码',
error_message VARCHAR(1000) NULL COMMENT '查询失败错误说明',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行本次尝试的操作人ID',
operator_name VARCHAR(64) NULL COMMENT '执行操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '尝试记录创建时间',
PRIMARY KEY (id),
KEY idx_carrier_schedule_attempt_query (query_id,created_at,id),
CONSTRAINT fk_carrier_schedule_attempt_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
@@ -81,17 +81,17 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_attempt (
) ENGINE=InnoDB COMMENT='船司官网查询尝试不可变审计';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
id BIGINT UNSIGNED NOT NULL,
query_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(24) NOT NULL,
from_version INT UNSIGNED NULL,
to_version INT UNSIGNED NOT NULL,
reason VARCHAR(1000) NULL,
before_snapshot JSON NULL,
after_snapshot JSON NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '船司查询任务历史ID',
query_id BIGINT UNSIGNED NOT NULL COMMENT '所属船司官网查询任务ID',
action VARCHAR(24) NOT NULL COMMENT '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果',
from_version INT UNSIGNED NULL COMMENT '变更前任务版本;创建时为空',
to_version INT UNSIGNED NOT NULL COMMENT '变更后任务版本',
reason VARCHAR(1000) NULL COMMENT '操作原因或说明',
before_snapshot JSON NULL COMMENT '变更前任务数据快照;创建时为空',
after_snapshot JSON NOT NULL COMMENT '变更后任务数据快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间',
PRIMARY KEY (id),
KEY idx_carrier_schedule_history_query (query_id,created_at,id),
CONSTRAINT fk_carrier_schedule_history_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id),
@@ -99,16 +99,16 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history (
) ENGINE=InnoDB COMMENT='船司查询任务不可变历史';
CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation (
id BIGINT UNSIGNED NOT NULL,
query_id BIGINT UNSIGNED NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(24) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '船司查询写操作幂等记录ID',
query_id BIGINT UNSIGNED NULL COMMENT '关联查询任务ID;创建任务落库前允许为空',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起写操作的平台操作人ID',
operation VARCHAR(24) NOT NULL COMMENT '幂等写操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功',
result_json JSON NULL COMMENT '成功结果JSON快照;供幂等重放返回',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间',
completed_at DATETIME(3) NULL COMMENT '写操作成功完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_carrier_schedule_operation (operator_id,idempotency_key),
KEY idx_carrier_schedule_operation_query (query_id,created_at,id),
@@ -2,17 +2,17 @@ SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_customs_checklist_version (
id BIGINT UNSIGNED NOT NULL,
checklist_code VARCHAR(40) NOT NULL,
version_no VARCHAR(32) NOT NULL,
country_code VARCHAR(32) NOT NULL,
channel_code VARCHAR(40) NOT NULL,
cargo_type VARCHAR(40) NOT NULL,
items_json JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '报关资料清单版本逻辑ID',
checklist_code VARCHAR(40) NOT NULL COMMENT '报关资料清单编码;同一清单的多个版本共用',
version_no VARCHAR(32) NOT NULL COMMENT '清单业务版本号',
country_code VARCHAR(32) NOT NULL COMMENT '清单适用的目的国家或地区编码',
channel_code VARCHAR(40) NOT NULL COMMENT '清单适用的运输渠道编码',
cargo_type VARCHAR(40) NOT NULL COMMENT '清单适用的货物类型编码',
items_json JSON NOT NULL COMMENT '不可变清单项目数组;元素包含code项目编码、name项目名称和required是否必填',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '清单版本状态:ACTIVE启用、RETIRED停用',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建清单版本的平台操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建清单版本时的平台操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清单版本创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_customs_checklist_version (checklist_code, version_no),
KEY idx_customs_checklist_match (country_code, channel_code, cargo_type, status),
@@ -20,24 +20,24 @@ CREATE TABLE IF NOT EXISTS fl_customs_checklist_version (
) ENGINE=InnoDB COMMENT='三期报关资料清单不可变版本';
CREATE TABLE IF NOT EXISTS fl_customs_case (
id BIGINT UNSIGNED NOT NULL,
customs_no VARCHAR(40) NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
checklist_version_id BIGINT UNSIGNED NOT NULL,
checklist_code VARCHAR(40) NOT NULL,
checklist_version_no VARCHAR(32) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'DOCUMENTS_PENDING',
remark VARCHAR(500) NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
id BIGINT UNSIGNED NOT NULL COMMENT '报关协同单逻辑ID',
customs_no VARCHAR(40) NOT NULL COMMENT '全局唯一报关协同单号',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order服务订单逻辑ID;跨库不建立外键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT 'Firstleg入库单逻辑ID',
container_booking_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '关联柜次逻辑ID;0表示创建时尚未关联柜次',
checklist_version_id BIGINT UNSIGNED NOT NULL COMMENT '本单冻结使用的报关资料清单版本ID',
checklist_code VARCHAR(40) NOT NULL COMMENT '创建协同单时的清单编码快照;源清单变化不回写',
checklist_version_no VARCHAR(32) NOT NULL COMMENT '创建协同单时的清单版本号快照;源清单变化不回写',
status VARCHAR(32) NOT NULL DEFAULT 'DOCUMENTS_PENDING' COMMENT '协同状态:DOCUMENTS_PENDING待补资料、REVIEWING审核中、SUPPLEMENT_REQUIRED需补件、READY_TO_SUBMIT待提交代理、RECEIPT_PENDING待回执、RECEIPT_AVAILABLE已有回执、CLEARANCE_COMPLETED已放行、CANCELLED已取消',
remark VARCHAR(500) NULL COMMENT '报关协同单内部业务备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建协同单的平台操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建协同单时的平台操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '协同单创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改协同单的平台操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改时的平台操作人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '协同单最后修改时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号;每次状态变更递增',
PRIMARY KEY (id),
UNIQUE KEY uk_customs_no (customs_no),
UNIQUE KEY uk_customs_case_scope (tenant_id, order_id, inbound_id, container_booking_id),
@@ -48,23 +48,23 @@ CREATE TABLE IF NOT EXISTS fl_customs_case (
) ENGINE=InnoDB COMMENT='三期订单报关协同聚合';
CREATE TABLE IF NOT EXISTS fl_customs_document (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
checklist_item_code VARCHAR(40) NOT NULL,
file_id BIGINT UNSIGNED NOT NULL,
document_version INT UNSIGNED NOT NULL,
replaces_document_id BIGINT UNSIGNED NULL,
file_name VARCHAR(255) NOT NULL,
file_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'UPLOADED',
visibility VARCHAR(16) NOT NULL DEFAULT 'CUSTOMER',
uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_by_type VARCHAR(16) NOT NULL,
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
reviewed_by BIGINT UNSIGNED NULL,
reviewed_by_name VARCHAR(64) NULL,
reviewed_at DATETIME(3) NULL,
review_reason VARCHAR(500) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '报关资料文件版本逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
checklist_item_code VARCHAR(40) NOT NULL COMMENT '对应冻结清单中的资料项目编码',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID;跨库不建立外键',
document_version INT UNSIGNED NOT NULL COMMENT '同一协同单和清单项目下的文件版本序号',
replaces_document_id BIGINT UNSIGNED NULL COMMENT '被本版本替换的上一资料版本ID;首个版本为空',
file_name VARCHAR(255) NOT NULL COMMENT '上传或引用时的文件名称快照',
file_hash CHAR(64) NOT NULL COMMENT '文件内容SHA-256摘要;用于版本审计和幂等校验',
status VARCHAR(16) NOT NULL DEFAULT 'UPLOADED' COMMENT '资料状态:UPLOADED待审核、APPROVED审核通过、RETURNED已退回、SUPERSEDED已被替换、FROZEN已随代理提交批次冻结',
visibility VARCHAR(16) NOT NULL DEFAULT 'CUSTOMER' COMMENT '资料可见范围:PLATFORM仅平台可见、CUSTOMER客户可见',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或引用资料的操作人ID',
uploaded_by_type VARCHAR(16) NOT NULL COMMENT '上传主体类型:PLATFORM平台操作人、MERCHANT客户操作人',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '资料上传或引用时间',
reviewed_by BIGINT UNSIGNED NULL COMMENT '审核资料的平台操作人ID;未审核时为空',
reviewed_by_name VARCHAR(64) NULL COMMENT '审核时的平台操作人名称快照',
reviewed_at DATETIME(3) NULL COMMENT '资料审核时间',
review_reason VARCHAR(500) NULL COMMENT '审核退回原因或审核备注',
PRIMARY KEY (id),
UNIQUE KEY uk_customs_document_version (customs_case_id, checklist_item_code, document_version),
UNIQUE KEY uk_customs_document_file (customs_case_id, file_id),
@@ -76,19 +76,19 @@ CREATE TABLE IF NOT EXISTS fl_customs_document (
) ENGINE=InnoDB COMMENT='报关客户文件版本引用';
CREATE TABLE IF NOT EXISTS fl_customs_supplement_request (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
source_document_id BIGINT UNSIGNED NOT NULL,
resolved_document_id BIGINT UNSIGNED NULL,
checklist_item_code VARCHAR(40) NOT NULL,
reason VARCHAR(500) NOT NULL,
due_at DATETIME(3) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'OPEN',
requested_by BIGINT UNSIGNED NOT NULL,
requested_by_name VARCHAR(64) NULL,
requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
resolved_by BIGINT UNSIGNED NULL,
resolved_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '报关补件请求逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
source_document_id BIGINT UNSIGNED NOT NULL COMMENT '触发本次补件的被退回资料版本ID',
resolved_document_id BIGINT UNSIGNED NULL COMMENT '解决本次补件的新资料版本ID;未解决时为空',
checklist_item_code VARCHAR(40) NOT NULL COMMENT '需要补充的报关资料项目编码',
reason VARCHAR(500) NOT NULL COMMENT '平台审核人员填写的补件原因',
due_at DATETIME(3) NULL COMMENT '期望完成补件时间;未设置时为空',
status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '补件状态:OPEN待补件、RESOLVED已由新资料解决、CANCELLED已取消',
requested_by BIGINT UNSIGNED NOT NULL COMMENT '发起补件的平台操作人ID',
requested_by_name VARCHAR(64) NULL COMMENT '发起补件时的平台操作人名称快照',
requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '补件请求发起时间',
resolved_by BIGINT UNSIGNED NULL COMMENT '提交解决资料的操作人ID;未解决时为空',
resolved_at DATETIME(3) NULL COMMENT '补件请求解决时间',
PRIMARY KEY (id),
KEY idx_customs_supplement_case (customs_case_id, status, due_at),
CONSTRAINT fk_customs_supplement_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
@@ -96,18 +96,18 @@ CREATE TABLE IF NOT EXISTS fl_customs_supplement_request (
) ENGINE=InnoDB COMMENT='报关补件请求';
CREATE TABLE IF NOT EXISTS fl_customs_submission_batch (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
batch_no VARCHAR(40) NOT NULL,
batch_version INT UNSIGNED NOT NULL,
customs_agent_name VARCHAR(128) NOT NULL,
external_reference VARCHAR(128) NULL,
file_snapshot_json JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED',
note VARCHAR(500) NULL,
submitted_by BIGINT UNSIGNED NOT NULL,
submitted_by_name VARCHAR(64) NULL,
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '报关代理提交批次逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
batch_no VARCHAR(40) NOT NULL COMMENT '全局唯一代理提交批次号',
batch_version INT UNSIGNED NOT NULL COMMENT '同一报关协同单内的提交批次版本序号',
customs_agent_name VARCHAR(128) NOT NULL COMMENT '本批次接收报关资料的报关代理名称快照',
external_reference VARCHAR(128) NULL COMMENT '报关代理或外部系统返回的业务参考号',
file_snapshot_json JSON NOT NULL COMMENT '提交时冻结的已审核资料数组;元素包含documentId、fileId、itemCode、documentVersion和fileHash',
status VARCHAR(16) NOT NULL DEFAULT 'SUBMITTED' COMMENT '代理提交状态:SUBMITTED已提交、ACKNOWLEDGED代理已确认、REJECTED代理已拒绝、COMPLETED代理处理完成',
note VARCHAR(500) NULL COMMENT '提交报关代理时的平台备注',
submitted_by BIGINT UNSIGNED NOT NULL COMMENT '提交报关代理的平台操作人ID',
submitted_by_name VARCHAR(64) NULL COMMENT '提交时的平台操作人名称快照',
submitted_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交报关代理时间',
PRIMARY KEY (id),
UNIQUE KEY uk_customs_batch_no (batch_no),
UNIQUE KEY uk_customs_batch_version (customs_case_id, batch_version),
@@ -116,20 +116,20 @@ CREATE TABLE IF NOT EXISTS fl_customs_submission_batch (
) ENGINE=InnoDB COMMENT='报关代理提交批次及冻结文件版本';
CREATE TABLE IF NOT EXISTS fl_customs_receipt (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
submission_batch_id BIGINT UNSIGNED NOT NULL,
receipt_type VARCHAR(16) NOT NULL,
file_id BIGINT UNSIGNED NOT NULL,
receipt_version INT UNSIGNED NOT NULL,
replaces_receipt_id BIGINT UNSIGNED NULL,
file_name VARCHAR(255) NOT NULL,
file_hash CHAR(64) NOT NULL,
visibility VARCHAR(16) NOT NULL DEFAULT 'PLATFORM',
note VARCHAR(500) NULL,
uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_by_name VARCHAR(64) NULL,
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '报关回执文件版本逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
submission_batch_id BIGINT UNSIGNED NOT NULL COMMENT '产生本回执的报关代理提交批次ID',
receipt_type VARCHAR(16) NOT NULL COMMENT '回执类型:RECEIVED已收件、DECLARED已申报、REJECTED已拒绝、INSPECTION查验通知、RELEASED放行回执、OTHER其他回执',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务回执文件逻辑ID;跨库不建立外键',
receipt_version INT UNSIGNED NOT NULL COMMENT '同一提交批次和回执类型下的版本序号',
replaces_receipt_id BIGINT UNSIGNED NULL COMMENT '被本版本替换的上一回执版本ID;首个版本为空',
file_name VARCHAR(255) NOT NULL COMMENT '上传或引用时的回执文件名称快照',
file_hash CHAR(64) NOT NULL COMMENT '回执文件内容SHA-256摘要;用于版本审计和幂等校验',
visibility VARCHAR(16) NOT NULL DEFAULT 'PLATFORM' COMMENT '回执可见范围:PLATFORM仅平台可见、CUSTOMER客户可查询下载',
note VARCHAR(500) NULL COMMENT '回执业务备注',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传或引用回执的平台操作人ID',
uploaded_by_name VARCHAR(64) NULL COMMENT '上传回执时的平台操作人名称快照',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '回执上传或引用时间',
PRIMARY KEY (id),
UNIQUE KEY uk_customs_receipt_version (submission_batch_id, receipt_type, receipt_version),
UNIQUE KEY uk_customs_receipt_file (submission_batch_id, file_id),
@@ -141,18 +141,18 @@ CREATE TABLE IF NOT EXISTS fl_customs_receipt (
) ENGINE=InnoDB COMMENT='报关代理回执版本与客户可见范围';
CREATE TABLE IF NOT EXISTS fl_customs_history (
id BIGINT UNSIGNED NOT NULL,
customs_case_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(40) NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NULL,
resource_type VARCHAR(32) NOT NULL,
resource_id BIGINT UNSIGNED NOT NULL,
note VARCHAR(500) NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
operator_type VARCHAR(16) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '报关协同历史逻辑ID',
customs_case_id BIGINT UNSIGNED NOT NULL COMMENT '所属报关协同单ID',
action VARCHAR(40) NOT NULL COMMENT '业务动作编码,如CREATE、UPLOAD_DOCUMENT、REPLACE_DOCUMENT、REVIEW_DOCUMENT_APPROVE、REVIEW_DOCUMENT_RETURN、SUBMIT_AGENT_BATCH或UPLOAD_RECEIPT',
from_status VARCHAR(32) NULL COMMENT '动作发生前的协同状态;创建动作时为空',
to_status VARCHAR(32) NULL COMMENT '动作完成后的协同状态',
resource_type VARCHAR(32) NOT NULL COMMENT '动作关联资源类型:CASE协同单、DOCUMENT资料、SUBMISSION_BATCH提交批次或RECEIPT回执',
resource_id BIGINT UNSIGNED NOT NULL COMMENT '动作关联资源逻辑ID',
note VARCHAR(500) NULL COMMENT '动作原因、资料项目编码或业务备注快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行业务动作的操作人ID',
operator_name VARCHAR(64) NULL COMMENT '执行动作时的操作人名称快照',
operator_type VARCHAR(16) NOT NULL COMMENT '操作主体类型:PLATFORM平台操作人、MERCHANT客户操作人',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间',
PRIMARY KEY (id),
KEY idx_customs_history_case (customs_case_id, created_at, id),
CONSTRAINT fk_customs_history_case FOREIGN KEY (customs_case_id) REFERENCES fl_customs_case(id),
@@ -160,16 +160,16 @@ CREATE TABLE IF NOT EXISTS fl_customs_history (
) ENGINE=InnoDB COMMENT='报关协同稳定历史';
CREATE TABLE IF NOT EXISTS fl_customs_operation (
id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_type VARCHAR(24) NULL,
result_id BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '报关写命令幂等记录逻辑ID',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起写命令的操作人ID;与动作和幂等键共同限定重放范围',
action VARCHAR(64) NOT NULL COMMENT '幂等业务动作编码,包含平台或客户动作及资料、审核、提交、回执类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端提供的幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化完整请求内容的SHA-256摘要;同键不同请求时拒绝',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS处理成功',
result_type VARCHAR(24) NULL COMMENT '成功结果资源类型:CHECKLIST、CASE、DOCUMENT、BATCH或RECEIPT',
result_id BIGINT UNSIGNED NULL COMMENT '成功结果资源逻辑ID;处理中为空',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间',
completed_at DATETIME(3) NULL COMMENT '写命令成功完成时间;处理中为空',
PRIMARY KEY (id),
UNIQUE KEY uk_customs_operation (operator_id, action, idempotency_key),
CONSTRAINT chk_customs_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
@@ -169,19 +169,19 @@ DROP PROCEDURE IF EXISTS phase3_inbound_add_index;
CREATE TABLE IF NOT EXISTS fl_inbound_split_batch (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
operation_id VARCHAR(64) NOT NULL COMMENT '业务操作幂等号',
idempotency_key VARCHAR(128) NOT NULL COMMENT '请求幂等键',
expected_parent_version INT NOT NULL COMMENT '操作时主单乐观锁版本',
package_count INT UNSIGNED NOT NULL COMMENT '本批次拆分箱数',
status VARCHAR(16) NOT NULL DEFAULT 'PREPARED' COMMENT 'PREPARED、APPLIED、CANCELLED',
status VARCHAR(16) NOT NULL DEFAULT 'PREPARED' COMMENT '拆分批次状态:PREPARED已准备、APPLIED已生效、CANCELLED已取消',
remark VARCHAR(500) NULL COMMENT '拆分原因或备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拆分批次创建时间',
applied_at DATETIME(3) NULL COMMENT '批次生效时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_operation (tenant_id, operation_id),
UNIQUE KEY uk_inbound_split_idempotency (tenant_id, parent_inbound_id, idempotency_key),
@@ -193,7 +193,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_batch (
CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入仓单ID',
child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID',
@@ -202,7 +202,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
source_measurement_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量批次ID',
source_measurement_item_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量箱规明细ID',
package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拆分数量台账创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no),
UNIQUE KEY uk_inbound_split_batch_source_child (tenant_id, split_batch_id, source_measurement_item_id, child_inbound_id),
@@ -217,16 +217,16 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item (
CREATE TABLE IF NOT EXISTS fl_inbound_split_history (
id BIGINT UNSIGNED NOT NULL COMMENT '拆分历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
split_batch_id BIGINT UNSIGNED NOT NULL COMMENT '拆分批次ID',
operation_id VARCHAR(64) NOT NULL COMMENT '历史操作幂等号',
action_type VARCHAR(24) NOT NULL COMMENT 'CREATE、APPLY、CANCEL',
action_type VARCHAR(24) NOT NULL COMMENT '拆分动作:CREATE创建批次、APPLY生效、CANCEL取消',
before_snapshot JSON NULL COMMENT '操作前快照',
after_snapshot JSON NOT NULL COMMENT '操作后快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人名称快照',
operation_reason VARCHAR(500) NULL COMMENT '操作原因',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变拆分历史创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_history_operation (tenant_id, operation_id),
KEY idx_inbound_split_history (tenant_id, split_batch_id, created_at, id),
@@ -3,26 +3,26 @@ USE baiyue_firstleg;
/* 装柜证据、PDA 拣货复核和派送完成。五类证据按有效记录计数,同类型允许多图追加;作废记录保留审计。 */
CREATE TABLE IF NOT EXISTS fl_loading_evidence (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '装柜证据记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '关联入库单逻辑ID',
evidence_type VARCHAR(32) NOT NULL COMMENT 'PHOTO_STEP_1空仓/PHOTO_STEP_2半仓/PHOTO_STEP_3满仓/PHOTO_STEP_4锁仓/SEAL带锁码照',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File 服务逻辑文件ID',
file_name VARCHAR(255) NULL,
seal_no VARCHAR(64) NULL,
remark VARCHAR(1000) NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT 'ACTIVE/VOID,作废不硬删除',
void_reason VARCHAR(500) NULL,
voided_by BIGINT UNSIGNED NULL,
voided_by_name VARCHAR(64) NULL,
voided_at DATETIME(3) NULL,
version INT NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
file_name VARCHAR(255) NULL COMMENT '上传时文件名称快照',
seal_no VARCHAR(64) NULL COMMENT '锁码文本;带锁码照必填,客户不可见',
remark VARCHAR(1000) NULL COMMENT '证据补充说明',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID已作废;错误照片保留记录不硬删除',
void_reason VARCHAR(500) NULL COMMENT '照片作废原因',
voided_by BIGINT UNSIGNED NULL COMMENT '执行作废的平台操作人ID',
voided_by_name VARCHAR(64) NULL COMMENT '执行作废的平台操作人姓名快照',
voided_at DATETIME(3) NULL COMMENT '照片作废时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
created_by BIGINT UNSIGNED NOT NULL COMMENT '上传证据的操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '上传证据的操作人姓名快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '证据记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '证据记录最后更新时间',
PRIMARY KEY (id),
KEY idx_loading_evidence_type (tenant_id, inbound_id, evidence_type, status),
KEY idx_loading_evidence_seal (tenant_id, seal_no, status),
@@ -46,11 +46,11 @@ BEGIN
END IF;
END$$
DELIMITER ;
CALL phase3_loading_add_column('status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''ACTIVE/VOID,作废不硬删除''');
CALL phase3_loading_add_column('void_reason', 'VARCHAR(500) NULL');
CALL phase3_loading_add_column('voided_by', 'BIGINT UNSIGNED NULL');
CALL phase3_loading_add_column('voided_by_name', 'VARCHAR(64) NULL');
CALL phase3_loading_add_column('voided_at', 'DATETIME(3) NULL');
CALL phase3_loading_add_column('status', 'VARCHAR(16) NOT NULL DEFAULT ''ACTIVE'' COMMENT ''证据状态:ACTIVE有效、VOID已作废;错误照片保留记录不硬删除''');
CALL phase3_loading_add_column('void_reason', 'VARCHAR(500) NULL COMMENT ''照片作废原因''');
CALL phase3_loading_add_column('voided_by', 'BIGINT UNSIGNED NULL COMMENT ''执行作废的平台操作人ID''');
CALL phase3_loading_add_column('voided_by_name', 'VARCHAR(64) NULL COMMENT ''执行作废的平台操作人姓名快照''');
CALL phase3_loading_add_column('voided_at', 'DATETIME(3) NULL COMMENT ''照片作废时间''');
DROP PROCEDURE IF EXISTS phase3_loading_add_column;
SET @phase3_loading_has_legacy_type_index = (
@@ -94,17 +94,17 @@ EXECUTE phase3_loading_seal_stmt;
DEALLOCATE PREPARE phase3_loading_seal_stmt;
CREATE TABLE IF NOT EXISTS fl_loading_operation (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(48) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '装柜写操作记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '操作目标入库单逻辑ID',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行写操作的平台用户ID',
operation VARCHAR(48) NOT NULL COMMENT '装柜或证据写操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键,同一操作重试复用',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功、FAILED失败',
result_json JSON NULL COMMENT '成功结果或失败信息JSON快照,用于幂等重放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作声明创建时间',
completed_at DATETIME(3) NULL COMMENT '操作完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_operation (tenant_id, inbound_id, operator_id, operation, idempotency_key),
KEY idx_loading_operation_time (tenant_id, inbound_id, created_at),
@@ -112,18 +112,18 @@ CREATE TABLE IF NOT EXISTS fl_loading_operation (
) ENGINE=InnoDB COMMENT='装柜与派送写操作幂等记录';
CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/IN_PROGRESS/COMPLETED',
version INT NOT NULL DEFAULT 0,
expected_count INT UNSIGNED NULL,
picked_count INT UNSIGNED NULL,
remark VARCHAR(1000) NULL,
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '入库单级PDA拣货任务ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '关联入库单逻辑ID',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:PENDING待拣货、IN_PROGRESS拣货中、COMPLETED已完成',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
expected_count INT UNSIGNED NULL COMMENT '计划应拣箱数',
picked_count INT UNSIGNED NULL COMMENT '实际已拣箱数',
remark VARCHAR(1000) NULL COMMENT '拣货结果或异常补充说明',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货任务创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货任务最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id, inbound_id),
KEY idx_loading_pick_status (tenant_id, status, updated_at),
@@ -10,9 +10,9 @@ CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link (
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '柜次级装柜计划ID',
idempotency_key VARCHAR(128) NOT NULL COMMENT '建立关联幂等键',
request_hash CHAR(64) NOT NULL COMMENT '建立关联请求SHA-256',
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_by BIGINT UNSIGNED NOT NULL COMMENT '建立预装与装柜计划关联的操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '建立关联的操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关联记录创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_preload_loading_pair (tenant_id, inbound_id, loading_plan_id),
UNIQUE KEY uk_preload_loading_idempotency (created_by, idempotency_key),
@@ -2,42 +2,42 @@ SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_ocean_booking (
id BIGINT UNSIGNED NOT NULL,
booking_no VARCHAR(40) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '当前产品客户ID',
customer_name VARCHAR(128) NOT NULL,
bill_of_lading_no VARCHAR(128) NULL,
shipping_company_id BIGINT UNSIGNED NULL,
shipping_company_name VARCHAR(128) NULL,
booking_agent_id BIGINT UNSIGNED NULL,
booking_agent_name VARCHAR(128) NULL,
vessel_name VARCHAR(128) NULL,
voyage_no VARCHAR(64) NULL,
departure_port_code VARCHAR(32) NULL,
departure_port_name VARCHAR(128) NULL,
destination_port_code VARCHAR(32) NULL,
destination_port_name VARCHAR(128) NULL,
place_of_delivery VARCHAR(128) NULL,
service_term VARCHAR(32) NULL,
freight_term VARCHAR(32) NULL,
estimated_sailing_at DATETIME(3) NULL,
estimated_arrival_at DATETIME(3) NULL,
pending_vessel_name VARCHAR(128) NULL,
pending_voyage_no VARCHAR(64) NULL,
pending_estimated_sailing_at DATETIME(3) NULL,
pending_estimated_arrival_at DATETIME(3) NULL,
change_reason VARCHAR(1000) NULL,
failure_reason VARCHAR(1000) NULL,
cancellation_reason VARCHAR(1000) NULL,
remark VARCHAR(1000) NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID',
booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
customer_name VARCHAR(128) NOT NULL COMMENT '客户名称快照',
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照',
shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照',
booking_agent_id BIGINT UNSIGNED NULL COMMENT '订舱代理逻辑ID快照',
booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照',
vessel_name VARCHAR(128) NULL COMMENT '当前确认船名快照',
voyage_no VARCHAR(64) NULL COMMENT '当前确认航次号快照',
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
departure_port_name VARCHAR(128) NULL COMMENT '起运港名称快照',
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照',
service_term VARCHAR(32) NULL COMMENT '运输服务条款快照',
freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照',
estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
pending_vessel_name VARCHAR(128) NULL COMMENT '待确认变更船名',
pending_voyage_no VARCHAR(64) NULL COMMENT '待确认变更航次号',
pending_estimated_sailing_at DATETIME(3) NULL COMMENT '待确认变更预计开船时间',
pending_estimated_arrival_at DATETIME(3) NULL COMMENT '待确认变更预计到港时间',
change_reason VARCHAR(1000) NULL COMMENT '船期变更原因',
failure_reason VARCHAR(1000) NULL COMMENT '订舱失败原因',
cancellation_reason VARCHAR(1000) NULL COMMENT '取消订舱原因',
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改操作人姓名快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '订舱记录最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_no (booking_no),
KEY idx_ocean_booking_customer (customer_id,status,updated_at,id),
@@ -53,13 +53,13 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
party_type VARCHAR(24) NOT NULL COMMENT 'SHIPPER/CONSIGNEE/NOTIFY_PARTY',
party_name VARCHAR(255) NOT NULL,
address VARCHAR(1000) NULL,
contact_name VARCHAR(128) NULL,
contact_phone VARCHAR(64) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '订舱提单关系方ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
party_type VARCHAR(24) NOT NULL COMMENT '关系方类型:SHIPPER发货人、CONSIGNEE收货人、NOTIFY_PARTY通知人',
party_name VARCHAR(255) NOT NULL COMMENT '关系方名称快照',
address VARCHAR(1000) NULL COMMENT '关系方地址快照',
contact_name VARCHAR(128) NULL COMMENT '关系方联系人快照',
contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type),
CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
@@ -67,12 +67,12 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party (
) ENGINE=InnoDB COMMENT='订舱提单关系方快照';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
container_type VARCHAR(32) NOT NULL,
container_kind VARCHAR(32) NOT NULL,
quantity INT UNSIGNED NOT NULL,
sort_no INT UNSIGNED NOT NULL DEFAULT 0,
id BIGINT UNSIGNED NOT NULL COMMENT '订舱箱型需求ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
container_type VARCHAR(32) NOT NULL COMMENT '所需集装箱尺寸类型编码',
container_kind VARCHAR(32) NOT NULL COMMENT '所需集装箱箱种编码',
quantity INT UNSIGNED NOT NULL COMMENT '该箱型箱种所需柜数',
sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序',
PRIMARY KEY (id),
KEY idx_ocean_requirement_booking (ocean_booking_id,sort_no,id),
CONSTRAINT fk_ocean_requirement_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id),
@@ -80,13 +80,13 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
) ENGINE=InnoDB COMMENT='订舱箱型箱种及数量需求';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
requirement_id BIGINT UNSIGNED NOT NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '订舱柜次分配关系ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
requirement_id BIGINT UNSIGNED NOT NULL COMMENT '对应订舱箱型需求ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID',
allocated_by BIGINT UNSIGNED NOT NULL,
allocated_by_name VARCHAR(64) NULL,
allocated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
allocated_by BIGINT UNSIGNED NOT NULL COMMENT '分配操作人ID',
allocated_by_name VARCHAR(64) NULL COMMENT '分配操作人姓名快照',
allocated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '柜次分配时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_allocation_container (container_booking_id),
KEY idx_ocean_allocation_booking (ocean_booking_id,requirement_id,allocated_at),
@@ -97,33 +97,33 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation (
) ENGINE=InnoDB COMMENT='订舱需求与现有柜次的唯一分配关系';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(24) NULL,
to_status VARCHAR(24) NOT NULL,
action VARCHAR(40) NOT NULL,
reason VARCHAR(1000) NULL,
before_snapshot JSON NULL,
after_snapshot JSON NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '订舱状态历史ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID',
from_status VARCHAR(24) NULL COMMENT '变更前订舱状态;创建时为空',
to_status VARCHAR(24) NOT NULL COMMENT '变更后订舱状态',
action VARCHAR(40) NOT NULL COMMENT '触发状态变更的业务动作',
reason VARCHAR(1000) NULL COMMENT '状态变更原因',
before_snapshot JSON NULL COMMENT '状态变更前订舱数据快照;创建时为空',
after_snapshot JSON NOT NULL COMMENT '状态变更后订舱数据快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间',
PRIMARY KEY (id),
KEY idx_ocean_history_booking (ocean_booking_id,created_at,id),
CONSTRAINT fk_ocean_history_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id)
) ENGINE=InnoDB COMMENT='海运订舱不可变状态历史';
CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation (
id BIGINT UNSIGNED NOT NULL,
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '创建请求完成前允许为空',
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(40) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '订舱写操作幂等记录ID',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '关联海运订舱ID;创建请求完成前允许为空',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起写操作的平台操作人ID',
operation VARCHAR(40) NOT NULL COMMENT '幂等写操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功',
result_json JSON NULL COMMENT '成功结果JSON快照;供幂等重放返回',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间',
completed_at DATETIME(3) NULL COMMENT '写操作成功完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key),
KEY idx_ocean_operation_booking (ocean_booking_id,created_at),
@@ -2,22 +2,22 @@
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_outbound_dispatch (
id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '订单引用,不复制订单主数据',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单主对象',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '柜次引用',
status VARCHAR(32) NOT NULL DEFAULT 'READY_FOR_DISPATCH',
version INT UNSIGNED NOT NULL DEFAULT 0,
confirmed_by BIGINT UNSIGNED NULL,
confirmed_by_name VARCHAR(64) NULL,
confirmed_at DATETIME(3) NULL,
reversed_by BIGINT UNSIGNED NULL,
reversed_by_name VARCHAR(64) NULL,
reversed_at DATETIME(3) NULL,
remark VARCHAR(500) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '国内出仓调度记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
order_id BIGINT UNSIGNED NOT NULL COMMENT 'Order服务订单逻辑ID,不复制订单主数据',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '关联入库单逻辑ID',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联柜次逻辑ID',
status VARCHAR(32) NOT NULL DEFAULT 'READY_FOR_DISPATCH' COMMENT '出仓状态:READY_FOR_DISPATCH待确认出仓、WAITING_FOR_LOADING等待装柜',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认国内出仓的操作人ID',
confirmed_by_name VARCHAR(64) NULL COMMENT '确认国内出仓的操作人姓名快照',
confirmed_at DATETIME(3) NULL COMMENT '国内出仓确认时间',
reversed_by BIGINT UNSIGNED NULL COMMENT '撤销国内出仓的操作人ID',
reversed_by_name VARCHAR(64) NULL COMMENT '撤销国内出仓的操作人姓名快照',
reversed_at DATETIME(3) NULL COMMENT '国内出仓撤销时间',
remark VARCHAR(500) NULL COMMENT '国内出仓调度备注',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '出仓调度记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '出仓调度记录最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_outbound_dispatch_owner (tenant_id,inbound_id,container_booking_id),
KEY idx_outbound_dispatch_order (tenant_id,order_id,updated_at),
@@ -28,33 +28,33 @@ CREATE TABLE IF NOT EXISTS fl_outbound_dispatch (
) ENGINE=InnoDB COMMENT='三期国内出仓派车状态,不重复创建出仓单';
CREATE TABLE IF NOT EXISTS fl_outbound_dispatch_history (
id BIGINT UNSIGNED NOT NULL,
dispatch_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(32) NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NOT NULL,
remark VARCHAR(500) NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '出仓状态历史记录ID',
dispatch_id BIGINT UNSIGNED NOT NULL COMMENT '关联国内出仓调度记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '关联入库单逻辑ID快照',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联柜次逻辑ID快照',
action VARCHAR(32) NOT NULL COMMENT '出仓状态变更动作',
from_status VARCHAR(32) NULL COMMENT '变更前出仓状态;首次创建时为空',
to_status VARCHAR(32) NOT NULL COMMENT '变更后出仓状态',
remark VARCHAR(500) NULL COMMENT '状态变更原因或备注',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行状态变更的平台用户ID',
operator_name VARCHAR(64) NULL COMMENT '执行状态变更的平台用户姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间',
PRIMARY KEY (id),
KEY idx_outbound_dispatch_history (tenant_id,inbound_id,container_booking_id,created_at,id),
CONSTRAINT fk_outbound_dispatch_history FOREIGN KEY (dispatch_id) REFERENCES fl_outbound_dispatch(id)
) ENGINE=InnoDB COMMENT='国内出仓追加式审计历史';
CREATE TABLE IF NOT EXISTS fl_outbound_dispatch_operation (
id BIGINT UNSIGNED NOT NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '出仓幂等操作记录ID',
dispatch_id BIGINT UNSIGNED NULL COMMENT '首次声明幂等键时允许为空',
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(32) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行写操作的平台用户ID',
operation VARCHAR(32) NOT NULL COMMENT '国内出仓写操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键,同一操作重试复用',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等声明创建时间',
completed_at DATETIME(3) NULL COMMENT '幂等操作完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_outbound_dispatch_operation (operator_id,operation,idempotency_key),
KEY idx_outbound_dispatch_operation_resource (dispatch_id,created_at),
@@ -2,30 +2,46 @@ SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment (
pick_task_id BIGINT UNSIGNED NOT NULL,
assignee_id BIGINT UNSIGNED NOT NULL,
assignee_name VARCHAR(64) NOT NULL,
assigned_by BIGINT UNSIGNED NOT NULL,
assigned_by_name VARCHAR(64) NULL,
assignment_type VARCHAR(16) NOT NULL COMMENT 'CLAIMED/ASSIGNED',
version INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT '柜次PDA拣货任务ID;同一任务仅保留一名作业员',
assignee_id BIGINT UNSIGNED NOT NULL COMMENT '当前唯一作业员ID',
assignee_name VARCHAR(64) NOT NULL COMMENT '当前作业员姓名快照',
assigned_by BIGINT UNSIGNED NOT NULL COMMENT '领取任务的作业员或执行分配的平台用户ID',
assigned_by_name VARCHAR(64) NULL COMMENT '领取或分配操作人姓名快照',
assignment_type VARCHAR(16) NOT NULL COMMENT '分配方式:CLAIMED作业员领取、ASSIGNED平台指派',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,防止多人并发领取或覆盖',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务领取或分配时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '作业员分配最后更新时间',
PRIMARY KEY (pick_task_id),
KEY idx_loading_pick_assignee (assignee_id, updated_at),
CONSTRAINT fk_loading_pick_assignment_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
CONSTRAINT chk_loading_pick_assignment_type CHECK (assignment_type IN ('CLAIMED', 'ASSIGNED'))
) ENGINE=InnoDB COMMENT='柜次PDA任务领取与作业员分配';
CREATE TABLE IF NOT EXISTS fl_loading_pick_item_assignment (
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID,同时作为本表主键',
assignee_id BIGINT UNSIGNED NOT NULL COMMENT '当前领取或被分配的作业员ID',
assignee_name VARCHAR(64) NOT NULL COMMENT '当前作业员姓名快照',
assigned_by BIGINT UNSIGNED NOT NULL COMMENT '执行领取或分配操作的用户ID',
assigned_by_name VARCHAR(64) NULL COMMENT '执行领取或分配操作的用户姓名快照',
assignment_type VARCHAR(16) NOT NULL COMMENT '分配类型:CLAIMED作业员领取、ASSIGNED管理员分配',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '明细分配记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '明细分配记录最后更新时间',
PRIMARY KEY (pick_item_id),
KEY idx_loading_pick_item_assignee (assignee_id, updated_at),
CONSTRAINT fk_loading_pick_item_assignment_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id),
CONSTRAINT chk_loading_pick_item_assignment_type CHECK (assignment_type IN ('CLAIMED', 'ASSIGNED'))
) ENGINE=InnoDB COMMENT='PDA拣货明细领取与作业员分配';
CREATE TABLE IF NOT EXISTS fl_loading_pick_review (
pick_task_id BIGINT UNSIGNED NOT NULL,
review_status VARCHAR(16) NOT NULL DEFAULT 'APPROVED',
reviewed_by BIGINT UNSIGNED NOT NULL,
reviewed_by_name VARCHAR(64) NULL,
remark VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT '已完成的柜次PDA拣货任务ID',
review_status VARCHAR(16) NOT NULL DEFAULT 'APPROVED' COMMENT '复核状态:APPROVED已通过;记录创建后不可修改或删除',
reviewed_by BIGINT UNSIGNED NOT NULL COMMENT '执行完成复核的平台用户ID',
reviewed_by_name VARCHAR(64) NULL COMMENT '复核人姓名快照',
remark VARCHAR(500) NULL COMMENT 'PDA任务复核备注',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '复核记录版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '复核通过时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '复核记录最后更新时间;正常业务不允许更新',
PRIMARY KEY (pick_task_id),
KEY idx_loading_pick_reviewer (reviewed_by, created_at),
CONSTRAINT fk_loading_pick_review_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id),
@@ -33,14 +49,14 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_review (
) ENGINE=InnoDB COMMENT='柜次PDA任务完成复核';
CREATE TABLE IF NOT EXISTS fl_loading_pick_operation (
id BIGINT UNSIGNED NOT NULL,
pick_task_id BIGINT UNSIGNED NOT NULL,
operation_id VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
action_type VARCHAR(16) NOT NULL COMMENT 'CLAIM/ASSIGN/REVIEW',
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA任务操作审计记录ID',
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT '关联柜次PDA拣货任务ID',
operation_id VARCHAR(128) NOT NULL COMMENT '全局唯一操作ID,用于幂等重放',
request_hash CHAR(64) NOT NULL COMMENT '完整批量请求或任务操作内容SHA-256摘要',
action_type VARCHAR(16) NOT NULL COMMENT '动作类型:CLAIM领取、ASSIGN分配、REVIEW复核、BATCH批量提交',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行PDA任务操作的用户ID',
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变操作审计创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_operation_id (operation_id),
KEY idx_loading_pick_operation_task (pick_task_id, created_at),
@@ -2,35 +2,35 @@ SET NAMES utf8mb4;
USE baiyue_firstleg;
CREATE TABLE IF NOT EXISTS fl_port_schedule (
id BIGINT UNSIGNED NOT NULL,
schedule_no VARCHAR(40) NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL COMMENT '当前产品客户ID',
ocean_booking_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NULL,
source_type VARCHAR(24) NOT NULL COMMENT 'MANUAL/CARRIER/TERMINAL/API',
source_name VARCHAR(128) NOT NULL,
source_ref VARCHAR(255) NOT NULL,
vessel_name VARCHAR(128) NOT NULL,
voyage_no VARCHAR(64) NOT NULL,
departure_port_code VARCHAR(32) NOT NULL,
departure_port_name VARCHAR(128) NULL,
destination_port_code VARCHAR(32) NOT NULL,
destination_port_name VARCHAR(128) NULL,
etd_at DATETIME(3) NULL,
eta_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '港务船期记录ID',
schedule_no VARCHAR(40) NOT NULL COMMENT '港务船期记录编号',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT '关联柜次ID;订舱整票船期时为空',
source_type VARCHAR(24) NOT NULL COMMENT '船期来源类型:MANUAL人工录入、CARRIER船公司、TERMINAL码头、API外部接口',
source_name VARCHAR(128) NOT NULL COMMENT '船期来源名称',
source_ref VARCHAR(255) NOT NULL COMMENT '来源侧记录唯一引用或查询凭据摘要',
vessel_name VARCHAR(128) NOT NULL COMMENT '船名快照',
voyage_no VARCHAR(64) NOT NULL COMMENT '航次号快照',
departure_port_code VARCHAR(32) NOT NULL COMMENT '起运港编码快照',
departure_port_name VARCHAR(128) NULL COMMENT '起运港名称快照',
destination_port_code VARCHAR(32) NOT NULL COMMENT '目的港编码快照',
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
etd_at DATETIME(3) NULL COMMENT '预计离港时间',
eta_at DATETIME(3) NULL COMMENT '预计到港时间',
booking_cutoff_at DATETIME(3) NULL COMMENT '截单时间',
customs_cutoff_at DATETIME(3) NULL COMMENT '截关时间',
port_cutoff_at DATETIME(3) NULL COMMENT '截港时间',
observed_at DATETIME(3) NOT NULL COMMENT '来源数据观测时间',
expires_at DATETIME(3) NOT NULL COMMENT '超过该时间即由API计算为过期',
remark VARCHAR(1000) NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
remark VARCHAR(1000) NULL COMMENT '平台内部备注',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '船期记录创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改操作人姓名快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '船期记录最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_port_schedule_no (schedule_no),
UNIQUE KEY uk_port_schedule_source (source_type,source_name,source_ref),
@@ -53,17 +53,17 @@ CREATE TABLE IF NOT EXISTS fl_port_schedule (
) ENGINE=InnoDB COMMENT='独立港务船期来源聚合,不替代订舱快照';
CREATE TABLE IF NOT EXISTS fl_port_schedule_history (
id BIGINT UNSIGNED NOT NULL,
port_schedule_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(24) NOT NULL,
from_version INT UNSIGNED NULL,
to_version INT UNSIGNED NOT NULL,
reason VARCHAR(1000) NULL,
before_snapshot JSON NULL,
after_snapshot JSON NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL COMMENT '港务船期变更历史ID',
port_schedule_id BIGINT UNSIGNED NOT NULL COMMENT '所属港务船期记录ID',
action VARCHAR(24) NOT NULL COMMENT '变更动作:CREATE创建、UPDATE更新',
from_version INT UNSIGNED NULL COMMENT '变更前版本;创建时为空',
to_version INT UNSIGNED NOT NULL COMMENT '变更后版本',
reason VARCHAR(1000) NULL COMMENT '变更原因或说明',
before_snapshot JSON NULL COMMENT '变更前船期数据快照;创建时为空',
after_snapshot JSON NOT NULL COMMENT '变更后船期数据快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间',
PRIMARY KEY (id),
KEY idx_port_schedule_history (port_schedule_id,created_at,id),
CONSTRAINT fk_port_schedule_history FOREIGN KEY (port_schedule_id) REFERENCES fl_port_schedule(id),
@@ -71,16 +71,16 @@ CREATE TABLE IF NOT EXISTS fl_port_schedule_history (
) ENGINE=InnoDB COMMENT='港务船期不可变变更历史';
CREATE TABLE IF NOT EXISTS fl_port_schedule_operation (
id BIGINT UNSIGNED NOT NULL,
port_schedule_id BIGINT UNSIGNED NULL COMMENT '创建请求完成前允许为空',
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(24) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL COMMENT '港务船期写操作幂等记录ID',
port_schedule_id BIGINT UNSIGNED NULL COMMENT '关联港务船期ID;创建请求完成前允许为空',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起写操作的平台操作人ID',
operation VARCHAR(24) NOT NULL COMMENT '幂等写操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功',
result_json JSON NULL COMMENT '成功结果JSON快照;供幂等重放返回',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间',
completed_at DATETIME(3) NULL COMMENT '写操作成功完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_port_schedule_operation (operator_id,idempotency_key),
KEY idx_port_schedule_operation_resource (port_schedule_id,created_at),
@@ -60,8 +60,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no),
@@ -77,8 +76,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)',
plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING'
COMMENT '唯一装柜状态:EDITING、LOADING、COMPLETED',
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认',
push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键',
preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID',
preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照',
@@ -95,8 +93,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜计划最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_plan_no (plan_no),
@@ -149,16 +146,13 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
removed_by_name VARCHAR(64) NULL COMMENT '推送前逻辑删除操作人名称快照',
removed_at DATETIME(3) NULL COMMENT '推送前逻辑删除时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '推送前逻辑删除标记;冻结后不可修改',
active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (
CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END
) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格',
active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_item_plan_detail (loading_plan_id, active_inbound_detail_id),
@@ -185,15 +179,14 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
CREATE TABLE IF NOT EXISTS fl_container_document (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
document_type VARCHAR(32) NOT NULL COMMENT 'CUSTOMS_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER',
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:CUSTOMS_CLEARANCE清关文件、DELIVERY_ORDER提货单、PICKUP_ORDER取货单',
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
file_url VARCHAR(1000) NULL COMMENT '文件访问地址快照',
uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
COMMENT '更新时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜文件关联最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_container_document_type (container_booking_id, document_type),
@@ -209,8 +202,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID',
loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID',
operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID',
action_type VARCHAR(32) NOT NULL
COMMENT 'CREATE_PLAN、ADD_ITEM、ADJUST_ITEM、REMOVE_ITEM、START_LOADING、COMPLETE_LOADING、PICK_ITEM',
action_type VARCHAR(32) NOT NULL COMMENT '动作类型:CREATE_PLAN创建计划、ADD_ITEM新增明细、ADJUST_ITEM调整明细、REMOVE_ITEM移除明细、START_LOADING开始装柜、COMPLETE_LOADING完成装柜、PICK_ITEM拣货',
from_status VARCHAR(24) NULL COMMENT '变更前状态',
to_status VARCHAR(24) NULL COMMENT '变更后状态',
before_snapshot JSON NULL COMMENT '变更前快照',
@@ -405,7 +397,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
container_no VARCHAR(64) NOT NULL COMMENT '集装箱号快照',
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'LOADING、COMPLETED',
task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成',
started_by BIGINT UNSIGNED NOT NULL COMMENT '开始装柜人',
started_by_name VARCHAR(64) NULL COMMENT '开始装柜人名称',
started_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间',
@@ -428,12 +420,12 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item (
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '订单货物明细ID',
planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整',
picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数',
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING、PICKING、PICKED、PARTIAL、SKIPPED',
item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '拣货明细状态:PENDING待拣、PICKING拣货中、PICKED已拣完、PARTIAL部分拣货、SKIPPED跳拣',
release_mode VARCHAR(10) NULL COMMENT 'AUTO按比例释放,MANUAL按人工选择释放',
release_location_ids JSON NULL COMMENT '人工选择释放库位ID;空数组表示明确释放0个',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_item_plan_item (loading_plan_item_id),
KEY idx_loading_pick_item_task (pick_task_id, item_status, id),
@@ -449,13 +441,13 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history (
pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
pick_item_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID',
operation_id VARCHAR(128) NOT NULL COMMENT '客户端幂等操作号',
request_hash CHAR(64) NOT NULL COMMENT '请求指纹',
action_type VARCHAR(24) NOT NULL DEFAULT 'PICK' COMMENT 'PICK、PARTIAL、SKIP',
request_hash CHAR(64) NOT NULL COMMENT '完整批量请求内容SHA-256摘要',
action_type VARCHAR(24) NOT NULL DEFAULT 'PICK' COMMENT '拣货动作:PICK正常拣货、PARTIAL部分拣货、SKIP跳拣',
picked_package_count INT UNSIGNED NOT NULL COMMENT '本次拣货箱数',
operation_reason VARCHAR(500) NULL COMMENT '部分拣货或跳拣原因',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人',
operator_name VARCHAR(64) NULL COMMENT '操作人名称',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行PDA拣货操作的作业员ID',
operator_name VARCHAR(64) NULL COMMENT '作业员姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变拣货操作历史创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_loading_pick_history_operation (operation_id),
KEY idx_loading_pick_history_item (pick_item_id, created_at),
@@ -4,18 +4,18 @@ USE baiyue_firstleg;
/* 三期分柜与预装清单;独立于现有测量、计费表,可重复部署。 */
CREATE TABLE IF NOT EXISTS fl_preload_plan (
id BIGINT UNSIGNED NOT NULL COMMENT '预装清单ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单/订单ID',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/CONFIRMING/CONFIRMED',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
items_json JSON NOT NULL COMMENT '可编辑分柜明细快照',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '关联入库单逻辑ID',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '预装状态:PENDING待确认、CONFIRMING确认中、CONFIRMED已确认',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
items_json JSON NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据',
remark VARCHAR(1000) NULL COMMENT '预装备注',
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
updated_by BIGINT UNSIGNED NOT NULL,
updated_by_name VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建预装清单的操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
updated_by_name VARCHAR(64) NULL COMMENT '最后修改操作人姓名快照',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '预装清单创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '预装清单最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_preload_tenant_inbound (tenant_id, inbound_id),
KEY idx_preload_status (tenant_id, status, updated_at),
@@ -24,14 +24,14 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan (
CREATE TABLE IF NOT EXISTS fl_split_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '幂等记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
inbound_id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(40) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING/SUCCESS',
result_json JSON NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
inbound_id BIGINT UNSIGNED NOT NULL COMMENT '操作目标入库单逻辑ID',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行分柜或预装写操作的平台用户ID',
operation VARCHAR(40) NOT NULL COMMENT '分柜或预装写操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端幂等键,同一操作重试复用',
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功',
result_json JSON NULL COMMENT '操作结果JSON快照,用于幂等重放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等操作声明创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_split_operation (tenant_id, inbound_id, operator_id, operation, idempotency_key),
KEY idx_split_operation_time (tenant_id, inbound_id, created_at),
@@ -96,25 +96,23 @@ DEALLOCATE PREPARE phase3_split_stmt;
CREATE TABLE IF NOT EXISTS fl_inbound_split_detail (
id BIGINT UNSIGNED NOT NULL COMMENT '部分锁定明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
parent_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID',
child_inbound_id BIGINT UNSIGNED NULL COMMENT '完成拆单后生成的二级子入库单ID',
inbound_detail_id BIGINT UNSIGNED NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键',
source_package_count INT UNSIGNED NOT NULL COMMENT '拆分前该明细总箱数快照',
locked_package_count INT UNSIGNED NOT NULL COMMENT '本次装柜锁定箱数',
remaining_package_count INT UNSIGNED GENERATED ALWAYS AS (
source_package_count - locked_package_count
) STORED COMMENT '生成二级子单的剩余箱数',
status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT 'LOCKED/COMPLETED',
remaining_package_count INT UNSIGNED GENERATED ALWAYS AS (source_package_count - locked_package_count) STORED COMMENT '锁定后剩余箱数,由来源箱数减锁定箱数生成',
status VARCHAR(16) NOT NULL DEFAULT 'LOCKED' COMMENT '分柜明细状态:LOCKED已锁定、COMPLETED已完成拆分',
operation_id VARCHAR(128) NOT NULL COMMENT '部分锁定操作幂等号',
created_by BIGINT UNSIGNED NOT NULL,
created_by_name VARCHAR(64) NULL,
completed_by BIGINT UNSIGNED NULL,
completed_by_name VARCHAR(64) NULL,
completed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建部分锁定明细的操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
completed_by BIGINT UNSIGNED NULL COMMENT '完成拆分的操作人ID',
completed_by_name VARCHAR(64) NULL COMMENT '完成操作人姓名快照',
completed_at DATETIME(3) NULL COMMENT '部分拆分完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '部分锁定明细创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '部分锁定明细最后更新时间',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
PRIMARY KEY (id),
UNIQUE KEY uk_inbound_split_parent_detail (tenant_id, parent_inbound_id, inbound_detail_id),
UNIQUE KEY uk_inbound_split_child_detail (tenant_id, child_inbound_id, inbound_detail_id),
@@ -1,33 +1,64 @@
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS fl_truck_appointment (
id BIGINT UNSIGNED NOT NULL, appointment_no VARCHAR(40) NOT NULL, outbound_order_id BIGINT UNSIGNED NULL,
ocean_booking_id BIGINT UNSIGNED NULL, container_booking_id BIGINT UNSIGNED NULL, loading_plan_id BIGINT UNSIGNED NULL,
warehouse_id BIGINT UNSIGNED NULL, customer_id BIGINT UNSIGNED NULL, customer_name VARCHAR(128) NULL,
carrier_id BIGINT UNSIGNED NULL, carrier_name VARCHAR(128) NULL, fleet_contact VARCHAR(64) NULL, fleet_phone VARCHAR(32) NULL,
driver_name VARCHAR(64) NULL, driver_phone VARCHAR(32) NULL, driver_id_card VARCHAR(32) NULL, vehicle_no VARCHAR(32) NULL,
store_name VARCHAR(128) NULL, store_contact VARCHAR(64) NULL, store_phone VARCHAR(32) NULL, store_address VARCHAR(255) NULL,
store_city VARCHAR(64) NULL, store_zipcode VARCHAR(16) NULL, package_count INT UNSIGNED NULL, gross_weight_kg DECIMAL(18,3) NULL,
volume_cbm DECIMAL(18,4) NULL, pallet_count INT UNSIGNED NULL, po_no VARCHAR(255) NULL, planned_loading_at DATETIME(3) NULL,
actual_arrived_warehouse_at DATETIME(3) NULL, actual_loading_at DATETIME(3) NULL, actual_departed_at DATETIME(3) NULL, transit_minutes INT UNSIGNED NULL, payable_amount DECIMAL(18,2) NULL, receivable_amount DECIMAL(18,2) NULL,
currency VARCHAR(8) NOT NULL DEFAULT 'RMB', status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', revision INT UNSIGNED NOT NULL DEFAULT 0,
version INT UNSIGNED NOT NULL DEFAULT 0, remark VARCHAR(1000) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no),
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约ID',
appointment_no VARCHAR(40) NOT NULL COMMENT '平台拖车预约编号',
outbound_order_id BIGINT UNSIGNED NULL COMMENT '关联头程订单逻辑ID;不跨库建外键',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '关联海运订舱逻辑ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT '关联柜次逻辑ID;拖车完成供装柜门禁校验',
loading_plan_id BIGINT UNSIGNED NULL COMMENT '关联装柜计划逻辑ID',
warehouse_id BIGINT UNSIGNED NULL COMMENT '预约到仓的国内收货仓逻辑ID',
customer_id BIGINT UNSIGNED NULL COMMENT '客户ID和数据隔离键',
customer_name VARCHAR(128) NULL COMMENT '客户名称快照',
carrier_id BIGINT UNSIGNED NULL COMMENT '拖车服务商逻辑ID',
carrier_name VARCHAR(128) NULL COMMENT '拖车服务商名称快照',
fleet_contact VARCHAR(64) NULL COMMENT '车队联系人',
fleet_phone VARCHAR(32) NULL COMMENT '车队联系电话',
driver_name VARCHAR(64) NULL COMMENT '司机姓名',
driver_phone VARCHAR(32) NULL COMMENT '司机联系电话',
driver_id_card VARCHAR(32) NULL COMMENT '司机身份证号;平台内部敏感信息',
vehicle_no VARCHAR(32) NULL COMMENT '车辆车牌号',
store_name VARCHAR(128) NULL COMMENT '到仓装柜地点名称快照',
store_contact VARCHAR(64) NULL COMMENT '到仓地点联系人快照',
store_phone VARCHAR(32) NULL COMMENT '到仓地点联系电话快照',
store_address VARCHAR(255) NULL COMMENT '到仓装柜地点地址快照',
store_city VARCHAR(64) NULL COMMENT '到仓地点城市快照',
store_zipcode VARCHAR(16) NULL COMMENT '到仓地点邮编快照',
package_count INT UNSIGNED NULL COMMENT '预约货物包装件数快照',
gross_weight_kg DECIMAL(18,3) NULL COMMENT '预约货物毛重,单位千克',
volume_cbm DECIMAL(18,4) NULL COMMENT '预约货物体积,单位立方米',
pallet_count INT UNSIGNED NULL COMMENT '预约托盘数量',
po_no VARCHAR(255) NULL COMMENT '关联采购单号或客户PO号',
planned_loading_at DATETIME(3) NULL COMMENT '预约到仓装柜时间',
actual_arrived_warehouse_at DATETIME(3) NULL COMMENT '车辆实际到达收货仓时间',
actual_loading_at DATETIME(3) NULL COMMENT '实际开始装柜时间',
actual_departed_at DATETIME(3) NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用',
transit_minutes INT UNSIGNED NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟',
payable_amount DECIMAL(18,2) NULL COMMENT '应付拖车服务商金额;币种由currency指定',
receivable_amount DECIMAL(18,2) NULL COMMENT '向客户收取的拖车金额;币种由currency指定',
currency VARCHAR(8) NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB',
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消',
revision INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '拖车预约业务修订号;每次状态或改约递增',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
remark VARCHAR(1000) NULL COMMENT '平台内部备注或异常说明',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间',
PRIMARY KEY(id), UNIQUE KEY uk_truck_appointment_no(appointment_no),
KEY idx_truck_appointment_status(status,updated_at), KEY idx_truck_appointment_container(container_booking_id), KEY idx_truck_appointment_customer(customer_id),
CONSTRAINT chk_truck_appointment_status CHECK(status IN ('DRAFT','PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED','COMPLETED','RESCHEDULED','EXCEPTION','CANCELLED'))
) ENGINE=InnoDB COMMENT='三期拖车预约派车聚合';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_history (
id BIGINT UNSIGNED NOT NULL, appointment_id BIGINT UNSIGNED NOT NULL, revision INT UNSIGNED NOT NULL, action VARCHAR(32) NOT NULL,
from_status VARCHAR(32) NULL, to_status VARCHAR(32) NULL, reason VARCHAR(500) NULL, operator_id BIGINT UNSIGNED NOT NULL,
operator_name VARCHAR(64) NULL, request_id VARCHAR(128) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), KEY idx_truck_history(appointment_id,revision)
id BIGINT UNSIGNED NOT NULL COMMENT '拖车预约历史ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', revision INT UNSIGNED NOT NULL COMMENT '该预约业务修订号', action VARCHAR(32) NOT NULL COMMENT '触发状态或改约的业务动作',
from_status VARCHAR(32) NULL COMMENT '变更前拖车状态;创建时为空', to_status VARCHAR(32) NULL COMMENT '变更后拖车状态;改约时可保持原状态', reason VARCHAR(500) NULL COMMENT '状态或改约原因', operator_id BIGINT UNSIGNED NOT NULL COMMENT '执行操作的平台用户ID',
operator_name VARCHAR(64) NULL COMMENT '操作人姓名快照', request_id VARCHAR(128) NULL COMMENT '请求链路中的请求ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', PRIMARY KEY(id), KEY idx_truck_history(appointment_id,revision)
) ENGINE=InnoDB COMMENT='拖车状态与改约历史';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_attachment (
id BIGINT UNSIGNED NOT NULL, appointment_id BIGINT UNSIGNED NOT NULL, file_id BIGINT UNSIGNED NOT NULL, attachment_type VARCHAR(32) NOT NULL,
file_name VARCHAR(255) NULL, file_hash CHAR(64) NULL, visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL', uploaded_by BIGINT UNSIGNED NOT NULL,
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id)
id BIGINT UNSIGNED NOT NULL COMMENT '拖车附件关系ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件逻辑ID', attachment_type VARCHAR(32) NOT NULL COMMENT '附件类型编码;由业务动作约定',
file_name VARCHAR(255) NULL COMMENT '上传时文件名快照', file_hash CHAR(64) NULL COMMENT '文件内容SHA-256摘要', visibility VARCHAR(24) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围:INTERNAL仅平台内部、CLIENT客户可见', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID',
uploaded_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_attachment(appointment_id,file_id), KEY idx_truck_attachment(appointment_id)
) ENGINE=InnoDB COMMENT='拖车附件文件引用';
CREATE TABLE IF NOT EXISTS fl_truck_appointment_operation (
id BIGINT UNSIGNED NOT NULL, appointment_id BIGINT UNSIGNED NOT NULL, idempotency_key VARCHAR(128) NOT NULL, request_hash CHAR(64) NOT NULL,
action VARCHAR(32) NOT NULL, response_snapshot JSON NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY(id), UNIQUE KEY uk_truck_operation(idempotency_key), KEY idx_truck_operation_appointment(appointment_id)
id BIGINT UNSIGNED NOT NULL COMMENT '拖车写操作幂等记录ID', appointment_id BIGINT UNSIGNED NOT NULL COMMENT '所属拖车预约ID', idempotency_key VARCHAR(128) NOT NULL COMMENT '客户端写操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求',
action VARCHAR(32) NOT NULL COMMENT '执行的拖车业务动作', response_snapshot JSON NULL COMMENT '成功响应JSON快照;供相同幂等请求重放', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', PRIMARY KEY(id), UNIQUE KEY uk_truck_operation(idempotency_key), KEY idx_truck_operation_appointment(appointment_id)
) ENGINE=InnoDB COMMENT='拖车操作幂等记录';
DROP PROCEDURE IF EXISTS phase3_truck_add_column;
@@ -54,11 +85,11 @@ DELIMITER ;
CALL phase3_truck_add_column(
'actual_arrived_warehouse_at',
'DATETIME(3) NULL AFTER planned_loading_at'
'DATETIME(3) NULL COMMENT ''车辆实际到达收货仓时间'' AFTER planned_loading_at'
);
CALL phase3_truck_add_column(
'actual_departed_at',
'DATETIME(3) NULL AFTER actual_loading_at'
'DATETIME(3) NULL COMMENT ''车辆实际离仓时间;DEPARTED后供LOADED门禁使用'' AFTER actual_loading_at'
);
DROP PROCEDURE IF EXISTS phase3_truck_add_column;
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.client;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceFeeSourceEventClientContractTest {
@Test
void usesFrozenFinanceInboxPathAndIdempotencyHeaders() {
FeignClient client = FinanceFeeSourceEventClient.class.getAnnotation(FeignClient.class);
assertThat(client.name()).isEqualTo("finance-service");
assertThat(client.path()).isEqualTo("/internal/finance/v1/fee-source-events");
var method = FinanceFeeSourceEventClient.class.getDeclaredMethods()[0];
assertThat(Arrays.stream(method.getParameterAnnotations())
.flatMap(Arrays::stream)
.filter(RequestHeader.class::isInstance)
.map(RequestHeader.class::cast)
.map(RequestHeader::value))
.contains("X-Internal-Service-Token", "Idempotency-Key");
}
}
@@ -0,0 +1,54 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class InternalFinanceFeeSourceControllerTest {
private final FinanceFeeSourceSnapshotService service = mock(FinanceFeeSourceSnapshotService.class);
private final InternalFinanceFeeSourceController controller = controller();
@Test
void rejectsMissingOrWrongInternalTokenBeforeSnapshotLookup() {
assertThatThrownBy(() -> controller.inbound(null, 21L, 31L))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
assertThatThrownBy(() -> controller.container("wrong", 41L, 51L))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
}
@Test
void routesAuthorizedSnapshotLookup() {
controller.inbound("finance-token", 21L, 31L);
controller.container("finance-token", 41L, 51L);
verify(service).inbound(21L, 31L);
verify(service).container(41L, 51L);
}
@Test
void exposesFrozenInternalPaths() {
var mapping = InternalFinanceFeeSourceController.class
.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
assertThat(mapping.value()).containsExactly("/internal/firstleg/v1");
assertThat(methodPath("inbound")).isEqualTo("/inbounds/{inboundId}/fees/{feeId}/source-snapshot");
assertThat(methodPath("container")).isEqualTo("/containers/{containerId}/fees/{feeId}/source-snapshot");
}
private InternalFinanceFeeSourceController controller() {
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service);
ReflectionTestUtils.setField(value, "expectedToken", "finance-token");
return value;
}
private static String methodPath(String name) {
return java.util.Arrays.stream(InternalFinanceFeeSourceController.class.getDeclaredMethods())
.filter(method -> method.getName().equals(name)).findFirst().orElseThrow()
.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()[0];
}
}
@@ -8,6 +8,8 @@ import org.apache.ibatis.annotations.Select;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
@@ -18,6 +20,9 @@ class PdaConvergenceSqlContractTest {
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item_assignment")
.contains("assignment_type VARCHAR(16) NOT NULL COMMENT '分配类型:CLAIMED作业员领取、ASSIGNED管理员分配'")
.contains("CONSTRAINT fk_loading_pick_item_assignment_item")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation")
.contains("UNIQUE KEY uk_loading_pick_operation_id")
@@ -30,6 +35,11 @@ class PdaConvergenceSqlContractTest {
.contains("platform:firstleg:pda:assign")
.contains("platform:firstleg:pda:work")
.contains("platform:firstleg:pda:review");
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(createBlock(unified, "fl_loading_pick_item_assignment"))
.isEqualTo(createBlock(sql, "fl_loading_pick_item_assignment"));
}
@Test
@@ -51,4 +61,13 @@ class PdaConvergenceSqlContractTest {
.contains("ON DUPLICATE KEY UPDATE")
.contains("柜次PDA自动汇总");
}
private static String createBlock(String sql, String table) {
String marker = "CREATE TABLE IF NOT EXISTS " + table + " (";
int start = sql.indexOf(marker);
int end = sql.indexOf(";", start);
assertThat(start).as("missing table %s", table).isGreaterThanOrEqualTo(0);
assertThat(end).as("missing table terminator %s", table).isGreaterThan(start);
return sql.substring(start, end + 1).replace("\r\n", "\n");
}
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceFeeSourceMigrationContractTest {
@Test
void migrationDefinesIdempotentOutboxEnvelopeAndContainerFeeModel() throws Exception {
String sql = new ClassPathResource("db/finance-fee-source-migration.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql).contains("information_schema.columns", "event_key", "payload_hash",
"uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by",
"idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail",
"settlement_included", "source_version", "RECEIVABLE", "PAYABLE",
"chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema");
}
}
@@ -5,11 +5,21 @@ import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
class Phase3FinalUnifiedSqlContractTest {
private static final Path UNIFIED = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
private static final Pattern CREATE_TABLE = Pattern.compile(
"(?i)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(?:`[^`]+`\\.)?`?([a-zA-Z0-9_]+)`?\\s*\\(");
private static final Pattern COLUMN = Pattern.compile(
"(?i)^\\s*`?([a-zA-Z0-9_]+)`?\\s+(bigint|binary|bit|blob|boolean|char|date|datetime|decimal|double|enum|float|int|integer|json|longblob|longtext|mediumblob|mediumint|mediumtext|numeric|real|set|smallint|text|time|timestamp|tinyblob|tinyint|tinytext|varbinary|varchar|year)\\b");
private static final Pattern CHINESE_COMMENT = Pattern.compile(
"(?i)\\bCOMMENT\\s*=?\\s*'(?:(?:''|\\\\'|[^'])*[\\u3400-\\u9fff](?:''|\\\\'|[^'])*)'");
@Test
void unifiedEntryOwnsPhase3OutboundCustomsPortAndEvidenceHardening() throws Exception {
@@ -27,6 +37,10 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail")
.contains("trg_inbound_split_detail_before_insert")
.contains("trg_inbound_split_detail_before_update")
.contains("trg_inbound_split_detail_before_delete")
.contains("CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW', 'BATCH'))")
.contains("phase3_loading_evidence_add_column")
.contains("DROP INDEX uk_loading_evidence_type")
@@ -74,6 +88,86 @@ class Phase3FinalUnifiedSqlContractTest {
"iam/src/main/resources/db/phase3-warehouse-roles.sql");
}
@Test
void financeFeeSourceMigrationsAreContiguousAndSynchronised() throws Exception {
String unified = normalise(Files.readString(resolveUnified()));
assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/finance-fee-source-migration.sql",
"-- 来源:finance/src/main/resources/db/fee-source-event-migration.sql",
"firstleg/src/main/resources/db/finance-fee-source-migration.sql");
assertBlock(unified, "-- 来源:finance/src/main/resources/db/fee-source-event-migration.sql",
"-- END FINANCE FEE SOURCE MODULE MIGRATIONS",
"finance/src/main/resources/db/fee-source-event-migration.sql");
assertThat(unified)
.contains("column_name IN ('source_type','source_no','direction','container_id','fee_item_code',")
.contains("column_name IN ('payload_hash','result_resource_id','error_code')")
.contains("column_name IN ('event_key', 'event_version', 'aggregate_sequence', 'payload_hash',")
.contains("CREATE TABLE IF NOT EXISTS fl_container_fee_detail");
}
@Test
void commentUpgradeIsIdempotentAndGuardsNineSchemaStructure() throws Exception {
String sql = normalise(Files.readString(resolveUnified()));
assertThat(sql)
.contains("CREATE PROCEDURE queue_table_comment")
.contains("CREATE PROCEDURE queue_column_comment")
.contains("CREATE PROCEDURE apply_one_table_comments")
.contains("CREATE PROCEDURE apply_queued_comments")
.contains("NOT (BINARY v_current_comment=BINARY p_column_comment)")
.contains("SET v_signature_before=SHA2")
.contains("BINARY target.signature_before")
.contains("字段注释升级遇到未知数据类型")
.contains("字段注释升级遇到未知EXTRA定义")
.contains("字段注释升级遇到未知生成列结构")
.contains("MySQL DDL 会隐式提交")
.contains("不使用 GROUP_CONCAT")
.contains("DECLARE table_cursor CURSOR FOR")
.contains("CALL apply_one_table_comments(v_schema_name,v_table_name)")
.contains("CALL apply_queued_comments();")
.contains("PREPARE table_comment_batch_stmt FROM @table_comment_batch_sql")
.contains("九个业务数据库未完整创建")
.contains("存在缺失中文注释的业务表")
.contains("存在缺失中文注释的业务字段")
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150);
assertThat(count(sql, "CALL queue_column_comment(")).isEqualTo(2456);
assertThat(sql.indexOf("CALL apply_queued_comments();",
sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")))
.isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"));
}
@Test
void unifiedCreateDefinitionsHaveChineseTableAndColumnComments() throws Exception {
String sql = normalise(Files.readString(resolveUnified()));
List<String> issues = new ArrayList<>();
Matcher tableMatcher = CREATE_TABLE.matcher(sql);
int tableCount = 0;
int columnCount = 0;
while (tableMatcher.find()) {
tableCount++;
String table = tableMatcher.group(1);
int open = tableMatcher.end() - 1;
int close = matchingParen(sql, open);
int semicolon = sql.indexOf(';', close);
assertThat(semicolon).as("missing semicolon for %s", table).isGreaterThan(close);
if (!CHINESE_COMMENT.matcher(sql.substring(close + 1, semicolon)).find()) {
issues.add(table + ": table comment");
}
for (String definition : splitDefinitions(sql.substring(open + 1, close))) {
Matcher columnMatcher = COLUMN.matcher(definition);
if (!columnMatcher.find()) continue;
columnCount++;
if (!CHINESE_COMMENT.matcher(definition).find()) {
issues.add(table + "." + columnMatcher.group(1));
}
}
}
assertThat(tableCount).isGreaterThan(100);
assertThat(columnCount).isGreaterThan(2000);
assertThat(issues).isEmpty();
}
@Test
void passIsUniqueAndOnlyTheFinalExecutableStatement() throws Exception {
String sql = normalise(Files.readString(resolveUnified()));
@@ -153,4 +247,51 @@ class Phase3FinalUnifiedSqlContractTest {
private static int count(String value, String token) {
return value.split(java.util.regex.Pattern.quote(token), -1).length - 1;
}
private static int matchingParen(String sql, int open) {
int depth = 0;
char quote = 0;
for (int index = open; index < sql.length(); index++) {
char current = sql.charAt(index);
if (quote != 0) {
if (current == '\\') index++;
else if (current == quote) {
if (index + 1 < sql.length() && sql.charAt(index + 1) == quote) index++;
else quote = 0;
}
continue;
}
if (current == '\'' || current == '"' || current == '`') quote = current;
else if (current == '(') depth++;
else if (current == ')' && --depth == 0) return index;
}
throw new AssertionError("unclosed CREATE TABLE at " + open);
}
private static List<String> splitDefinitions(String body) {
List<String> definitions = new ArrayList<>();
int start = 0;
int depth = 0;
char quote = 0;
for (int index = 0; index < body.length(); index++) {
char current = body.charAt(index);
if (quote != 0) {
if (current == '\\') index++;
else if (current == quote) {
if (index + 1 < body.length() && body.charAt(index + 1) == quote) index++;
else quote = 0;
}
continue;
}
if (current == '\'' || current == '"' || current == '`') quote = current;
else if (current == '(') depth++;
else if (current == ')') depth--;
else if (current == ',' && depth == 0) {
definitions.add(body.substring(start, index));
start = index + 1;
}
}
definitions.add(body.substring(start));
return definitions;
}
}
@@ -0,0 +1,114 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ContainerFeeDetailServiceTest {
private final ContainerFeeDetailRepository repository = mock(ContainerFeeDetailRepository.class);
private final SmartLoadingRepository loading = mock(SmartLoadingRepository.class);
private final FeeItemRepository feeItems = mock(FeeItemRepository.class);
private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class);
private final ContainerFeeDetailService service =
new ContainerFeeDetailService(repository, loading, feeItems, outbox);
@Test
void rejectsContainerThatDoesNotContainCustomerOrders() {
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(41L);
when(loading.plan(41L)).thenReturn(plan);
LoadingPlanItemEntity other = new LoadingPlanItemEntity();
other.setAccountId(20002L);
when(loading.items(41L)).thenReturn(List.of(other));
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, request(true)))
.isInstanceOf(BizException.class).hasMessageContaining("不属于该客户");
verify(repository, never()).insert(any());
}
@Test
void approvedSettlementFeePublishesContainerSource() {
ownedPlan();
ContainerFeeDetailEntity approved = new ContainerFeeDetailEntity();
approved.setId(51L);
approved.setTenantId(10001L);
approved.setLoadingPlanId(41L);
approved.setSourceVersion(1);
approved.setSettlementIncluded(true);
when(repository.find(10001L, 41L, 51L)).thenReturn(approved);
when(repository.review(10001L, 41L, 51L, "APPROVED", 9L, "admin", null)).thenReturn(1);
service.approve(10001L, 41L, 51L, 9L, "admin");
verify(outbox).publishContainer(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(41L), eq(51L), eq(1), any(LocalDateTime.class));
}
@Test
void adjustmentIncrementsSourceVersionAndReturnsToReview() {
ownedPlan();
FeeItemEntity item = item();
when(feeItems.findItem(11L)).thenReturn(item);
ContainerFeeDetailEntity current = new ContainerFeeDetailEntity();
current.setId(51L);
current.setTenantId(10001L);
current.setLoadingPlanId(41L);
current.setSourceVersion(1);
when(repository.find(10001L, 41L, 51L)).thenReturn(current);
when(repository.adjust(any(), eq(1))).thenReturn(1);
service.adjust(10001L, 41L, 51L, 1, 9L, request(true));
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
verify(repository).adjust(captor.capture(), eq(1));
assertThat(captor.getValue().getSourceVersion()).isEqualTo(2);
assertThat(captor.getValue().getReviewStatus()).isEqualTo("PENDING");
}
private void ownedPlan() {
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(41L);
when(loading.plan(41L)).thenReturn(plan);
LoadingPlanItemEntity item = new LoadingPlanItemEntity();
item.setAccountId(10001L);
when(loading.items(41L)).thenReturn(List.of(item));
}
private static FeeItemEntity item() {
FeeItemEntity value = new FeeItemEntity();
value.setId(11L);
value.setFeeCode("TRUCKING");
value.setFeeTypeName("拖车费");
value.setWarehousePayable(true);
value.setStatus("ACTIVE");
return value;
}
private static ContainerFeeDetailDtos.SaveRequest request(boolean settlementIncluded) {
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
settlementIncluded, "整柜拖车费");
}
}
@@ -42,6 +42,7 @@ import static org.mockito.Mockito.when;
@DisplayName("FBA费用状态机")
@SpringJUnitConfig(classes = {FbaPricingService.class, FbaPricingServiceTest.TestConfig.class})
class FbaPricingServiceTest {
@MockBean private FinanceFeeSourceOutboxService financeOutbox;
private static final long TENANT = 10001L;
private static final long OPERATOR = 20001L;
private static final long INBOUND = 30001L;
@@ -224,7 +225,9 @@ class FbaPricingServiceTest {
assertThat(first.nextProcessStatus()).isEqualTo("WAIT_PALLET");
assertThat(repeated.nextProcessStatus()).isEqualTo("WAIT_PALLET");
verify(repository, times(1)).insertOutbox(any());
verify(financeOutbox, times(1)).publishInbound(
eq(FinanceFeeSourceOutboxService.PUBLISHED), eq(TENANT), eq(INBOUND),
eq(501L), eq(1), eq(NOW));
verify(repository, times(1)).sendFee(TENANT, 501L, OPERATOR, NOW);
verify(feeDetails, times(2)).publishFreight(TENANT, INBOUND, 501L, OPERATOR, null, NOW);
}
@@ -250,7 +253,8 @@ class FbaPricingServiceTest {
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
verify(repository).markInboundFeeSent(TENANT, INBOUND, "INBOUNDED");
verify(repository).insertOutbox(any());
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW));
}
@Test
@@ -300,7 +304,8 @@ class FbaPricingServiceTest {
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
assertThat(inbound.getProcessStatus()).isEqualTo("INBOUNDED");
verify(repository).supersedeSent(TENANT, 501L);
verify(repository).insertOutbox(any());
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED),
eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW));
}
private void readyRepository() {
@@ -27,6 +27,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
@@ -40,6 +41,8 @@ class FeeManagementServiceTest {
private FeeItemRepository repository;
@MockBean
private FbaPricingRepository pricingRepository;
@MockBean
private FinanceFeeSourceOutboxService financeOutbox;
@Resource
private FeeItemService feeItems;
@Resource
@@ -268,6 +271,54 @@ class FeeManagementServiceTest {
verify(repository).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null);
}
@Test
@DisplayName("审核通过且参与结算的人工费用写入Finance事件")
void publishesApprovedSettlementFee() {
InboundFeeDetailEntity approved = new InboundFeeDetailEntity();
approved.setId(31L);
approved.setTenantId(10001L);
approved.setInboundId(21L);
approved.setFeeItemId(11L);
approved.setSourceType("MANUAL");
approved.setSourceFeeVersion(1);
approved.setSettlementIncluded(true);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
details.approve(10001L, 21L, 31L, 9L, "admin");
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
@Test
@DisplayName("作废已入池人工费用引用当前版本并发布VOIDED")
void voidsPublishedSettlementFee() {
InboundFeeDetailEntity current = new InboundFeeDetailEntity();
current.setId(31L);
current.setTenantId(10001L);
current.setInboundId(21L);
current.setFeeItemId(11L);
current.setSourceType("MANUAL");
current.setSourceFeeVersion(1);
current.setSettlementIncluded(true);
InboundFeeDetailEntity voided = new InboundFeeDetailEntity();
voided.setId(31L);
voided.setTenantId(10001L);
voided.setInboundId(21L);
voided.setFeeItemId(11L);
voided.setSourceType("MANUAL");
voided.setSourceFeeVersion(1);
voided.setSettlementIncluded(true);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(current, voided);
when(repository.voidApproved(10001L, 21L, 31L, 1, 9L)).thenReturn(1);
details.voidApproved(10001L, 21L, 31L, 1, 9L);
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.VOIDED),
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
private static FeeItemDtos.SaveRequest itemRequest() {
return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", "仓库租赁费",
false, false, true, true, true, "应付仓库租赁费");
@@ -0,0 +1,135 @@
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.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.repository.FinanceFeeSourceOutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FinanceFeeSourceOutboxRelayTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 11, 15, 0);
private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
private final FinanceFeeSourceOutboxRepository repository = mock(FinanceFeeSourceOutboxRepository.class);
private final FinanceFeeSourceEventClient client = mock(FinanceFeeSourceEventClient.class);
private final FinanceFeeSourceOutboxRelay relay = relay();
@Test
void claimsThenPublishesOnlyAfterFinanceAcknowledgesSameEvent() throws Exception {
InboundOutboxEntity row = event();
ready(row);
FinanceFeeSourceEvent envelope = json.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class);
when(client.receive("finance-token", envelope.idempotencyKey(), envelope)).thenReturn(
ApiResponse.success(new FinanceFeeSourceEventClient.Acknowledgement(
901L)));
when(repository.published(row.getId(), "worker-1", NOW)).thenReturn(true);
relay.relay();
verify(repository).recoverStale(NOW.minusMinutes(5), NOW.plusSeconds(10), 12);
verify(repository).claim(row.getId(), "worker-1", NOW);
verify(client).receive("finance-token", envelope.idempotencyKey(), envelope);
verify(repository).published(row.getId(), "worker-1", NOW);
verify(repository, never()).retry(any(Long.class), any(), any(), any(), eq(false));
}
@Test
void concurrentWorkerThatLosesClaimDoesNotDeliver() {
InboundOutboxEntity row = event();
when(repository.pending(50, NOW)).thenReturn(List.of(row));
when(repository.claim(row.getId(), "worker-1", NOW)).thenReturn(false);
relay.relay();
verify(client, never()).receive(any(), any(), any());
verify(repository, never()).published(any(Long.class), any(), any());
}
@Test
void transientFailureReturnsClaimToPendingWithBackoff() throws Exception {
InboundOutboxEntity row = event();
ready(row);
FinanceFeeSourceEvent envelope = json.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class);
when(client.receive("finance-token", envelope.idempotencyKey(), envelope))
.thenThrow(new IllegalStateException("finance unavailable"));
relay.relay();
verify(repository).retry(row.getId(), "worker-1", NOW.plusSeconds(10),
"finance unavailable", false);
verify(repository, never()).published(any(Long.class), any(), any());
}
@Test
void invalidEventHashFailsPermanentlyWithoutCallingFinance() {
InboundOutboxEntity row = event();
row.setPayloadHash("0".repeat(64));
ready(row);
relay.relay();
verify(repository).retry(eq(row.getId()), eq("worker-1"), eq(NOW.plusSeconds(10)),
any(), eq(true));
verify(client, never()).receive(any(), any(), any());
}
@Test
void financeIdempotencyConflictFailsPermanently() throws Exception {
InboundOutboxEntity row = event();
ready(row);
FinanceFeeSourceEvent envelope = json.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class);
when(client.receive("finance-token", envelope.idempotencyKey(), envelope)).thenReturn(
new ApiResponse<>("FIN-EVT-002", "同一事件ID对应不同请求内容", null, "r", "t", Instant.now()));
relay.relay();
verify(repository).retry(eq(row.getId()), eq("worker-1"), eq(NOW.plusSeconds(10)),
any(), eq(true));
verify(repository, never()).published(any(Long.class), any(), any());
}
private void ready(InboundOutboxEntity row) {
when(repository.pending(50, NOW)).thenReturn(List.of(row));
when(repository.claim(row.getId(), "worker-1", NOW)).thenReturn(true);
when(repository.findClaimed(row.getId(), "worker-1")).thenReturn(row);
}
private InboundOutboxEntity event() {
InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
FinanceFeeSourceOutboxService producer = new FinanceFeeSourceOutboxService(mapper, json);
producer.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED,
10001L, 21L, 31L, 1, NOW);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(mapper).insert(captor.capture());
InboundOutboxEntity row = captor.getValue();
row.setId(71L);
return row;
}
private FinanceFeeSourceOutboxRelay relay() {
Clock clock = Clock.fixed(Instant.parse("2026-08-11T07:00:00Z"), ZoneId.of("Asia/Shanghai"));
FinanceFeeSourceOutboxRelay value =
new FinanceFeeSourceOutboxRelay(repository, client, json, clock, "worker-1");
ReflectionTestUtils.setField(value, "internalToken", "finance-token");
return value;
}
}
@@ -0,0 +1,52 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DuplicateKeyException;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class FinanceFeeSourceOutboxServiceTest {
private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
private final FinanceFeeSourceOutboxService service =
new FinanceFeeSourceOutboxService(mapper, objectMapper);
@Test
void writesContractEnvelopeWithShortPayloadAndStableHash() {
LocalDateTime occurredAt = LocalDateTime.of(2026, 8, 11, 12, 0);
service.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED,
10001L, 21L, 31L, 1, occurredAt);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(mapper).insert(captor.capture());
InboundOutboxEntity event = captor.getValue();
assertThat(event.getEventType()).isEqualTo("FIRSTLEG_FEE_PUBLISHED");
assertThat(event.getEventVersion()).isEqualTo(1);
assertThat(event.getAggregateSequence()).isEqualTo(1);
assertThat(event.getPayloadHash()).matches("[0-9a-f]{64}");
assertThat(event.getPayloadJson()).contains("\"producer\":\"firstleg-service\"",
"\"inboundId\":\"21\"", "\"feeId\":\"31\"", "\"feeVersion\":1")
.doesNotContain("amount", "currency");
}
@Test
void treatsDuplicateEventKeyAsSuccessfulReplay() {
doThrow(new DuplicateKeyException("duplicate event_key")).when(mapper)
.insert((InboundOutboxEntity) org.mockito.ArgumentMatchers.any());
assertThatCode(() -> service.publishContainer(FinanceFeeSourceOutboxService.VOIDED,
10001L, 41L, 51L, 2, LocalDateTime.now())).doesNotThrowAnyException();
}
}
@@ -0,0 +1,71 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
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 org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class FinanceFeeSourceSnapshotServiceTest {
private final FeeItemRepository inboundFees = mock(FeeItemRepository.class);
private final ContainerFeeDetailRepository containerFees = mock(ContainerFeeDetailRepository.class);
private final FinanceFeeSourceSnapshotService service =
new FinanceFeeSourceSnapshotService(inboundFees, containerFees);
@Test
void returnsReviewedSettlementSnapshotWithStableSourceHash() {
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
detail.setId(31L);
detail.setTenantId(10001L);
detail.setInboundId(21L);
detail.setFeeItemId(11L);
detail.setSourceType("MANUAL");
detail.setSourceFeeVersion(2);
detail.setDirection("RECEIVABLE");
detail.setAmount(new BigDecimal("100.00"));
detail.setCurrency("CNY");
detail.setReviewStatus("APPROVED");
detail.setPublicationStatus("PUBLISHED");
detail.setSettlementIncluded(true);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(21L);
inbound.setOrderId(81L);
inbound.setInboundCode("RK-21");
inbound.setCustomerName("客户A");
when(inboundFees.findInbound(10001L, 21L)).thenReturn(inbound);
FeeItemEntity item = new FeeItemEntity();
item.setId(11L);
item.setFeeCode("STORAGE");
when(inboundFees.findItem(11L)).thenReturn(item);
FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L);
assertThat(snapshot.customerId()).isEqualTo(10001L);
assertThat(snapshot.orderId()).isEqualTo(81L);
assertThat(snapshot.feeVersion()).isEqualTo(2);
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
detail.setPublicationStatus("VOIDED");
assertThat(service.inbound(21L, 31L).sourceHash()).isEqualTo(snapshot.sourceHash());
}
@Test
void rejectsFeeThatDoesNotBelongToInboundPath() {
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
detail.setInboundId(22L);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
assertThatThrownBy(() -> service.inbound(21L, 31L))
.isInstanceOf(BizException.class).hasMessageContaining("不存在");
}
}
@@ -25,6 +25,7 @@ import static org.mockito.Mockito.when;
@DisplayName("发送费用并入库状态编排")
@SpringJUnitConfig(InboundCompletionOrchestrator.class)
class InboundCompletionOrchestratorTest {
@MockBean private FinanceFeeSourceOutboxService financeOutbox;
@MockBean
private InboundWorkflowRepository repository;
@MockBean
@@ -78,6 +79,11 @@ class InboundCompletionOrchestratorTest {
fixture.order.setProcessStatus("INBOUNDING");
when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(1);
when(fixture.repository.markFeeSent(10001L, 10L, 30L, 9L)).thenReturn(1);
com.baiyue.firstleg.entity.InboundFeeDetailEntity published =
new com.baiyue.firstleg.entity.InboundFeeDetailEntity();
published.setSourceFeeVersion(1);
when(feeDetails.publishFreight(eq(10001L), eq(10L), eq(30L), eq(9L), eq("操作员"),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class))).thenReturn(published);
fixture.service.completeSendFeeAndInbound(
10001L, 9L, "操作员", 10L, 30L, 5, "complete-1");
@@ -86,7 +92,9 @@ class InboundCompletionOrchestratorTest {
verify(fixture.repository).markFeeSent(10001L, 10L, 30L, 9L);
verify(feeDetails).publishFreight(eq(10001L), eq(10L), eq(30L), eq(9L), eq("操作员"),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
verify(fixture.repository).insertOutbox(any());
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(10L), eq(30L), eq(1),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
}
@Test
@@ -102,7 +110,9 @@ class InboundCompletionOrchestratorTest {
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(fixture.repository, never()).markFeeSent(anyLong(), anyLong(), anyLong(), anyLong());
verify(fixture.repository, never()).insertOutbox(any());
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
}
@Test