From 4df8b8c3550cad454db9cd517db1e19bd67d2986 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 5 Aug 2026 20:32:43 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A2=E6=88=B7=E8=BF=90=E8=B4=B9=E8=AE=A1?= =?UTF-8?q?=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 104 +++++++++++++ .../dto/MerchantInboundBillingResponse.java | 22 ++- .../baiyue/firstleg/entity/FeeItemEntity.java | 1 + .../entity/InboundFeeDetailEntity.java | 15 ++ .../repository/FeeItemRepository.java | 36 +++++ .../firstleg/service/FbaPricingService.java | 10 +- .../InboundCompletionOrchestrator.java | 4 + .../service/InboundFeeDetailService.java | 137 ++++++++++++++++++ .../MerchantInboundBillingService.java | 22 ++- ...se2-published-freight-detail-migration.sql | 83 +++++++++++ .../main/resources/openapi/firstleg-v1.yaml | 63 +++++++- .../service/FbaPricingServiceTest.java | 2 + .../service/FeeManagementServiceTest.java | 95 ++++++++++++ .../InboundCompletionOrchestratorTest.java | 5 + .../MerchantInboundBillingServiceTest.java | 13 ++ 15 files changed, 608 insertions(+), 4 deletions(-) create mode 100644 firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 493b5412f..3bf2c83f8 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3764,6 +3764,79 @@ CREATE TABLE IF NOT EXISTS sys_audit_dead_letter ( -- ============================================================ -- 全部增量完成后的最终结构强校验;只有通过此段才输出唯一 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() @@ -3782,6 +3855,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' diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java index 82198fd99..39544286f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantInboundBillingResponse.java @@ -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 measurementImages, List 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 + ) { + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java index a57d42e35..6a62b0bf6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeDetailEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeDetailEntity.java index 8ee402c35..b7251fd90 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeDetailEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/InboundFeeDetailEntity.java @@ -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; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java index 68d586479..439b2110b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FeeItemRepository.java @@ -47,6 +47,12 @@ public class FeeItemRepository { .eq(FeeItemEntity::getId, id)); } + public FeeItemEntity findItemByCode(long tenantId, String feeCode) { + return feeItems.selectOne(new LambdaQueryWrapper() + .eq(FeeItemEntity::getTenantId, tenantId) + .eq(FeeItemEntity::getFeeCode, feeCode)); + } + public boolean itemNameExists(long tenantId, String tradeScope, String feeTypeName, Long excludedId) { return feeItems.selectCount(new LambdaQueryWrapper() .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() + .eq(InboundFeeDetailEntity::getTenantId, tenantId) + .eq(InboundFeeDetailEntity::getSourceType, sourceType) + .eq(InboundFeeDetailEntity::getSourceFeeId, sourceFeeId)); + } + + public InboundFeeDetailEntity findPublishedFreight(long tenantId, long inboundId) { + return feeDetails.selectOne(new LambdaQueryWrapper() + .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() + .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); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java index 972a8b289..60bef7c98 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaPricingService.java @@ -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"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java index d27e85257..0b66074ab 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundCompletionOrchestrator.java @@ -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)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java index 684622219..3a843f4a5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java @@ -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 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()) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java index 2486e053b..2f3a12913 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java @@ -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) { } } diff --git a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql new file mode 100644 index 000000000..2c77f22c6 --- /dev/null +++ b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql @@ -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; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index c01e047d9..d237a107b 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java index b9aff45b5..57aa55e9f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaPricingServiceTest.java @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java index c27320759..d2354a815 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java @@ -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 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() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java index 1760b04e2..0f2770e35 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCompletionOrchestratorTest.java @@ -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()); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java index 769cc32dd..ff8bd200d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantInboundBillingServiceTest.java @@ -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); }