This commit is contained in:
2026-08-06 11:49:53 +08:00
15 changed files with 608 additions and 4 deletions
@@ -4430,6 +4430,79 @@ DROP TEMPORARY TABLE IF EXISTS tmp_wh_fixed_platform_warehouse;
-- 全部增量完成后的最终结构强校验;只有通过此段才输出唯一 PASS
-- ============================================================
USE baiyue_firstleg;
DROP PROCEDURE IF EXISTS unified_publish_fee_add_column;
DELIMITER $$
CREATE PROCEDURE unified_publish_fee_add_column(
IN target_table VARCHAR(64), IN target_column VARCHAR(64), IN column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = target_table AND column_name = target_column
) THEN
SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `', target_column, '` ', column_definition);
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL unified_publish_fee_add_column('fl_fee_item', 'fee_code',
"VARCHAR(50) NULL COMMENT '稳定费用编码;系统费用使用固定编码' AFTER trade_scope");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'source_type',
"VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT 'MANUAL或FBA_PRICING' AFTER fee_item_id");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'source_fee_id',
"BIGINT UNSIGNED NULL COMMENT '来源计费快照ID' AFTER source_type");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'source_fee_version',
"INT UNSIGNED NULL COMMENT '来源计费快照版本' AFTER source_fee_id");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'source_fee_no',
"VARCHAR(40) NULL COMMENT '来源费用单号' AFTER source_fee_version");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'customer_id',
"BIGINT UNSIGNED NULL COMMENT '客户ID快照' AFTER business_date");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'customer_name',
"VARCHAR(128) NULL COMMENT '客户公司名称快照' AFTER customer_id");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'invoice_title',
"VARCHAR(256) NULL COMMENT '发票抬头快照' AFTER customer_name");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'chargeable_quantity',
"DECIMAL(18,4) NULL COMMENT '本费用最终计费量' AFTER chargeable_volume_cbm");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'charge_unit',
"VARCHAR(16) NULL COMMENT 'KG或CBM' AFTER chargeable_quantity");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'publication_status',
"VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT、PUBLISHED、SUPERSEDED' AFTER review_reason");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'published_by',
"BIGINT NULL COMMENT '发布人ID' AFTER publication_status");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'published_by_name',
"VARCHAR(64) NULL COMMENT '发布人名称快照' AFTER published_by");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'published_at',
"DATETIME(3) NULL COMMENT '发布时间' AFTER published_by_name");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'inbounded_at',
"DATETIME(3) NULL COMMENT '入库完成时间快照' AFTER published_at");
CALL unified_publish_fee_add_column('fl_inbound_fee_detail', 'settlement_included',
"TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否参与结算' AFTER inbounded_at");
DROP PROCEDURE IF EXISTS unified_publish_fee_add_column;
DROP PROCEDURE IF EXISTS unified_publish_fee_add_index;
DELIMITER $$
CREATE PROCEDURE unified_publish_fee_add_index(
IN target_table VARCHAR(64), IN target_index VARCHAR(64), IN index_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = target_table AND index_name = target_index
) THEN
SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD ', index_definition);
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL unified_publish_fee_add_index('fl_fee_item', 'uk_fee_item_code',
'UNIQUE KEY `uk_fee_item_code` (`tenant_id`, `fee_code`)');
CALL unified_publish_fee_add_index('fl_inbound_fee_detail', 'uk_inbound_fee_source',
'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`)');
CALL unified_publish_fee_add_index('fl_inbound_fee_detail', 'idx_inbound_fee_published',
'KEY `idx_inbound_fee_published` (`tenant_id`, `inbound_id`, `publication_status`, `source_fee_version`)');
DROP PROCEDURE IF EXISTS unified_publish_fee_add_index;
DROP PROCEDURE IF EXISTS unified_assert_final_extensions;
DELIMITER $$
CREATE PROCEDURE unified_assert_final_extensions()
@@ -4448,6 +4521,37 @@ BEGIN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:费用表 tenant_id 客户隔离语义未同步';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_fee_item'
AND column_name = 'fee_code') <> 1
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_fee_detail'
AND column_name IN (
'source_type', 'source_fee_id', 'source_fee_version', 'source_fee_no',
'customer_id', 'customer_name', 'invoice_title',
'chargeable_quantity', 'charge_unit', 'publication_status',
'published_by', 'published_by_name', 'published_at', 'inbounded_at',
'settlement_included')) <> 15 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:已发布入库运费明细字段未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_fee_item'
AND index_name = 'uk_fee_item_code' AND non_unique = 0) <> 2
OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_fee_detail'
AND index_name = 'uk_inbound_fee_source' AND non_unique = 0) <> 3
OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_fee_detail'
AND index_name = 'idx_inbound_fee_published') <> 4 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:已发布入库运费明细索引未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(table_name, '.', index_name))
FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg'
@@ -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) {
}
}
@@ -0,0 +1,83 @@
USE baiyue_firstleg;
SET NAMES utf8mb4;
-- 将计费快照发布为后台和客户共用的入库运费明细。
DROP PROCEDURE IF EXISTS phase2_publish_add_column;
DELIMITER $$
CREATE PROCEDURE phase2_publish_add_column(
IN target_table VARCHAR(64), IN target_column VARCHAR(64), IN column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = target_table AND column_name = target_column
) THEN
SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `', target_column, '` ', column_definition);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL phase2_publish_add_column('fl_fee_item', 'fee_code',
"VARCHAR(50) NULL COMMENT '稳定费用编码;系统费用使用固定编码' AFTER trade_scope");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_type',
"VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT 'MANUAL或FBA_PRICING' AFTER fee_item_id");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_fee_id',
"BIGINT UNSIGNED NULL COMMENT '来源计费快照ID' AFTER source_type");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_fee_version',
"INT UNSIGNED NULL COMMENT '来源计费快照版本' AFTER source_fee_id");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'source_fee_no',
"VARCHAR(40) NULL COMMENT '来源费用单号' AFTER source_fee_version");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'customer_id',
"BIGINT UNSIGNED NULL COMMENT '客户ID快照' AFTER business_date");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'customer_name',
"VARCHAR(128) NULL COMMENT '客户公司名称快照' AFTER customer_id");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'invoice_title',
"VARCHAR(256) NULL COMMENT '发票抬头快照' AFTER customer_name");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'chargeable_quantity',
"DECIMAL(18,4) NULL COMMENT '本费用最终计费量' AFTER chargeable_volume_cbm");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'charge_unit',
"VARCHAR(16) NULL COMMENT 'KG或CBM' AFTER chargeable_quantity");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'publication_status',
"VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT、PUBLISHED、SUPERSEDED' AFTER review_reason");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'published_by',
"BIGINT NULL COMMENT '发布人ID' AFTER publication_status");
CALL phase2_publish_add_column('fl_inbound_fee_detail', 'published_by_name',
"VARCHAR(64) NULL COMMENT '发布人名称快照' AFTER published_by");
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;
DELIMITER $$
CREATE PROCEDURE phase2_publish_add_index(
IN target_table VARCHAR(64), IN target_index VARCHAR(64), IN index_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = target_table AND index_name = target_index
) THEN
SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD ', index_definition);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL phase2_publish_add_index('fl_fee_item', 'uk_fee_item_code',
'UNIQUE KEY `uk_fee_item_code` (`tenant_id`, `fee_code`)');
CALL phase2_publish_add_index('fl_inbound_fee_detail', 'uk_inbound_fee_source',
'UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`, `source_type`, `source_fee_id`)');
CALL phase2_publish_add_index('fl_inbound_fee_detail', 'idx_inbound_fee_published',
'KEY `idx_inbound_fee_published` (`tenant_id`, `inbound_id`, `publication_status`, `source_fee_version`)');
DROP PROCEDURE IF EXISTS phase2_publish_add_index;
@@ -183,7 +183,11 @@ paths:
parameters:
- {name: orderId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'200':
description: 成功;publishedFeeDetail 是后台和客户共同读取的同一条已发布入库运费明细,latestFee 仅为兼容保留的计费快照
content:
application/json:
schema: {$ref: '#/components/schemas/MerchantInboundBillingEnvelope'}
'403': {description: 非当前客户或无订单查看权限}
/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:
post:
@@ -255,6 +259,63 @@ components:
in: query
schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}
schemas:
MerchantInboundBillingEnvelope:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string}
data:
type: object
properties:
inbound: {type: object, description: 入库单详情}
measurementSummary: {type: object, description: 测量汇总}
measurementItems: {type: array, items: {type: object}}
weightResults: {type: array, items: {type: object}}
measurementImages: {type: array, items: {type: object}}
weightImages: {type: array, items: {type: object}}
billingBasis: {type: object, description: 计费依据}
latestFee: {type: object, nullable: true, description: 兼容保留的最新计费快照}
publishedFeeDetail:
allOf:
- {$ref: '#/components/schemas/PublishedFreightFeeDetail'}
nullable: true
description: 后台与客户共用的已发布FBA头程运费应收明细
PublishedFreightFeeDetail:
type: object
description: 审核发布后绑定入库单的FBA头程运费应收明细
properties:
id: {type: integer, format: int64, description: 入库费用明细ID}
inboundId: {type: integer, format: int64}
inboundCode: {type: string}
sourceFeeId: {type: integer, format: int64, description: 来源计费快照ID}
sourceFeeVersion: {type: integer}
sourceFeeNo: {type: string}
direction: {type: string, enum: [RECEIVABLE]}
businessDate: {type: string, format: date}
customerId: {type: integer, format: int64}
customerName: {type: string}
invoiceTitle: {type: string, nullable: true}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
destinationCode: {type: string, nullable: true}
destinationName: {type: string, nullable: true}
packageCount: {type: integer, nullable: true}
actualGrossWeightKg: {type: number, format: double, nullable: true}
actualVolumeCbm: {type: number, format: double, nullable: true}
volumeWeightKg: {type: number, format: double, nullable: true}
chargeableWeightKg: {type: number, format: double, nullable: true}
chargeableVolumeCbm: {type: number, format: double, nullable: true}
chargeableQuantity: {type: number, format: double}
chargeUnit: {type: string, enum: [KG, CBM]}
feeTypeName: {type: string, example: FBA头程运费}
unitPrice: {type: number, format: double}
currency: {type: string, example: CNY}
amount: {type: number, format: double, description: 客户应收金额}
reviewStatus: {type: string, enum: [APPROVED]}
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:
@@ -47,6 +47,7 @@ class FbaPricingServiceTest {
private static final long INBOUND = 30001L;
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 3, 10, 0);
@MockBean private FbaPricingRepository repository;
@MockBean private InboundFeeDetailService feeDetails;
@Resource private FbaPricingService service;
@Test
@@ -172,6 +173,7 @@ class FbaPricingServiceTest {
verify(repository, times(1)).insertOutbox(any());
verify(repository, times(1)).sendFee(TENANT, 501L, OPERATOR, NOW);
verify(feeDetails, times(2)).publishFreight(TENANT, INBOUND, 501L, OPERATOR, null, NOW);
}
@Test
@@ -6,22 +6,29 @@ import com.baiyue.firstleg.dto.FeeItemDtos;
import com.baiyue.firstleg.dto.InboundFeeDetailDtos;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -31,6 +38,8 @@ import static org.mockito.Mockito.when;
class FeeManagementServiceTest {
@MockBean
private FeeItemRepository repository;
@MockBean
private FbaPricingRepository pricingRepository;
@Resource
private FeeItemService feeItems;
@Resource
@@ -96,6 +105,92 @@ class FeeManagementServiceTest {
assertThat(created.getReviewStatus()).isEqualTo("PENDING");
}
@Test
@DisplayName("发布计费快照生成客户与后台共用的入库运费明细")
void publishesSharedFreightDetail() {
InboundOrderEntity inbound = inbound();
inbound.setCustomerId(101L);
inbound.setCustomerName("客户A");
inbound.setCompanyName("客户A有限公司");
when(repository.findInbound(10001L, 21L)).thenReturn(inbound);
when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null);
when(repository.findItemByCode(10001L, "FBA_HEAD_FREIGHT")).thenReturn(null);
doAnswer(invocation -> {
FeeItemEntity value = invocation.getArgument(0);
value.setId(11L);
return null;
}).when(repository).insertItem(any());
InboundFeeEntity fee = new InboundFeeEntity();
fee.setId(501L);
fee.setInboundId(21L);
fee.setFeeNo("FBAF-21-1");
fee.setFeeVersion(1);
fee.setFeeStatus("SENT");
fee.setActualWeightKg(new BigDecimal("100.0000"));
fee.setActualVolumeCbm(new BigDecimal("1.000000"));
fee.setChargeableQuantity(new BigDecimal("167.0000"));
fee.setChargeUnit("KG");
fee.setCurrency("USD");
fee.setTotalAmount(new BigDecimal("354.00"));
when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee);
InboundFeeItemEntity base = new InboundFeeItemEntity();
base.setUnitPrice(new BigDecimal("2.0000"));
when(pricingRepository.findBaseItem(10001L, 501L)).thenReturn(base);
LocalDateTime publishedAt = LocalDateTime.of(2026, 8, 5, 12, 0);
InboundFeeDetailEntity result = details.publishFreight(
10001L, 21L, 501L, 9L, "admin", publishedAt);
ArgumentCaptor<InboundFeeDetailEntity> captor = ArgumentCaptor.forClass(InboundFeeDetailEntity.class);
verify(repository).insertDetail(captor.capture());
InboundFeeDetailEntity saved = captor.getValue();
assertThat(result).isSameAs(saved);
assertThat(saved.getFeeItemId()).isEqualTo(11L);
assertThat(saved.getSourceFeeId()).isEqualTo(501L);
assertThat(saved.getDirection()).isEqualTo("RECEIVABLE");
assertThat(saved.getAmount()).isEqualByComparingTo("354.00");
assertThat(saved.getUnitPrice()).isEqualByComparingTo("2.1198");
assertThat(saved.getReviewStatus()).isEqualTo("APPROVED");
assertThat(saved.getPublicationStatus()).isEqualTo("PUBLISHED");
assertThat(saved.getSettlementIncluded()).isTrue();
}
@Test
@DisplayName("并发发布同一计费快照时返回已生成的运费明细")
void reusesConcurrentFreightDetail() {
InboundFeeDetailEntity concurrent = new InboundFeeDetailEntity();
concurrent.setId(99L);
when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L))
.thenReturn(null, concurrent);
when(repository.findInbound(10001L, 21L)).thenReturn(inbound());
FeeItemEntity freightItem = item(true, false);
freightItem.setFeeCode("FBA_HEAD_FREIGHT");
when(repository.findItemByCode(10001L, "FBA_HEAD_FREIGHT")).thenReturn(freightItem);
InboundFeeEntity fee = new InboundFeeEntity();
fee.setId(501L);
fee.setInboundId(21L);
fee.setFeeNo("FBAF-21-1");
fee.setFeeVersion(1);
fee.setFeeStatus("SENT");
fee.setActualWeightKg(new BigDecimal("100.0000"));
fee.setActualVolumeCbm(new BigDecimal("1.000000"));
fee.setChargeableQuantity(new BigDecimal("1.0000"));
fee.setChargeUnit("CBM");
fee.setCurrency("USD");
fee.setTotalAmount(new BigDecimal("200.00"));
when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee);
doThrow(new DuplicateKeyException("duplicate source fee"))
.when(repository).insertDetail(any());
InboundFeeDetailEntity result = details.publishFreight(
10001L, 21L, 501L, 9L, "admin", LocalDateTime.of(2026, 8, 5, 12, 0));
assertThat(result).isSameAs(concurrent);
}
@Test
@DisplayName("拒绝绑定未启用仓储应收的项目")
void rejectsInapplicableItem() {
@@ -16,6 +16,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -28,6 +29,8 @@ class InboundCompletionOrchestratorTest {
private InboundWorkflowRepository repository;
@MockBean
private InboundIdempotencyService idempotency;
@MockBean
private InboundFeeDetailService feeDetails;
@Resource
private InboundCompletionOrchestrator service;
@Test
@@ -81,6 +84,8 @@ class InboundCompletionOrchestratorTest {
verify(fixture.repository).completeInbound(10001L, 10L, 9L, 5);
verify(fixture.repository).markFeeSent(10001L, 10L, 30L, 9L);
verify(feeDetails).publishFreight(eq(10001L), eq(10L), eq(30L), eq(9L), eq("操作员"),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
verify(fixture.repository).insertOutbox(any());
}
@@ -6,6 +6,7 @@ import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundMeasurementResponses;
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 jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
@@ -30,6 +31,7 @@ class MerchantInboundBillingServiceTest {
@MockBean private FbaPricingService pricing;
@MockBean private InboundMeasurementService measurements;
@MockBean private MeasurementImageClient imageClient;
@MockBean private InboundFeeDetailService feeDetails;
@Resource private MerchantInboundBillingService service;
@Test
@@ -50,6 +52,15 @@ class MerchantInboundBillingServiceTest {
when(measurement.items()).thenReturn(List.of());
when(measurement.weightResults()).thenReturn(List.of());
when(pricing.latest(10001L, 88L)).thenReturn(fee);
InboundFeeDetailEntity published = new InboundFeeDetailEntity();
published.setId(601L);
published.setInboundId(88L);
published.setSourceFeeId(501L);
published.setSourceFeeVersion(1);
published.setFeeTypeNameSnapshot("FBA头程运费");
published.setAmount(new BigDecimal("334.00"));
published.setPublicationStatus("PUBLISHED");
when(feeDetails.publishedFreight(10001L, 88L)).thenReturn(published);
when(fee.chargeMode()).thenReturn("KG");
when(fee.chargeUnit()).thenReturn("KG");
when(fee.actualWeightKg()).thenReturn(new BigDecimal("20"));
@@ -65,6 +76,8 @@ class MerchantInboundBillingServiceTest {
assertThat(result.inbound().statusHistory()).isEmpty();
assertThat(result.billingBasis().chargeableBasis()).isEqualTo("VOLUME_WEIGHT");
assertThat(result.latestFee()).isSameAs(fee);
assertThat(result.publishedFeeDetail().id()).isEqualTo(601L);
assertThat(result.publishedFeeDetail().amount()).isEqualByComparingTo("334.00");
verify(orders).findByOrderId(10001L, 77L);
}