This commit is contained in:
2026-08-19 09:14:30 +08:00
parent 8845a66749
commit ff1d330c2d
91 changed files with 1538 additions and 523 deletions
@@ -11,6 +11,7 @@ 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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
@@ -27,17 +28,19 @@ public class InternalFinanceFeeSourceController {
@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) {
@PathVariable long inboundId, @PathVariable long feeId,
@RequestParam @jakarta.validation.constraints.Min(1) int sourceVersion) {
authenticate(token);
return ApiResponse.success(service.inbound(inboundId, feeId));
return ApiResponse.success(service.inbound(inboundId, feeId, sourceVersion));
}
@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) {
@PathVariable long containerId, @PathVariable long feeId,
@RequestParam @jakarta.validation.constraints.Min(1) int sourceVersion) {
authenticate(token);
return ApiResponse.success(service.container(containerId, feeId));
return ApiResponse.success(service.container(containerId, feeId, sourceVersion));
}
private void authenticate(String token) {
@@ -20,9 +20,7 @@ public final class ContainerFeeDetailDtos {
@NotNull LocalDate businessDate,
@NotNull @DecimalMin("0.00") BigDecimal amount,
@NotBlank @Pattern(regexp = "CNY") String currency,
@NotNull Boolean settlementIncluded,
Long partnerId,
@Size(max = 200) String partnerName,
Long supplierId,
@Size(max = 500) String remark
) {
}
@@ -14,6 +14,7 @@ public final class FeeItemDtos {
@NotBlank @Size(max = 64) String category,
@NotBlank @Pattern(regexp = "DOMESTIC|INTERNATIONAL") String tradeScope,
@Size(max = 50) @Pattern(regexp = "[A-Za-z0-9_-]{1,50}") String feeCode,
@Pattern(regexp = "BOOKING|CUSTOMS|TRUCKING|CERTIFICATE") String supplierCategory,
@NotBlank @Size(max = 128) String feeTypeName,
@NotNull Boolean pickupPayable,
@NotNull Boolean pickupReceivable,
@@ -22,12 +23,22 @@ public final class FeeItemDtos {
@NotNull Boolean allocationFee,
@Size(max = 500) String description
) {
public SaveRequest(String category, String tradeScope, String feeCode,
String feeTypeName, Boolean pickupPayable,
Boolean pickupReceivable, Boolean warehousePayable,
Boolean warehouseReceivable, Boolean allocationFee,
String description) {
this(category, tradeScope, feeCode, null, feeTypeName, pickupPayable,
pickupReceivable, warehousePayable, warehouseReceivable,
allocationFee, description);
}
/** Backward-compatible constructor for callers predating the stable fee code. */
public SaveRequest(String category, String tradeScope, String feeTypeName,
Boolean pickupPayable, Boolean pickupReceivable,
Boolean warehousePayable, Boolean warehouseReceivable,
Boolean allocationFee, String description) {
this(category, tradeScope, null, feeTypeName, pickupPayable, pickupReceivable,
this(category, tradeScope, null, null, feeTypeName, pickupPayable, pickupReceivable,
warehousePayable, warehouseReceivable, allocationFee, description);
}
}
@@ -18,8 +18,9 @@ public record FinanceFeeSourceSnapshot(
String containerNo,
long customerId,
String customerName,
Long partnerId,
String partnerName,
Long supplierId,
String supplierName,
String supplierCategory,
Long feeItemId,
String feeItemCode,
String feeItemName,
@@ -29,7 +30,6 @@ public record FinanceFeeSourceSnapshot(
BigDecimal totalAmount,
String reviewStatus,
String publicationStatus,
boolean settlementIncluded,
LocalDateTime publishedAt,
BigDecimal chargeableQuantity,
String chargeUnit,
@@ -21,15 +21,13 @@ 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,
Long partnerId,
@Size(max = 200) String partnerName,
Long supplierId,
@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, null, null, remark);
this(feeItemId, direction, businessDate, unitPrice, currency, amount, null, remark);
}
}
@@ -63,7 +63,7 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
BigDecimal chargeableQuantity, String chargeUnit,
String feeTypeName, BigDecimal unitPrice, String currency, BigDecimal amount,
String reviewStatus, String publicationStatus,
LocalDateTime publishedAt, LocalDateTime inboundedAt, Boolean settlementIncluded
LocalDateTime publishedAt, LocalDateTime inboundedAt
) {
}
}
@@ -25,12 +25,12 @@ public class ContainerFeeDetailEntity {
private String feeItemCodeSnapshot;
private String feeItemNameSnapshot;
private String direction;
private Long partnerId;
private String partnerName;
private Long supplierId;
private String supplierName;
private String supplierCategory;
private LocalDate businessDate;
private BigDecimal amount;
private String currency;
private Boolean settlementIncluded;
private String reviewStatus;
private String sourceStatus;
private Integer sourceVersion;
@@ -0,0 +1,39 @@
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_source_snapshot")
public class ContainerFeeSourceSnapshotEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long sourceFeeId;
private Integer sourceVersion;
private Long tenantId;
private Long loadingPlanId;
private Long containerBookingId;
private String containerNo;
private Long feeItemId;
private String feeItemCode;
private String feeItemName;
private String direction;
private Long supplierId;
private String supplierName;
private String supplierCategory;
private LocalDate businessDate;
private BigDecimal amount;
private String currency;
private String reviewStatus;
private LocalDateTime reviewedAt;
private LocalDateTime createdAt;
}
@@ -18,6 +18,7 @@ public class FeeItemEntity extends BaseEntity {
private String category;
private String tradeScope;
private String feeCode;
private String supplierCategory;
private String feeTypeName;
private Boolean pickupPayable;
private Boolean pickupReceivable;
@@ -28,4 +29,5 @@ public class FeeItemEntity extends BaseEntity {
private String status;
private String createdByName;
private String updatedByName;
}
@@ -27,8 +27,9 @@ public class InboundFeeDetailEntity extends BaseEntity {
private String sourceFeeNo;
private String inboundCode;
private String direction;
private Long partnerId;
private String partnerName;
private Long supplierId;
private String supplierName;
private String supplierCategory;
private LocalDate businessDate;
private Long customerId;
private String customerName;
@@ -62,5 +63,4 @@ public class InboundFeeDetailEntity extends BaseEntity {
private String publishedByName;
private LocalDateTime publishedAt;
private LocalDateTime inboundedAt;
private Boolean settlementIncluded;
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ContainerFeeSourceSnapshotMapper extends BaseMapper<ContainerFeeSourceSnapshotEntity> {
}
@@ -15,6 +15,7 @@ import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -42,6 +43,7 @@ public class OceanBookingService {
@Resource private ObjectMapper objectMapper;
@Resource private WarehouseSupplierSnapshotService suppliers;
@Resource private WarehouseMasterDataClient warehouses;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
@@ -80,7 +82,7 @@ public class OceanBookingService {
ResolvedDraft resolved = resolveDraft(request.data());
OceanBookingEntity value = new OceanBookingEntity();
value.setId(IdWorker.getId());
value.setBookingNo("OB" + value.getId());
value.setBookingNo(documentNumbers.next("OB"));
value.setStatus(OceanBookingStatus.DRAFT.name());
copy(value, request.data(), resolved);
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
@@ -42,6 +42,22 @@ public class ContainerFeeDetailRepository {
mapper.insert(value);
}
public int updateSupplierSnapshot(long tenantId, long loadingPlanId, long feeId,
Long supplierId, String supplierName,
String supplierCategory, long operatorId) {
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::getSupplierId, supplierId)
.set(ContainerFeeDetailEntity::getSupplierName, supplierName)
.set(ContainerFeeDetailEntity::getSupplierCategory, supplierCategory)
.set(ContainerFeeDetailEntity::getUpdatedBy, operatorId)
.set(ContainerFeeDetailEntity::getUpdatedAt, LocalDateTime.now()));
}
public int review(long tenantId, long loadingPlanId, long feeId, String status,
long reviewerId, String reviewerName, String reason) {
return mapper.update(null, new LambdaUpdateWrapper<ContainerFeeDetailEntity>()
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity;
import com.baiyue.firstleg.mapper.ContainerFeeSourceSnapshotMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
@Repository
@RequiredArgsConstructor
public class ContainerFeeSourceSnapshotRepository {
private final ContainerFeeSourceSnapshotMapper mapper;
public void saveApproved(ContainerFeeDetailEntity source) {
ContainerFeeSourceSnapshotEntity value = new ContainerFeeSourceSnapshotEntity();
value.setSourceFeeId(source.getId());
value.setSourceVersion(source.getSourceVersion());
value.setTenantId(source.getTenantId());
value.setLoadingPlanId(source.getLoadingPlanId());
value.setContainerBookingId(source.getContainerBookingId());
value.setContainerNo(source.getContainerNo());
value.setFeeItemId(source.getFeeItemId());
value.setFeeItemCode(source.getFeeItemCodeSnapshot());
value.setFeeItemName(source.getFeeItemNameSnapshot());
value.setDirection(source.getDirection());
value.setSupplierId(source.getSupplierId());
value.setSupplierName(source.getSupplierName());
value.setSupplierCategory(source.getSupplierCategory());
value.setBusinessDate(source.getBusinessDate());
value.setAmount(source.getAmount());
value.setCurrency(source.getCurrency());
value.setReviewStatus(source.getReviewStatus());
value.setReviewedAt(source.getReviewedAt());
value.setCreatedAt(LocalDateTime.now());
mapper.insert(value);
}
public ContainerFeeSourceSnapshotEntity find(long loadingPlanId, long sourceFeeId,
int sourceVersion) {
return mapper.selectOne(new LambdaQueryWrapper<ContainerFeeSourceSnapshotEntity>()
.eq(ContainerFeeSourceSnapshotEntity::getLoadingPlanId, loadingPlanId)
.eq(ContainerFeeSourceSnapshotEntity::getSourceFeeId, sourceFeeId)
.eq(ContainerFeeSourceSnapshotEntity::getSourceVersion, sourceVersion));
}
}
@@ -121,6 +121,15 @@ public class FeeItemRepository {
.eq(InboundFeeDetailEntity::getId, detailId));
}
public InboundFeeDetailEntity findSourceVersion(long inboundId, long sourceFeeId,
int sourceVersion) {
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
.eq(InboundFeeDetailEntity::getSourceFeeVersion, sourceVersion)
.and(scope -> scope.eq(InboundFeeDetailEntity::getId, sourceFeeId)
.or().eq(InboundFeeDetailEntity::getSourceFeeId, sourceFeeId)));
}
public InboundFeeDetailEntity findBySourceFee(String sourceType, long sourceFeeId) {
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getSourceType, sourceType)
@@ -166,7 +175,6 @@ public class FeeItemRepository {
.in(InboundFeeDetailEntity::getPublicationStatus, List.of("DRAFT", "PUBLISHED"))
.ne(InboundFeeDetailEntity::getSourceFeeId, retainedSourceFeeId)
.set(InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED")
.set(InboundFeeDetailEntity::getSettlementIncluded, false)
.set(InboundFeeDetailEntity::getUpdatedBy, operatorId)
.set(InboundFeeDetailEntity::getUpdatedAt, now));
}
@@ -186,7 +194,6 @@ public class FeeItemRepository {
.set(InboundFeeDetailEntity::getPublishedBy, operatorId)
.set(InboundFeeDetailEntity::getPublishedByName, operatorName)
.set(InboundFeeDetailEntity::getPublishedAt, now)
.set(InboundFeeDetailEntity::getSettlementIncluded, true)
.set(InboundFeeDetailEntity::getUpdatedBy, operatorId)
.set(InboundFeeDetailEntity::getUpdatedAt, now));
}
@@ -9,12 +9,12 @@ 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.ContainerFeeSourceSnapshotRepository;
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 org.springframework.util.StringUtils;
import java.math.RoundingMode;
import java.time.LocalDateTime;
@@ -27,6 +27,8 @@ public class ContainerFeeDetailService {
private final SmartLoadingRepository loading;
private final FeeItemRepository feeItems;
private final FinanceFeeSourceOutboxService financeOutbox;
private final WarehouseSupplierSnapshotService suppliers;
private final ContainerFeeSourceSnapshotRepository sourceSnapshots;
public List<ContainerFeeDetailEntity> list(long tenantId, long loadingPlanId) {
requireOwnedPlan(tenantId, loadingPlanId);
@@ -56,18 +58,19 @@ public class ContainerFeeDetailService {
@Transactional
public void approve(long tenantId, long loadingPlanId, long feeId,
long reviewerId, String reviewerName) {
required(tenantId, loadingPlanId, feeId);
ContainerFeeDetailEntity pending = required(tenantId, loadingPlanId, feeId);
FeeItemEntity item = requireItem(pending.getFeeItemId(), pending.getDirection());
refreshSupplierSnapshot(pending, item, reviewerId);
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());
}
sourceSnapshots.saveApproved(approved);
String eventType = approved.getSourceVersion() > 1
? FinanceFeeSourceOutboxService.ADJUSTED : FinanceFeeSourceOutboxService.PUBLISHED;
financeOutbox.publishContainer(eventType, tenantId, loadingPlanId, feeId,
approved.getSourceVersion(), LocalDateTime.now());
}
@Transactional
@@ -111,10 +114,8 @@ public class ContainerFeeDetailService {
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());
}
financeOutbox.publishContainer(FinanceFeeSourceOutboxService.VOIDED, tenantId,
loadingPlanId, feeId, expectedVersion, LocalDateTime.now());
}
private PlanContext requireOwnedPlan(long tenantId, long loadingPlanId) {
@@ -144,28 +145,49 @@ public class ContainerFeeDetailService {
return value;
}
private static void apply(ContainerFeeDetailEntity value, FeeItemEntity item,
SaveRequest request, long operatorId) {
if ("PAYABLE".equals(request.direction())
&& (request.partnerId() == null || request.partnerId() <= 0
|| !StringUtils.hasText(request.partnerName()))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "应付柜次费用必须选择有效往来单位");
}
private 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.setPartnerId("PAYABLE".equals(request.direction()) ? request.partnerId() : null);
value.setPartnerName("PAYABLE".equals(request.direction()) ? request.partnerName().trim() : null);
applySupplierSnapshot(value, item, request.direction(), request.supplierId());
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 void applySupplierSnapshot(ContainerFeeDetailEntity value, FeeItemEntity item,
String direction, Long supplierId) {
if (!"PAYABLE".equals(direction)) {
value.setSupplierId(null);
value.setSupplierName(null);
value.setSupplierCategory(null);
return;
}
if (supplierId == null || supplierId <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "应付柜次费用必须选择有效供应商");
}
String category = FeeItemSupplierCategory.requirePayable(item.getSupplierCategory());
var snapshot = suppliers.fee(supplierId, category);
value.setSupplierId(snapshot.supplierId());
value.setSupplierName(snapshot.name());
value.setSupplierCategory(snapshot.category());
}
private void refreshSupplierSnapshot(ContainerFeeDetailEntity value, FeeItemEntity item,
long operatorId) {
applySupplierSnapshot(value, item, value.getDirection(), value.getSupplierId());
if (repository.updateSupplierSnapshot(value.getTenantId(), value.getLoadingPlanId(),
value.getId(), value.getSupplierId(), value.getSupplierName(),
value.getSupplierCategory(), operatorId) != 1) {
throw conflict("柜次费用供应商快照已变化,请刷新后重试");
}
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
@@ -47,6 +47,7 @@ public class ContainerService {
@Resource private PriceBookQueryRepository priceBooks;
@Resource private OceanBookingRepository oceanBookings;
@Resource private WarehouseSupplierSnapshotService suppliers;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Autowired(required = false) private ContainerTransportHistoryMapper transportHistories;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
@@ -66,7 +67,7 @@ public class ContainerService {
.filter(row -> request.channelCode().trim().equals(row.getChannelCode())).findFirst()
.orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择"));
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
value.setBookingNo("CB" + value.getId()); value.setCabinetNo("CAB-" + value.getId());
value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.next("CAB"));
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
value.setChannelCode(channel.getChannelCode()); value.setChannelName(channel.getChannelName());
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
@@ -102,10 +102,19 @@ public class FeeItemService {
}
private static void copy(FeeItemEntity value, SaveRequest request) {
boolean payable = Boolean.TRUE.equals(request.pickupPayable())
|| Boolean.TRUE.equals(request.warehousePayable());
if (payable && (request.supplierCategory() == null
|| request.supplierCategory().isBlank())) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"启用应付方向时必须配置供应商类别");
}
value.setCategory(request.category().trim());
value.setTradeScope(request.tradeScope());
value.setFeeCode(request.feeCode() == null || request.feeCode().isBlank()
? value.getFeeCode() : request.feeCode().trim().toUpperCase());
value.setSupplierCategory(payable
? FeeItemSupplierCategory.normalize(request.supplierCategory()) : null);
value.setFeeTypeName(request.feeTypeName().trim());
value.setPickupPayable(request.pickupPayable());
value.setPickupReceivable(request.pickupReceivable());
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.util.Locale;
import java.util.Set;
/** 费用项目显式供应商类别校验;未配置的应付费用失败关闭。 */
public final class FeeItemSupplierCategory {
private static final Set<String> ALLOWED = Set.of(
"BOOKING", "CUSTOMS", "TRUCKING", "CERTIFICATE");
private FeeItemSupplierCategory() {
}
public static String normalize(String supplierCategory) {
if (supplierCategory == null || supplierCategory.isBlank()) return null;
String normalized = supplierCategory.trim().toUpperCase(Locale.ROOT);
if (!ALLOWED.contains(normalized)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "供应商类别无效");
}
return normalized;
}
public static String requirePayable(String supplierCategory) {
String category = normalize(supplierCategory);
if (category == null) {
throw new BizException(ResultCode.CONFLICT,
"费用项目未配置供应商类别,不能用于应付费用");
}
return category;
}
}
@@ -3,11 +3,11 @@ 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.ContainerFeeSourceSnapshotEntity;
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.ContainerFeeSourceSnapshotRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -21,11 +21,10 @@ import java.util.HexFormat;
@RequiredArgsConstructor
public class FinanceFeeSourceSnapshotService {
private final FeeItemRepository inboundFees;
private final ContainerFeeDetailRepository containerFees;
private final ContainerFeeSourceSnapshotRepository containerSnapshots;
public FinanceFeeSourceSnapshot inbound(long inboundId, long feeId) {
InboundFeeDetailEntity detail = inboundFees.findDetail(inboundId, feeId);
if (detail == null) detail = inboundFees.findBySourceFee("FBA_PRICING", feeId);
public FinanceFeeSourceSnapshot inbound(long inboundId, long feeId, int sourceVersion) {
InboundFeeDetailEntity detail = inboundFees.findSourceVersion(inboundId, feeId, sourceVersion);
if (detail == null || !Long.valueOf(inboundId).equals(detail.getInboundId())) {
throw new BizException(ResultCode.NOT_FOUND, "入库费用来源不存在");
}
@@ -34,43 +33,44 @@ public class FinanceFeeSourceSnapshotService {
if (inbound == null || item == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库费用来源关联快照不完整");
}
long sourceFeeId = "FBA_PRICING".equals(detail.getSourceType())
? detail.getSourceFeeId() : detail.getId();
long sourceFeeId = detail.getSourceFeeId() == null ? detail.getId() : detail.getSourceFeeId();
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()));
nullSafe(detail.getDirection()), nullSafe(detail.getSupplierId()),
nullSafe(detail.getSupplierName()), nullSafe(detail.getSupplierCategory()),
detail.getAmount().toPlainString(),
nullSafe(detail.getCurrency()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "INBOUND_FEE", sourceFeeId,
version, inboundId, inbound.getInboundCode(), inbound.getOrderId(), null, null, null,
detail.getTenantId(), inbound.getCustomerName(), detail.getPartnerId(), detail.getPartnerName(), item.getId(), item.getFeeCode(),
detail.getTenantId(), inbound.getCustomerName(), detail.getSupplierId(),
detail.getSupplierName(), detail.getSupplierCategory(), item.getId(), item.getFeeCode(),
detail.getFeeTypeNameSnapshot(), detail.getDirection(), detail.getBusinessDate(),
detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
detail.getPublicationStatus(), Boolean.TRUE.equals(detail.getSettlementIncluded()),
detail.getPublicationStatus(),
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);
public FinanceFeeSourceSnapshot container(long containerId, long feeId, int sourceVersion) {
ContainerFeeSourceSnapshotEntity detail = containerSnapshots.find(containerId, feeId, sourceVersion);
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()),
nullSafe(detail.getPartnerId()), nullSafe(detail.getPartnerName()),
nullSafe(detail.getFeeItemCode()), nullSafe(detail.getDirection()),
nullSafe(detail.getSupplierId()), nullSafe(detail.getSupplierName()),
nullSafe(detail.getSupplierCategory()),
detail.getAmount().toPlainString(), nullSafe(detail.getCurrency()),
String.valueOf(detail.getSettlementIncluded()));
nullSafe(detail.getReviewStatus()));
return new FinanceFeeSourceSnapshot(detail.getTenantId(), "CONTAINER_FEE", feeId,
version, null, null, null, containerId, detail.getContainerBookingId(),
detail.getContainerNo(), detail.getTenantId(), null, detail.getPartnerId(),
detail.getPartnerName(), detail.getFeeItemId(),
detail.getFeeItemCodeSnapshot(), detail.getFeeItemNameSnapshot(), detail.getDirection(),
detail.getContainerNo(), detail.getTenantId(), null, detail.getSupplierId(),
detail.getSupplierName(), detail.getSupplierCategory(), detail.getFeeItemId(),
detail.getFeeItemCode(), detail.getFeeItemName(), detail.getDirection(),
detail.getBusinessDate(), detail.getCurrency(), detail.getAmount(), detail.getReviewStatus(),
publicationStatus, Boolean.TRUE.equals(detail.getSettlementIncluded()),
"PUBLISHED",
detail.getReviewedAt(), null, null, null, null, sha256(canonical), 1);
}
@@ -0,0 +1,55 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Set;
@Component
public class FirstlegDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("OB", "TA", "CB", "CAB", "LP");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;
private final Clock clock;
public FirstlegDocumentNumberGenerator(JdbcTemplate jdbc) {
this(jdbc, Clock.system(BUSINESS_ZONE));
}
FirstlegDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) {
this.jdbc = jdbc;
this.clock = clock;
}
@Transactional(propagation = Propagation.MANDATORY)
public String next(String prefix) {
if (!SUPPORTED_PREFIXES.contains(prefix)) {
throw new IllegalArgumentException("unsupported firstleg document prefix: " + prefix);
}
LocalDate sequenceDate = LocalDate.now(clock);
String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate);
jdbc.update(
"INSERT INTO fl_document_sequence(sequence_key,current_value) " +
"VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " +
"current_value=LAST_INSERT_ID(current_value+1)",
sequenceKey);
Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class);
if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) {
throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据编号已用尽");
}
return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" +
String.format(Locale.ROOT, "%04d", sequence);
}
}
@@ -29,6 +29,8 @@ public class InboundFeeDetailService {
private FbaPricingRepository pricingRepository;
@Resource
private FinanceFeeSourceOutboxService financeOutbox;
@Resource
private WarehouseSupplierSnapshotService suppliers;
@Transactional
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId, boolean includeHistory) {
@@ -130,7 +132,6 @@ public class InboundFeeDetailService {
detail.setPublishedByName(operatorName);
detail.setPublishedAt(now);
detail.setInboundedAt(inbound.getInboundedAt());
detail.setSettlementIncluded(true);
detail.setCreatedBy(operatorId);
detail.setUpdatedBy(operatorId);
try {
@@ -199,7 +200,7 @@ public class InboundFeeDetailService {
detail.setRemark("来源费用单:" + fee.getFeeNo());
detail.setReviewStatus(published ? "APPROVED" : "PENDING");
detail.setPublicationStatus(published ? "PUBLISHED" : "DRAFT");
detail.setSettlementIncluded(published); detail.setCreatedBy(operatorId); detail.setUpdatedBy(operatorId);
detail.setCreatedBy(operatorId); detail.setUpdatedBy(operatorId);
if (published) {
detail.setReviewerId(operatorId); detail.setReviewerName(operatorName); detail.setReviewedAt(now);
detail.setPublishedBy(operatorId); detail.setPublishedByName(operatorName); detail.setPublishedAt(now);
@@ -252,7 +253,9 @@ public class InboundFeeDetailService {
@Transactional
public void approve(long tenantId, long inboundId, long detailId,
long reviewerId, String reviewerName) {
requiredManual(tenantId, inboundId, detailId);
InboundFeeDetailEntity pending = requiredManual(tenantId, inboundId, detailId);
FeeItemEntity item = requiredActiveItem(pending.getFeeItemId());
refreshSupplierSnapshot(pending, item, reviewerId);
if (repository.review(tenantId, inboundId, detailId, "APPROVED",
reviewerId, reviewerName, null) != 1) {
throw conflict("只有待审核费用明细可以审核通过");
@@ -301,10 +304,8 @@ public class InboundFeeDetailService {
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());
}
current.setPublicationStatus("VOIDED");
publishFinanceSource(current, "VOIDED", LocalDateTime.now());
}
@Transactional
@@ -373,14 +374,13 @@ public class InboundFeeDetailService {
return value;
}
private static void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound,
FeeItemEntity item, SaveRequest request, long operatorId) {
private void apply(InboundFeeDetailEntity value, InboundOrderEntity inbound,
FeeItemEntity item, SaveRequest request, long operatorId) {
ensureWarehouseApplicable(item, request.direction());
value.setFeeItemId(item.getId());
value.setInboundCode(inbound.getInboundCode());
value.setDirection(request.direction());
value.setPartnerId("PAYABLE".equals(request.direction()) ? request.partnerId() : null);
value.setPartnerName("PAYABLE".equals(request.direction()) ? request.partnerName() : null);
applySupplierSnapshot(value, item, request.direction(), request.supplierId());
value.setBusinessDate(request.businessDate());
value.setFeeCategorySnapshot(item.getCategory());
value.setTradeScopeSnapshot(item.getTradeScope());
@@ -399,18 +399,43 @@ 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);
}
private void applySupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item,
String direction, Long supplierId) {
if (!"PAYABLE".equals(direction)) {
value.setSupplierId(null);
value.setSupplierName(null);
value.setSupplierCategory(null);
return;
}
if (supplierId == null || supplierId <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED, "应付入库费用必须选择有效供应商");
}
String category = FeeItemSupplierCategory.requirePayable(item.getSupplierCategory());
var snapshot = suppliers.fee(supplierId, category);
value.setSupplierId(snapshot.supplierId());
value.setSupplierName(snapshot.name());
value.setSupplierCategory(snapshot.category());
}
private void refreshSupplierSnapshot(InboundFeeDetailEntity value, FeeItemEntity item,
long operatorId) {
applySupplierSnapshot(value, item, value.getDirection(), value.getSupplierId());
value.setUpdatedBy(operatorId);
if (repository.updateDetail(value) != 1) {
throw conflict("费用明细供应商快照已变化,请刷新后重试");
}
}
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
@@ -149,7 +149,7 @@ public class MerchantInboundBillingService {
detail.getChargeableVolumeCbm(), detail.getChargeableQuantity(), detail.getChargeUnit(),
detail.getFeeTypeNameSnapshot(), detail.getUnitPrice(), detail.getCurrency(), detail.getAmount(),
detail.getReviewStatus(), detail.getPublicationStatus(), detail.getPublishedAt(),
detail.getInboundedAt(), detail.getSettlementIncluded());
detail.getInboundedAt());
}
public record ImageDownload(byte[] content, String contentType) {
@@ -39,6 +39,7 @@ public class SmartLoadingService {
@Resource private LoadingPickService loadingPickService;
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private SplitPreloadService splitPreloadService;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
public SmartLoadingService() { }
@@ -77,7 +78,7 @@ public class SmartLoadingService {
LoadingPlanEntity existing = repository.planByBooking(bookingId);
if (existing != null) return draftView(existing, booking);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setPlanNo("LP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20));
plan.setPlanNo(documentNumbers.next("LP"));
plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING"); plan.setRemark(trim(remark));
plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId);
plan.setUpdatedByName(operatorName); plan.setVersion(0);
@@ -40,6 +40,7 @@ public class TruckAppointmentService {
@Resource TruckAppointmentFileClient files;
@Resource WarehouseSupplierSnapshotService suppliers;
@Resource IamCustomerSnapshotService customers;
@Resource FirstlegDocumentNumberGenerator documentNumbers;
@Resource ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}") String internalToken;
public PageResult<TruckAppointmentEntity> page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));}
@@ -47,7 +48,7 @@ public class TruckAppointmentService {
@Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);TruckAppointmentEntity appointment=required(id);if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法上传附件");try{var response=files.upload(internalToken,uid,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}}
@Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());TruckAppointmentEntity appointment=required(id); if(appointment.getCustomerId()==null||appointment.getCustomerId()<=0)throw bad("预约缺少客户归属,无法引用附件"); TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,appointment.getCustomerId(),"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}
@Transactional public void deleteAttachment(long id,long attachmentId){required(id);if(repo.deleteAttachment(id,attachmentId)!=1)throw new BizException(ResultCode.NOT_FOUND,"附件引用不存在");}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo("TA"+e.getId()); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,links,customer.tenantName(),carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
@Transactional public TruckAppointmentEntity create(long uid,String name,SaveRequest r){if(r.expectedVersion()==null||r.expectedVersion()!=0)throw bad("新增 expectedVersion 必须为0");validateRequired(r); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); TruckAppointmentEntity e=new TruckAppointmentEntity(); e.setId(IdWorker.getId()); e.setAppointmentNo(documentNumbers.next("TA")); e.setStatus(TruckAppointmentStatus.DRAFT); e.setRevision(0); e.setVersion(0); e.setCreatedAt(LocalDateTime.now()); e.setUpdatedAt(e.getCreatedAt()); copy(e,r,links,customer.tenantName(),carrier.name()); repo.insert(e); record(e,null,e.getStatus().name(),"CREATE",null,uid,name); return e;}
@Transactional public TruckAppointmentEntity edit(long id,long uid,String name,SaveRequest r){validateRequired(r);TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if(!(e.getStatus()==TruckAppointmentStatus.DRAFT||e.getStatus()==TruckAppointmentStatus.PENDING_DISPATCH||e.getStatus()==TruckAppointmentStatus.RESCHEDULED))throw conflict("当前状态不可编辑"); var customer=customers.active(r.customerId(),r.customerName()); AssociationContext links=validateAssociations(r,customer.id()); var carrier=suppliers.trucking(r.carrierId(),r.carrierName()); copy(e,r,links,customer.tenantName(),carrier.name()); if(repo.updateEdit(e,r.expectedVersion())!=1)throw conflict("版本已变化,请刷新后重试"); record(e,e.getStatus().name(),e.getStatus().name(),"EDIT",null,uid,name); return required(id);}
@Transactional public TruckAppointmentEntity action(long id,String action,ActionRequest r,String idem,String clientHash,long uid,String name){String requestHash=hash(action,id,r);verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id); checkVersion(e,r.expectedVersion()); if("RESCHEDULE".equals(action))throw conflict("改约请使用专用接口"); TruckAppointmentStatus from=e.getStatus(),to=next(from,action); String dispatchBill="DISPATCH".equals(action)?requireDispatchReady(e):null; int changed="DISPATCH".equals(action)?repo.dispatch(id,from.name(),dispatchBill,e.getVersion()):repo.transition(id,from.name(),to.name(),e.getVersion()); if(changed!=1)throw conflict("状态或版本已变化,请刷新后重试"); TruckAppointmentEntity after=required(id);record(after,from.name(),to.name(),action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@Transactional public TruckAppointmentEntity reschedule(long id,LocalDateTime at,ActionRequest r,String idem,String clientHash,long uid,String name){String action="RESCHEDULE";String requestHash=hash(action,id,new ReschedulePayload(at,r));verifyClientHash(clientHash,requestHash);TruckAppointmentEntity replay=claimOrReplay(id,action,idem,requestHash);if(replay!=null)return replay;TruckAppointmentEntity e=required(id);checkVersion(e,r.expectedVersion()); if(e.getStatus()==TruckAppointmentStatus.COMPLETED||e.getStatus()==TruckAppointmentStatus.CANCELLED)throw conflict("当前状态不可改约"); String from=e.getStatus().name(); if(repo.reschedule(id,at,r.reason(),e.getVersion())!=1)throw conflict("状态或版本已变化,请刷新后重试");TruckAppointmentEntity after=required(id);record(after,from,"RESCHEDULED",action,r.reason(),uid,name);return completeOperation(id,action,idem,after);}
@@ -26,6 +26,14 @@ public class WarehouseSupplierSnapshotService {
return resolve(supplierId, suppliedName, "CUSTOMS");
}
/** 费用来源只提交供应商 ID,名称与类别均以 Warehouse 返回快照为准。 */
public WarehouseSupplierClient.Snapshot fee(Long supplierId, String category) {
if (!StringUtils.hasText(category)) {
throw new BizException(ResultCode.CONFLICT, "费用项目未配置供应商类别");
}
return resolve(supplierId, null, category);
}
private WarehouseSupplierClient.Snapshot resolve(Long supplierId, String suppliedName,
String category) {
if (supplierId == null || supplierId <= 0) {
@@ -71,12 +71,12 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
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应付',
partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空',
partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空',
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空',
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 '费用来源版本;调整递增,作废携带当前版本',
@@ -91,22 +91,46 @@ CREATE TABLE IF NOT EXISTS fl_container_fee_detail (
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),
KEY idx_container_fee_review (tenant_id, review_status, 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';
) ENGINE=InnoDB COMMENT='柜次级人工应收应付费用来源;审核通过后自动同步Finance';
CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_id',
"BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'partner_name',
"VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id',
"BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_name',
"VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空' AFTER partner_id");
CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot (
id BIGINT UNSIGNED NOT NULL,
source_fee_id BIGINT UNSIGNED NOT NULL,
source_version INT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
loading_plan_id BIGINT UNSIGNED NOT NULL,
container_booking_id BIGINT UNSIGNED NULL,
container_no VARCHAR(64) NULL,
fee_item_id BIGINT UNSIGNED NOT NULL,
fee_item_code VARCHAR(50) NOT NULL,
fee_item_name VARCHAR(128) NOT NULL,
direction VARCHAR(16) NOT NULL,
supplier_id BIGINT UNSIGNED NULL,
supplier_name VARCHAR(200) NULL,
supplier_category VARCHAR(16) NULL,
business_date DATE NOT NULL,
amount DECIMAL(18,2) NOT NULL,
currency CHAR(3) NOT NULL DEFAULT 'CNY',
review_status VARCHAR(16) NOT NULL,
reviewed_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_container_fee_source_version (loading_plan_id, source_fee_id, source_version),
KEY idx_container_fee_snapshot_tenant (tenant_id, loading_plan_id)
) ENGINE=InnoDB COMMENT='柜次费用不可变版本快照';
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id', "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_name', "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_container_fee_detail', 'supplier_category', "VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空' AFTER supplier_name");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id', "BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空' AFTER direction");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_name', "VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空' AFTER supplier_id");
CALL firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_category', "VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空' AFTER supplier_name");
DROP PROCEDURE IF EXISTS firstleg_finance_add_column;
DROP PROCEDURE IF EXISTS firstleg_finance_assert_schema;
@@ -125,13 +149,13 @@ BEGIN
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_container_fee_detail'
AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用往来单位快照字段不完整';
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:柜次费用供应商快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_fee_detail'
AND column_name IN ('partner_id', 'partner_name')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用往来单位快照字段不完整';
AND column_name IN ('supplier_id', 'supplier_name', 'supplier_category')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Firstleg Finance迁移失败:入库费用供应商快照字段不完整';
END IF;
END$$
DELIMITER ;
@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item (
category VARCHAR(64) NOT NULL COMMENT '费用分类',
trade_scope VARCHAR(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际',
fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码',
supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别;未配置则失败关闭',
fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称',
pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与',
pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与',
@@ -36,8 +37,9 @@ CREATE TABLE IF NOT EXISTS fl_inbound_fee_detail (
fee_item_id BIGINT UNSIGNED NOT NULL COMMENT '绑定费用项目ID',
inbound_code VARCHAR(40) NOT NULL COMMENT '入仓码快照',
direction VARCHAR(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付',
partner_id BIGINT UNSIGNED NULL COMMENT '应付往来单位ID快照;应收为空',
partner_name VARCHAR(200) NULL COMMENT '应付往来单位名称快照;应收为空',
supplier_id BIGINT UNSIGNED NULL COMMENT '应付Warehouse供应商ID快照;应收为空',
supplier_name VARCHAR(200) NULL COMMENT '应付Warehouse供应商名称快照;应收为空',
supplier_category VARCHAR(16) NULL COMMENT '费用项目供应商类别快照;应收为空',
business_date DATE NOT NULL COMMENT '业务发生日',
fee_category_snapshot VARCHAR(64) NOT NULL COMMENT '费用分类快照',
trade_scope_snapshot VARCHAR(20) NOT NULL COMMENT '国内国际快照',
@@ -22,6 +22,8 @@ DELIMITER ;
CALL phase2_publish_add_column('fl_fee_item', 'fee_code',
"VARCHAR(50) NULL COMMENT '稳定费用编码;系统费用使用固定编码' AFTER trade_scope");
CALL phase2_publish_add_column('fl_fee_item', 'supplier_category',
"VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别;未配置则失败关闭' AFTER fee_code");
ALTER TABLE fl_fee_item
MODIFY COLUMN tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id',
COMMENT = '平台公共费用项目主数据';
@@ -431,9 +433,6 @@ CALL phase2_publish_add_column('fl_inbound_fee_detail', 'published_at',
"DATETIME(3) NULL COMMENT '发布时间' AFTER published_by_name");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'inbounded_at',
"DATETIME(3) NULL COMMENT '入库完成时间快照' AFTER published_at");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'settlement_included',
"TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与结算' AFTER inbounded_at");
DROP PROCEDURE IF EXISTS phase2_publish_add_column;
DROP PROCEDURE IF EXISTS phase2_publish_add_index;
@@ -26,6 +26,13 @@ DELIMITER ;
CALL phase3_add_allocation_picked_count();
DROP PROCEDURE phase3_add_allocation_picked_count;
CREATE TABLE IF NOT EXISTS fl_document_sequence (
sequence_key VARCHAR(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如CAB:20260818',
current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号',
PRIMARY KEY (sequence_key),
CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0)
) ENGINE=InnoDB COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号';
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
@@ -295,7 +295,7 @@ components:
nextProcessStatus: {type: string, nullable: true, description: 费用动作后的入库主流程状态;审核完成后保持 WAIT_SPLIT}
InboundFeeDetailSaveRequest:
type: object
required: [feeItemId, direction, businessDate, unitPrice, currency, amount, settlementIncluded]
required: [feeItemId, direction, businessDate, unitPrice, currency, amount]
properties:
feeItemId: {type: string, pattern: '^[1-9][0-9]*$'}
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
@@ -303,9 +303,9 @@ components:
unitPrice: {type: number, minimum: 0}
currency: {type: string, pattern: '^[A-Z]{3}$'}
amount: {type: number, minimum: 0}
settlementIncluded: {type: boolean}
partnerId: {type: string, nullable: true}
partnerName: {type: string, maxLength: 200, nullable: true}
supplierId: {type: string, nullable: true}
supplierName: {type: string, maxLength: 200, nullable: true, readOnly: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true}
remark: {type: string, maxLength: 500, nullable: true}
InboundFeeDetailVersionRequest:
type: object
@@ -318,7 +318,7 @@ components:
InboundFeeDetailResource:
type: object
description: 入仓费用展示记录。sourceType=FBA_PRICING 时是只读派生;sourceType=MANUAL 时按 sourceFeeId/sourceFeeVersion 独立版本化。
required: [id, tenantId, inboundId, feeItemId, sourceType, sourceFeeVersion, direction, businessDate, unitPrice, currency, amount, reviewStatus, publicationStatus, settlementIncluded]
required: [id, tenantId, inboundId, feeItemId, sourceType, sourceFeeVersion, direction, businessDate, unitPrice, currency, amount, reviewStatus, publicationStatus]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -330,8 +330,9 @@ components:
sourceFeeNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
direction: {type: string, enum: [RECEIVABLE, PAYABLE]}
partnerId: {type: string, nullable: true}
partnerName: {type: string, nullable: true}
supplierId: {type: string, nullable: true}
supplierName: {type: string, nullable: true, readOnly: true}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true}
businessDate: {type: string, format: date}
customerId: {type: string, nullable: true}
customerName: {type: string, nullable: true}
@@ -365,7 +366,6 @@ components:
publishedByName: {type: string, nullable: true}
publishedAt: {type: string, format: date-time, nullable: true}
inboundedAt: {type: string, format: date-time, nullable: true}
settlementIncluded: {type: boolean}
createdBy: {type: string, nullable: true}
createdAt: {type: string, format: date-time, nullable: true}
updatedBy: {type: string, nullable: true}
@@ -100,6 +100,7 @@ components:
category: {type: string, maxLength: 64}
tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]}
feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: pickupPayable或warehousePayable为true时必填}
feeTypeName: {type: string, maxLength: 128}
pickupPayable: {type: boolean}
pickupReceivable: {type: boolean}
@@ -115,6 +116,7 @@ components:
category: {type: string}
tradeScope: {type: string, enum: [DOMESTIC, INTERNATIONAL]}
feeCode: {type: string}
supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true}
feeTypeName: {type: string}
pickupPayable: {type: boolean}
pickupReceivable: {type: boolean}
@@ -838,7 +838,7 @@ paths:
summary: 调整已审核柜次费用的日期和金额
description: >-
仅 ACTIVE、APPROVED 且 sourceVersion 匹配的费用可调整。成功后 sourceVersion 加一并重新进入
PENDING;费用方向、费用项目、往来单位、币种、参与结算和备注保持原快照。身份或结算字段需要变化时,
PENDING;费用方向、费用项目、供应商、币种和备注保持原快照。业务字段需要变化时,
必须作废原费用并新增。同一 expectedSourceVersion、businessDate、amount 的原样重试只读返回已生成的
PENDING 版本;旧版本异参、已作废或非已审核状态返回 A0409 且零写入。
parameters:
@@ -1446,10 +1446,10 @@ components:
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
planNo: {type: string, nullable: true, description: 装柜清单号;未创建计划时为空}
bookingNo: {type: string, maxLength: 40}
planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空}
bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001, description: 平台内部货柜登记号}
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64}
cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 平台稳定柜次号}
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回}
mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空}
@@ -1580,7 +1580,7 @@ components:
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items]
properties:
id: {type: string}
planNo: {type: string}
planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号}
status: {type: string, enum: [EDITING, LOADING, COMPLETED]}
bookingId: {type: string}
containerNo: {type: string, nullable: true}
@@ -1692,7 +1692,6 @@ components:
publicationStatus: {type: string, enum: [PUBLISHED]}
publishedAt: {type: string, format: date-time}
inboundedAt: {type: string, format: date-time, nullable: true}
settlementIncluded: {type: boolean, example: true}
InboundOrderPageEnvelope:
type: object
properties:
@@ -294,9 +294,9 @@ components:
properties:
planId: {$ref: '#/components/schemas/LongId'}
containerBookingId: {$ref: '#/components/schemas/LongId'}
preloadNo: {type: string, description: 预装单号}
preloadNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用}
containerNo: {type: string, nullable: true, description: 集装箱号}
cabinetNo: {type: string, nullable: true, description: 稳定柜次号;不得与真实集装箱号混用}
cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 稳定柜次号;不得与真实集装箱号混用}
customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}}
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
warehouseName: {type: string, nullable: true, description: 仓库名称}
@@ -351,11 +351,11 @@ components:
properties:
planId: {$ref: '#/components/schemas/LongId'}
containerBookingId: {$ref: '#/components/schemas/LongId'}
loadingListNo: {type: string, description: 装柜清单号}
loadingListNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 装柜清单号;沿用预装阶段生成的同一编号}
warehouseId: {$ref: '#/components/schemas/NullableLongId'}
warehouseName: {type: string, nullable: true, description: 仓库名称}
containerNo: {type: string, nullable: true, description: 集装箱号}
cabinetNo: {type: string, nullable: true, description: 稳定柜次号}
cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001, description: 稳定柜次号}
status: {type: string, enum: [COMPLETED], description: 装柜清单状态}
totalPackageCount: {type: string, pattern: '^[0-9]+$', description: 总箱数}
totalGrossWeightKg: {type: number, description: 总毛重,单位千克}
@@ -94,4 +94,15 @@ components:
channelName: {type: string}
priceModel: {type: string}
pricingTaxModes: {type: array, items: {type: string}}
Detail: {type: object, required: [oceanBooking, parties, containerRequirements, containers, histories], properties: {oceanBooking: {type: object}, parties: {type: array}, containerRequirements: {type: array}, containers: {type: array, description: Existing containers allocated after port pickup linking}, histories: {type: array}}}
Detail:
type: object
required: [oceanBooking, parties, containerRequirements, containers, histories]
properties:
oceanBooking:
type: object
properties:
bookingNo: {type: string, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001, description: 平台订舱号;按北京时间创建日逐日编号}
parties: {type: array}
containerRequirements: {type: array}
containers: {type: array, description: Existing containers allocated after port pickup linking}
histories: {type: array}
@@ -55,7 +55,7 @@ components:
exception: {$ref: '#/components/schemas/ExceptionState'}
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string}, cabinetNo: {type: string}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, version: {type: integer, minimum: 0}}}
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, version: {type: integer, minimum: 0}}}
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
@@ -29,7 +29,7 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/SaveRequest'}
responses:
'200': {description: Created appointment}
'200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}}
/api/platform/v1/truck-appointments/{id}:
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
get:
@@ -182,13 +182,14 @@ components:
expectedVersion: {type: integer, minimum: 0, description: Must be 0 on create; current version on edit.}
DetailStableReferences:
type: object
required: [customerId, customerName, carrierId, carrierName, attachmentCount]
required: [appointmentNo, customerId, customerName, carrierId, carrierName, attachmentCount]
description: Stable IDs plus server-resolved saved resource names returned by appointment detail. Fulfillment associations remain null for an independently created appointment.
properties:
appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号}
oceanBookingId: {type: integer, format: int64, nullable: true}
oceanBookingNo: {type: string, nullable: true}
oceanBookingNo: {type: string, nullable: true, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001}
containerBookingId: {type: integer, format: int64, nullable: true}
cabinetNo: {type: string, nullable: true}
cabinetNo: {type: string, nullable: true, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}
containerNo: {type: string, nullable: true}
loadingPlanId: {type: integer, format: int64, nullable: true}
warehouseId: {type: integer, format: int64, nullable: true}
@@ -58,7 +58,7 @@ class FeeManagementContractTest {
.contains("ContainerFeeAdjustRequest:")
.contains("required: [expectedSourceVersion, businessDate, amount]")
.contains("additionalProperties: false")
.contains("费用方向、费用项目、往来单位、币种、参与结算和备注保持原快照")
.contains("费用方向、费用项目、往来单位、币种和备注保持原快照")
.contains("旧版本异参、已作废或非已审核状态返回 A0409 且零写入");
}
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -35,6 +36,7 @@ class OceanBookingServiceTest {
@Mock private PriceBookQueryRepository priceBooks;
@Mock private WarehouseSupplierSnapshotService suppliers;
@Mock private WarehouseMasterDataClient warehouses;
@Mock private FirstlegDocumentNumberGenerator documentNumbers;
@InjectMocks private OceanBookingService service;
private ObjectMapper objectMapper;
@@ -42,6 +44,7 @@ class OceanBookingServiceTest {
void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
objectMapper = new ObjectMapper().findAndRegisterModules();
lenient().when(documentNumbers.next("OB")).thenReturn("OB-260818-0001");
var field = OceanBookingService.class.getDeclaredField("objectMapper");
field.setAccessible(true);
field.set(service, objectMapper);
@@ -60,6 +63,7 @@ class OceanBookingServiceTest {
new CreateRequest(0, partialDraft(null)));
assertThat(result.oceanBooking().getStatus()).isEqualTo("DRAFT");
assertThat(result.oceanBooking().getBookingNo()).isEqualTo("OB-260818-0001");
assertThat(inserted.get().getCustomerId()).isNull();
verify(repository).replaceRequirements(inserted.get().getId(), List.of());
verify(repository, never()).insertContainer(any());
@@ -22,6 +22,6 @@ class ContainerFeeDetailRepositoryTest {
"getReviewReason", "getUpdatedBy", "getUpdatedAt")
.doesNotContain("getFeeItemId", "getFeeItemCodeSnapshot", "getFeeItemNameSnapshot",
"getDirection", "getPartnerId", "getPartnerName", "getCurrency",
"getSettlementIncluded", "getRemark");
"getRemark");
}
}
@@ -32,7 +32,7 @@ class FinanceFeeSourceMigrationContractTest {
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", "partner_id", "partner_name",
"source_version", "partner_id", "partner_name",
"firstleg_finance_add_column('fl_container_fee_detail', 'partner_id'", "RECEIVABLE", "PAYABLE",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'partner_id'",
"Firstleg Finance迁移失败:入库费用往来单位快照字段不完整",
@@ -31,6 +31,7 @@ class SmartLoadingSqlContractTest {
@DisplayName("平台聚合与客户明细边界及原型快照字段完整")
void shouldProvidePlatformAggregatesAndAccountScopedSnapshots() throws Exception {
assertThat(migration())
.contains("CREATE TABLE IF NOT EXISTS fl_document_sequence")
.contains("CREATE TABLE IF NOT EXISTS fl_container_booking")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_plan_item")
@@ -59,7 +59,6 @@ class ContainerFeeDetailServiceTest {
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);
@@ -92,7 +91,6 @@ class ContainerFeeDetailServiceTest {
assertThat(result.getDirection()).isEqualTo("PAYABLE");
assertThat(result.getPartnerId()).isEqualTo(71L);
assertThat(result.getCurrency()).isEqualTo("CNY");
assertThat(result.getSettlementIncluded()).isTrue();
assertThat(result.getRemark()).isEqualTo("原费用备注");
verify(feeItems, never()).findItem(anyLong());
}
@@ -170,7 +168,7 @@ class ContainerFeeDetailServiceTest {
var missingPartner = new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
true, null, null, "整柜拖车费");
null, null, "整柜拖车费");
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, missingPartner))
.isInstanceOf(BizException.class).hasMessageContaining("必须选择有效往来单位");
@@ -184,7 +182,7 @@ class ContainerFeeDetailServiceTest {
var request = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
true, 71L, "不应保留", "整柜操作费");
71L, "不应保留", "整柜操作费");
service.create(10001L, 41L, 9L, request);
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
@@ -225,7 +223,6 @@ class ContainerFeeDetailServiceTest {
value.setPartnerId(71L);
value.setPartnerName("现有往来单位");
value.setCurrency("CNY");
value.setSettlementIncluded(true);
value.setRemark("原费用备注");
value.setBusinessDate(LocalDate.of(2026, 8, 11));
value.setAmount(new BigDecimal("300.00"));
@@ -239,9 +236,9 @@ class ContainerFeeDetailServiceTest {
return new AdjustRequest(version, LocalDate.of(2026, 8, 12), new BigDecimal(amount));
}
private static ContainerFeeDetailDtos.SaveRequest request(boolean settlementIncluded) {
private static ContainerFeeDetailDtos.SaveRequest request(boolean ignored) {
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
settlementIncluded, 71L, " 现有往来单位 ", "整柜拖车费");
71L, " 现有往来单位 ", "整柜拖车费");
}
}
@@ -54,11 +54,14 @@ class ContainerServiceTest {
@MockBean private PriceBookQueryRepository priceBooks;
@MockBean private OceanBookingRepository oceanBookings;
@MockBean private WarehouseSupplierSnapshotService suppliers;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private ContainerTransportHistoryMapper transportHistories;
@Resource private ContainerService service;
@BeforeEach
void setUpWarehouse() {
when(documentNumbers.next("CB")).thenReturn("CB-260818-0001");
when(documentNumbers.next("CAB")).thenReturn("CAB-260818-0001");
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
when(suppliers.trucking(202L, "拖车公司A"))
@@ -77,8 +80,8 @@ class ContainerServiceTest {
verify(repository).insert(captor.capture());
ContainerBookingEntity saved = captor.getValue();
assertThat(created).isSameAs(saved);
assertThat(saved.getBookingNo()).startsWith("CB");
assertThat(saved.getCabinetNo()).startsWith("CAB-");
assertThat(saved.getBookingNo()).isEqualTo("CB-260818-0001");
assertThat(saved.getCabinetNo()).isEqualTo("CAB-260818-0001");
assertThat(saved.getContainerNo()).isNull();
assertThat(saved.getOceanBookingId()).isNull();
assertThat(saved.getChannelName()).isEqualTo("美西海运");
@@ -220,7 +220,6 @@ class FeeManagementServiceTest {
assertThat(saved.getUnitPrice()).isEqualByComparingTo("2.1198");
assertThat(saved.getReviewStatus()).isEqualTo("APPROVED");
assertThat(saved.getPublicationStatus()).isEqualTo("PUBLISHED");
assertThat(saved.getSettlementIncluded()).isTrue();
}
@Test
@@ -324,16 +323,24 @@ class FeeManagementServiceTest {
@Test
@DisplayName("审核只更新待审核明细")
void approvesPendingDetail() {
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(new InboundFeeDetailEntity());
InboundFeeDetailEntity pending = new InboundFeeDetailEntity();
pending.setId(31L);
pending.setTenantId(10001L);
pending.setInboundId(21L);
pending.setSourceType("MANUAL");
pending.setSourceFeeVersion(1);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(pending);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
details.approve(10001L, 21L, 31L, 9L, "admin");
verify(repository).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null);
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
@Test
@DisplayName("审核通过且参与结算的人工费用写入Finance事件")
@DisplayName("审核通过的人工费用自动写入Finance事件")
void publishesApprovedSettlementFee() {
InboundFeeDetailEntity approved = new InboundFeeDetailEntity();
approved.setId(31L);
@@ -342,7 +349,6 @@ class FeeManagementServiceTest {
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);
@@ -409,7 +415,6 @@ class FeeManagementServiceTest {
current.setFeeItemId(11L);
current.setSourceType("MANUAL");
current.setSourceFeeVersion(1);
current.setSettlementIncluded(true);
InboundFeeDetailEntity voided = new InboundFeeDetailEntity();
voided.setId(31L);
voided.setTenantId(10001L);
@@ -417,7 +422,6 @@ class FeeManagementServiceTest {
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);
@@ -3,10 +3,10 @@ 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.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.ContainerFeeSourceSnapshotEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.ContainerFeeSourceSnapshotRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import org.junit.jupiter.api.Test;
@@ -19,7 +19,7 @@ import static org.mockito.Mockito.when;
class FinanceFeeSourceSnapshotServiceTest {
private final FeeItemRepository inboundFees = mock(FeeItemRepository.class);
private final ContainerFeeDetailRepository containerFees = mock(ContainerFeeDetailRepository.class);
private final ContainerFeeSourceSnapshotRepository containerFees = mock(ContainerFeeSourceSnapshotRepository.class);
private final FinanceFeeSourceSnapshotService service =
new FinanceFeeSourceSnapshotService(inboundFees, containerFees);
@@ -37,8 +37,7 @@ class FinanceFeeSourceSnapshotServiceTest {
detail.setCurrency("CNY");
detail.setReviewStatus("APPROVED");
detail.setPublicationStatus("PUBLISHED");
detail.setSettlementIncluded(true);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
when(inboundFees.findSourceVersion(21L, 31L, 2)).thenReturn(detail);
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(21L);
inbound.setOrderId(81L);
@@ -50,53 +49,53 @@ class FinanceFeeSourceSnapshotServiceTest {
item.setFeeCode("STORAGE");
when(inboundFees.findItem(11L)).thenReturn(item);
FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L);
FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L, 2);
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());
assertThat(service.inbound(21L, 31L, 2).sourceHash()).isEqualTo(snapshot.sourceHash());
}
@Test
void rejectsFeeThatDoesNotBelongToInboundPath() {
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
detail.setInboundId(22L);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
when(inboundFees.findSourceVersion(21L, 31L, 1)).thenReturn(detail);
assertThatThrownBy(() -> service.inbound(21L, 31L))
assertThatThrownBy(() -> service.inbound(21L, 31L, 1))
.isInstanceOf(BizException.class).hasMessageContaining("不存在");
}
@Test
void containerSnapshotPublishesPayableCounterpartyIdentity() {
ContainerFeeDetailEntity detail = new ContainerFeeDetailEntity();
detail.setId(51L);
ContainerFeeSourceSnapshotEntity detail = new ContainerFeeSourceSnapshotEntity();
detail.setSourceFeeId(51L);
detail.setTenantId(10001L);
detail.setLoadingPlanId(41L);
detail.setContainerBookingId(61L);
detail.setContainerNo("CONT-41");
detail.setFeeItemId(11L);
detail.setFeeItemCodeSnapshot("TRUCKING");
detail.setFeeItemNameSnapshot("拖车费");
detail.setFeeItemCode("TRUCKING");
detail.setFeeItemName("拖车费");
detail.setDirection("PAYABLE");
detail.setPartnerId(71L);
detail.setPartnerName("现有往来单位");
detail.setSupplierId(71L);
detail.setSupplierName("现有供应商");
detail.setSupplierCategory("TRUCKING");
detail.setBusinessDate(java.time.LocalDate.of(2026, 8, 12));
detail.setAmount(new BigDecimal("300.00"));
detail.setCurrency("CNY");
detail.setReviewStatus("APPROVED");
detail.setSourceStatus("ACTIVE");
detail.setSourceVersion(2);
detail.setSettlementIncluded(true);
when(containerFees.find(41L, 51L)).thenReturn(detail);
when(containerFees.find(41L, 51L, 2)).thenReturn(detail);
FinanceFeeSourceSnapshot snapshot = service.container(41L, 51L);
FinanceFeeSourceSnapshot snapshot = service.container(41L, 51L, 2);
assertThat(snapshot.partnerId()).isEqualTo(71L);
assertThat(snapshot.partnerName()).isEqualTo("现有往来单位");
assertThat(snapshot.supplierId()).isEqualTo(71L);
assertThat(snapshot.supplierName()).isEqualTo("现有供应商");
assertThat(snapshot.supplierCategory()).isEqualTo("TRUCKING");
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
}
}
@@ -0,0 +1,48 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FirstlegDocumentNumberGeneratorTest {
private static final Clock CLOCK = Clock.fixed(
Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai"));
@Test
void formatsAllOperationalDocumentPrefixes() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class))
.thenReturn(1L, 1L, 1L, 1L, 1L);
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
assertThat(generator.next("OB")).isEqualTo("OB-260818-0001");
assertThat(generator.next("TA")).isEqualTo("TA-260818-0001");
assertThat(generator.next("CB")).isEqualTo("CB-260818-0001");
assertThat(generator.next("CAB")).isEqualTo("CAB-260818-0001");
assertThat(generator.next("LP")).isEqualTo("LP-260818-0001");
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"),
eq("OB:20260818"));
}
@Test
void rejectsUnsupportedTypeAndDailyOverflow() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK);
assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class);
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L);
assertThatThrownBy(() -> generator.next("CAB")).isInstanceOf(BizException.class);
}
}
@@ -63,6 +63,7 @@ class SmartLoadingServiceTest {
@MockBean private LoadingPickService loadingPickService;
@MockBean private LoadingModelBridgeService loadingModelBridge;
@MockBean private SplitPreloadService splitPreloadService;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private SmartLoadingService service;
@TestConfiguration
@@ -73,6 +74,7 @@ class SmartLoadingServiceTest {
@BeforeEach
void setUp() {
when(documentNumbers.next("LP")).thenReturn("LP-260818-0001");
when(loadingPickService.mutationSnapshot(anyLong())).thenReturn(
new LoadingPickService.MutationSnapshot(null, null, null, Map.of()));
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
@@ -174,6 +176,22 @@ class SmartLoadingServiceTest {
verify(repository, never()).insert(any(LoadingPlanEntity.class));
}
@Test
void createsPlanWithShortStableNumber() {
when(repository.booking(99L)).thenReturn(booking());
when(repository.planByBooking(99L)).thenReturn(null);
doAnswer(invocation -> {
LoadingPlanEntity value = invocation.getArgument(0);
value.setId(1L);
return 1;
}).when(repository).insert(any(LoadingPlanEntity.class));
when(repository.items(1L)).thenReturn(List.of());
Plan result = service.create(99L, "备注", 7L, "u");
assertThat(result.planNo()).isEqualTo("LP-260818-0001");
}
@Test
void rejectsExcessBoxes() {
LoadingPlanEntity plan = plan("EDITING", 0);
@@ -47,11 +47,13 @@ class TruckAppointmentServiceTest {
@Mock WarehouseSupplierSnapshotService suppliers;
@Mock IamCustomerSnapshotService customers;
@Mock TruckAppointmentFileClient files;
@Mock FirstlegDocumentNumberGenerator documentNumbers;
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
@InjectMocks TruckAppointmentService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this);
service.internalToken = "test-internal-token";
lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001");
when(customers.active(anyLong(), any())).thenAnswer(invocation ->
new IamCustomerSnapshotClient.Customer(invocation.getArgument(0), "C-7",
"Canonical Customer", "ACTIVE", null, null));
@@ -107,6 +109,7 @@ class TruckAppointmentServiceTest {
assertThat(entity.getValue().getLoadingPlanId()).isNull();
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier");
assertThat(entity.getValue().getAppointmentNo()).isEqualTo("TA-260818-0001");
verifyNoInteractions(oceanBookings, containers, loadingPlans);
}