客户运费计费

This commit is contained in:
2026-08-05 20:32:43 +08:00
parent a11e47fe3b
commit 4df8b8c355
15 changed files with 608 additions and 4 deletions
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
@@ -12,7 +14,8 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
List<ImageReference> measurementImages,
List<ImageReference> weightImages,
BillingBasis billingBasis,
FeeSnapshotResponse latestFee) {
FeeSnapshotResponse latestFee,
PublishedFeeDetail publishedFeeDetail) {
public record MeasurementSummary(
Long measurementId, String sourceType, String measurementStatus,
@@ -45,4 +48,21 @@ public record MerchantInboundBillingResponse(InboundOrderDetail inbound,
BigDecimal convertedQuantity, BigDecimal chargeableQuantity,
BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm) {
}
/** 后台和客户共同读取的同一条已发布入库运费明细。 */
public record PublishedFeeDetail(
Long id, Long inboundId, String inboundCode,
Long sourceFeeId, Integer sourceFeeVersion, String sourceFeeNo,
String direction, LocalDate businessDate,
Long customerId, String customerName, String invoiceTitle,
String channelCode, String channelName,
String destinationCode, String destinationName,
Integer packageCount, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
BigDecimal volumeWeightKg, BigDecimal chargeableWeightKg, BigDecimal chargeableVolumeCbm,
BigDecimal chargeableQuantity, String chargeUnit,
String feeTypeName, BigDecimal unitPrice, String currency, BigDecimal amount,
String reviewStatus, String publicationStatus,
LocalDateTime publishedAt, LocalDateTime inboundedAt, Boolean settlementIncluded
) {
}
}
@@ -17,6 +17,7 @@ public class FeeItemEntity extends BaseEntity {
private Long tenantId;
private String category;
private String tradeScope;
private String feeCode;
private String feeTypeName;
private Boolean pickupPayable;
private Boolean pickupReceivable;
@@ -21,9 +21,16 @@ public class InboundFeeDetailEntity extends BaseEntity {
private Long tenantId;
private Long inboundId;
private Long feeItemId;
private String sourceType;
private Long sourceFeeId;
private Integer sourceFeeVersion;
private String sourceFeeNo;
private String inboundCode;
private String direction;
private LocalDate businessDate;
private Long customerId;
private String customerName;
private String invoiceTitle;
private String feeCategorySnapshot;
private String tradeScopeSnapshot;
private String feeTypeNameSnapshot;
@@ -37,6 +44,8 @@ public class InboundFeeDetailEntity extends BaseEntity {
private BigDecimal volumeWeightKg;
private BigDecimal chargeableWeightKg;
private BigDecimal chargeableVolumeCbm;
private BigDecimal chargeableQuantity;
private String chargeUnit;
private BigDecimal unitPrice;
private String currency;
private BigDecimal amount;
@@ -46,4 +55,10 @@ public class InboundFeeDetailEntity extends BaseEntity {
private String reviewerName;
private LocalDateTime reviewedAt;
private String reviewReason;
private String publicationStatus;
private Long publishedBy;
private String publishedByName;
private LocalDateTime publishedAt;
private LocalDateTime inboundedAt;
private Boolean settlementIncluded;
}
@@ -47,6 +47,12 @@ public class FeeItemRepository {
.eq(FeeItemEntity::getId, id));
}
public FeeItemEntity findItemByCode(long tenantId, String feeCode) {
return feeItems.selectOne(new LambdaQueryWrapper<FeeItemEntity>()
.eq(FeeItemEntity::getTenantId, tenantId)
.eq(FeeItemEntity::getFeeCode, feeCode));
}
public boolean itemNameExists(long tenantId, String tradeScope, String feeTypeName, Long excludedId) {
return feeItems.selectCount(new LambdaQueryWrapper<FeeItemEntity>()
.eq(FeeItemEntity::getTenantId, tenantId)
@@ -98,6 +104,36 @@ public class FeeItemRepository {
.eq(InboundFeeDetailEntity::getId, detailId));
}
public InboundFeeDetailEntity findBySourceFee(long tenantId, String sourceType, long sourceFeeId) {
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
.eq(InboundFeeDetailEntity::getSourceType, sourceType)
.eq(InboundFeeDetailEntity::getSourceFeeId, sourceFeeId));
}
public InboundFeeDetailEntity findPublishedFreight(long tenantId, long inboundId) {
return feeDetails.selectOne(new LambdaQueryWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
.eq(InboundFeeDetailEntity::getSourceType, "FBA_PRICING")
.eq(InboundFeeDetailEntity::getPublicationStatus, "PUBLISHED")
.orderByDesc(InboundFeeDetailEntity::getSourceFeeVersion)
.last("LIMIT 1"));
}
public int supersedePublishedFreight(long tenantId, long inboundId, long retainedSourceFeeId,
long operatorId, LocalDateTime now) {
return feeDetails.update(null, new LambdaUpdateWrapper<InboundFeeDetailEntity>()
.eq(InboundFeeDetailEntity::getTenantId, tenantId)
.eq(InboundFeeDetailEntity::getInboundId, inboundId)
.eq(InboundFeeDetailEntity::getSourceType, "FBA_PRICING")
.eq(InboundFeeDetailEntity::getPublicationStatus, "PUBLISHED")
.ne(InboundFeeDetailEntity::getSourceFeeId, retainedSourceFeeId)
.set(InboundFeeDetailEntity::getPublicationStatus, "SUPERSEDED")
.set(InboundFeeDetailEntity::getUpdatedBy, operatorId)
.set(InboundFeeDetailEntity::getUpdatedAt, now));
}
public void insertDetail(InboundFeeDetailEntity detail) {
feeDetails.insert(detail);
}
@@ -37,6 +37,8 @@ public class FbaPricingService {
private FbaPricingRepository repository;
@Resource
private Clock clock;
@Resource
private InboundFeeDetailService feeDetails;
@Transactional
public FeeSnapshotResponse calculate(long tenantId, long operatorId, long inboundId,
@@ -120,12 +122,17 @@ public class FbaPricingService {
String hash = hash("SEND", inboundId, current.getId().toString());
InboundPricingIdempotencyEntity token = beginIdempotency(
tenantId, operatorId, inboundId, "FEE_SEND", idempotencyKey, hash);
if ("COMPLETED".equals(token.getStatus())) return response(tenantId, token.getResultResourceId(), "WAIT_PALLET");
if ("COMPLETED".equals(token.getStatus())) {
feeDetails.publishFreight(tenantId, inboundId, token.getResultResourceId(),
operatorId, null, LocalDateTime.now(clock));
return response(tenantId, token.getResultResourceId(), "WAIT_PALLET");
}
LocalDateTime now = LocalDateTime.now(clock);
if (repository.sendFee(tenantId, current.getId(), operatorId, now) != 1
|| repository.markInboundWaitingPallet(tenantId, inboundId) != 1) {
throw conflict("费用未确认或入库单状态已变化,不能发送");
}
feeDetails.publishFreight(tenantId, inboundId, current.getId(), operatorId, null, now);
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
@@ -207,6 +214,7 @@ public class FbaPricingService {
extra.setRemark(request.reason().trim());
repository.insertItem(extra);
}
feeDetails.publishFreight(tenantId, inboundId, adjusted.getId(), operatorId, null, now);
InboundOutboxEntity event = new InboundOutboxEntity();
event.setTenantId(tenantId);
event.setAggregateType("INBOUND");
@@ -27,6 +27,8 @@ public class InboundCompletionOrchestrator {
private InboundWorkflowRepository repository;
@Resource
private InboundIdempotencyService idempotency;
@Resource
private InboundFeeDetailService feeDetails;
/**
* 任务5对接方法一:锁定发送动作。允许前置状态仅为 FEE_CONFIRMING + CONFIRMED。
@@ -87,6 +89,8 @@ 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());
repository.insertHistory(MeasurementReviewService.history(tenantId, inboundId,
"INBOUNDING", "INBOUNDED", "SEND_FEE_COMPLETE", null, operatorId, operatorName));
repository.insertOutbox(completedEvent(tenantId, inboundId, feeId));
@@ -4,27 +4,121 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos.SaveRequest;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
/** 入库单人工费用明细应用服务。 */
@Service
public class InboundFeeDetailService {
private static final String FREIGHT_FEE_CODE = "FBA_HEAD_FREIGHT";
@Resource
private FeeItemRepository repository;
@Resource
private FbaPricingRepository pricingRepository;
public List<InboundFeeDetailEntity> list(long tenantId, long inboundId) {
requiredInbound(tenantId, inboundId);
return repository.listDetails(tenantId, inboundId);
}
public InboundFeeDetailEntity publishedFreight(long tenantId, long inboundId) {
requiredInbound(tenantId, inboundId);
InboundFeeDetailEntity detail = repository.findPublishedFreight(tenantId, inboundId);
if (detail == null) throw new BizException(ResultCode.NOT_FOUND, "入库运费明细尚未发布");
return detail;
}
/** 将计费快照发布为后台和客户共同读取的入库应收运费明细。 */
@Transactional
public InboundFeeDetailEntity publishFreight(long tenantId, long inboundId, long feeId,
long operatorId, String operatorName,
LocalDateTime publishedAt) {
InboundFeeDetailEntity existing = repository.findBySourceFee(tenantId, "FBA_PRICING", feeId);
if (existing != null) return existing;
InboundOrderEntity inbound = requiredInbound(tenantId, inboundId);
InboundFeeEntity fee = pricingRepository.findFee(tenantId, feeId);
if (fee == null || !Long.valueOf(inboundId).equals(fee.getInboundId())) {
throw new BizException(ResultCode.NOT_FOUND, "待发布的入库计费快照不存在");
}
if (!"SENT".equals(fee.getFeeStatus())) {
throw conflict("只有已发送状态的计费快照才能生成客户运费明细");
}
LocalDateTime now = publishedAt == null ? LocalDateTime.now() : publishedAt;
FeeItemEntity feeItem = requiredFreightItem(tenantId, operatorId, operatorName);
InboundFeeItemEntity baseItem = pricingRepository.findBaseItem(tenantId, feeId);
repository.supersedePublishedFreight(tenantId, inboundId, feeId, operatorId, now);
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
detail.setTenantId(tenantId);
detail.setInboundId(inboundId);
detail.setFeeItemId(feeItem.getId());
detail.setSourceType("FBA_PRICING");
detail.setSourceFeeId(feeId);
detail.setSourceFeeVersion(fee.getFeeVersion());
detail.setSourceFeeNo(fee.getFeeNo());
detail.setInboundCode(inbound.getInboundCode());
detail.setDirection("RECEIVABLE");
detail.setBusinessDate(now.toLocalDate());
detail.setCustomerId(inbound.getCustomerId());
detail.setCustomerName(inbound.getCustomerName());
detail.setInvoiceTitle(inbound.getCompanyName());
detail.setFeeCategorySnapshot(feeItem.getCategory());
detail.setTradeScopeSnapshot(feeItem.getTradeScope());
detail.setFeeTypeNameSnapshot(feeItem.getFeeTypeName());
detail.setChannelCode(inbound.getChannelCode());
detail.setChannelName(inbound.getChannelName());
detail.setDestinationCode(inbound.getDestinationCode());
detail.setDestinationName(inbound.getDestinationName());
detail.setPackageCount(inbound.getTotalPackageCount());
detail.setActualGrossWeightKg(fee.getActualWeightKg());
detail.setActualVolumeCbm(fee.getActualVolumeCbm());
detail.setVolumeWeightKg(inbound.getVolumeWeightKg());
detail.setChargeableWeightKg("KG".equals(fee.getChargeUnit())
? fee.getChargeableQuantity() : inbound.getChargeableWeightKg());
detail.setChargeableVolumeCbm("CBM".equals(fee.getChargeUnit())
? fee.getChargeableQuantity() : inbound.getChargeableVolumeCbm());
detail.setChargeableQuantity(fee.getChargeableQuantity());
detail.setChargeUnit(fee.getChargeUnit());
detail.setUnitPrice(effectiveUnitPrice(fee));
detail.setCurrency(fee.getCurrency());
detail.setAmount(fee.getTotalAmount().setScale(2, RoundingMode.HALF_UP));
detail.setRemark(baseItem == null ? "来源费用单:" + fee.getFeeNo()
: "来源费用单:" + fee.getFeeNo() + ";基础单价:" + baseItem.getUnitPrice());
detail.setReviewStatus("APPROVED");
detail.setReviewerId(operatorId);
detail.setReviewerName(operatorName);
detail.setReviewedAt(now);
detail.setPublicationStatus("PUBLISHED");
detail.setPublishedBy(operatorId);
detail.setPublishedByName(operatorName);
detail.setPublishedAt(now);
detail.setInboundedAt(inbound.getInboundedAt());
detail.setSettlementIncluded(true);
detail.setCreatedBy(operatorId);
detail.setUpdatedBy(operatorId);
try {
repository.insertDetail(detail);
return detail;
} catch (DuplicateKeyException exception) {
InboundFeeDetailEntity concurrent = repository.findBySourceFee(
tenantId, "FBA_PRICING", feeId);
if (concurrent != null) return concurrent;
throw exception;
}
}
@Transactional
public InboundFeeDetailEntity create(long tenantId, long inboundId, long operatorId,
SaveRequest request) {
@@ -36,6 +130,9 @@ public class InboundFeeDetailService {
value.setCreatedBy(operatorId);
apply(value, inbound, item, request, operatorId);
value.setReviewStatus("PENDING");
value.setSourceType("MANUAL");
value.setPublicationStatus("DRAFT");
value.setSettlementIncluded(false);
repository.insertDetail(value);
return value;
}
@@ -95,6 +192,46 @@ public class InboundFeeDetailService {
return value;
}
private FeeItemEntity requiredFreightItem(long tenantId, long operatorId, String operatorName) {
FeeItemEntity item = repository.findItemByCode(tenantId, FREIGHT_FEE_CODE);
if (item != null) {
if (!"ACTIVE".equals(item.getStatus())) throw conflict("系统费用项目FBA头程运费已停用");
return item;
}
FeeItemEntity created = new FeeItemEntity();
created.setTenantId(tenantId);
created.setCategory("头程运费");
created.setTradeScope("INTERNATIONAL");
created.setFeeCode(FREIGHT_FEE_CODE);
created.setFeeTypeName("FBA头程运费");
created.setPickupPayable(false);
created.setPickupReceivable(false);
created.setWarehousePayable(false);
created.setWarehouseReceivable(true);
created.setAllocationFee(false);
created.setDescription("系统计费发布时自动维护,不允许用于应付费用");
created.setStatus("ACTIVE");
created.setCreatedBy(operatorId);
created.setUpdatedBy(operatorId);
created.setCreatedByName(operatorName);
created.setUpdatedByName(operatorName);
try {
repository.insertItem(created);
return created;
} catch (DuplicateKeyException exception) {
FeeItemEntity concurrent = repository.findItemByCode(tenantId, FREIGHT_FEE_CODE);
if (concurrent != null) return concurrent;
throw exception;
}
}
private static java.math.BigDecimal effectiveUnitPrice(InboundFeeEntity fee) {
if (fee.getChargeableQuantity() == null || fee.getChargeableQuantity().signum() <= 0) {
throw conflict("计费快照的计费量必须大于零");
}
return fee.getTotalAmount().divide(fee.getChargeableQuantity(), 4, RoundingMode.HALF_UP);
}
private static void ensureWarehouseApplicable(FeeItemEntity item, String direction) {
boolean applicable = "RECEIVABLE".equals(direction)
? Boolean.TRUE.equals(item.getWarehouseReceivable())
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.repository.InboundOrderRepository;
import org.springframework.stereotype.Service;
@@ -26,6 +27,8 @@ public class MerchantInboundBillingService {
private InboundMeasurementService measurements;
@Resource
private MeasurementImageClient imageClient;
@Resource
private InboundFeeDetailService feeDetails;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@@ -45,6 +48,7 @@ public class MerchantInboundBillingService {
.toList(), List.of(), List.of());
InboundMeasurementResponses.Detail measurement = measurements.detail(tenantId, inbound.getId());
FeeSnapshotResponse fee = pricing.latest(tenantId, inbound.getId());
InboundFeeDetailEntity publishedFee = feeDetails.publishedFreight(tenantId, inbound.getId());
var summary = new MerchantInboundBillingResponse.MeasurementSummary(
measurement.measurementId(), measurement.sourceType(), measurement.measurementStatus(),
measurement.actualPackageCount(), measurement.actualGrossWeightKg(),
@@ -78,7 +82,7 @@ public class MerchantInboundBillingService {
platformDetail.summary().chargeableWeightKg(),
platformDetail.summary().chargeableVolumeCbm());
return new MerchantInboundBillingResponse(merchantDetail, summary, measurementItems,
weightResults, measurementImages, weightImages, basis, fee);
weightResults, measurementImages, weightImages, basis, fee, publishedFee(publishedFee));
}
public ImageDownload downloadImage(long tenantId, long orderId, long fileId) {
@@ -121,6 +125,22 @@ public class MerchantInboundBillingService {
return compared > 0 ? "ACTUAL_VOLUME" : "WEIGHT_CONVERTED_VOLUME";
}
private static MerchantInboundBillingResponse.PublishedFeeDetail publishedFee(
InboundFeeDetailEntity detail) {
return new MerchantInboundBillingResponse.PublishedFeeDetail(
detail.getId(), detail.getInboundId(), detail.getInboundCode(),
detail.getSourceFeeId(), detail.getSourceFeeVersion(), detail.getSourceFeeNo(),
detail.getDirection(), detail.getBusinessDate(), detail.getCustomerId(),
detail.getCustomerName(), detail.getInvoiceTitle(), detail.getChannelCode(),
detail.getChannelName(), detail.getDestinationCode(), detail.getDestinationName(),
detail.getPackageCount(), detail.getActualGrossWeightKg(), detail.getActualVolumeCbm(),
detail.getVolumeWeightKg(), detail.getChargeableWeightKg(),
detail.getChargeableVolumeCbm(), detail.getChargeableQuantity(), detail.getChargeUnit(),
detail.getFeeTypeNameSnapshot(), detail.getUnitPrice(), detail.getCurrency(), detail.getAmount(),
detail.getReviewStatus(), detail.getPublicationStatus(), detail.getPublishedAt(),
detail.getInboundedAt(), detail.getSettlementIncluded());
}
public record ImageDownload(byte[] content, String contentType) {
}
}