This commit is contained in:
2026-08-07 14:46:55 +08:00
parent 830575cc92
commit b0ea1a0c5c
109 changed files with 5774 additions and 339 deletions
@@ -44,6 +44,7 @@ class InboundOrderControllerContractTest {
.contains("required: false")
.contains("InboundOrderSummary:")
.contains("required: [id, tenantId]")
.contains("pricingTaxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]")
.contains("tenantId: {type: string, pattern: '^[1-9][0-9]*$', description:")
.contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]")
.contains("platform:firstleg:inbound:list")
@@ -0,0 +1,100 @@
package com.baiyue.firstleg.controller;
import com.baiyue.firstleg.domain.PriceBookChannelCatalog;
import com.baiyue.firstleg.domain.PriceBookStatus;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.dto.PriceBookDtos;
import com.baiyue.firstleg.mapper.PriceBookQueryMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("船期价格表公共契约")
class PriceBookContractTest {
@Test
@DisplayName("固定十五个渠道且海派使用区域重量模型")
void keepsStableChannelCatalog() {
assertThat(PriceBookChannelCatalog.CHANNELS).hasSize(15);
assertThat(PriceBookChannelCatalog.CHANNELS.stream()
.map(PriceBookChannelCatalog.ChannelDefinition::channelCode))
.containsExactly(
"MATSON_CLX_TRUCK", "MATSON_MAX_TRUCK", "EXX_TRUCK", "HEDE_TRUCK", "EXX_RAIL_NY",
"OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_CHICAGO_RAIL", "OA_HOUSTON_TRUCK",
"OA_NEW_YORK_TRUCK", "OA_SAVANNAH_TRUCK", "MATSON_CLX_PARCEL", "MATSON_MAX_PARCEL",
"HEDE_PARCEL", "OA_PARCEL");
assertThat(PriceBookChannelCatalog.CHANNELS.stream()
.filter(channel -> channel.priceModel() == PriceModel.REGION_WEIGHT_TIER))
.extracting(PriceBookChannelCatalog.ChannelDefinition::channelCode)
.containsExactly("MATSON_CLX_PARCEL", "MATSON_MAX_PARCEL", "HEDE_PARCEL", "OA_PARCEL");
}
@Test
@DisplayName("报价包税维度独立于订单税模式")
void keepsPricingTaxModeSeparate() {
assertThat(PriceBookStatus.values()).containsExactly(
PriceBookStatus.PENDING_CONFIRMATION, PriceBookStatus.ACTIVE,
PriceBookStatus.SUPERSEDED, PriceBookStatus.FAILED);
assertThat(PricingTaxMode.values()).containsExactly(PricingTaxMode.TAX_INCLUDED, PricingTaxMode.TAX_EXCLUDED);
assertThat(PricingDestinationType.values()).containsExactly(PricingDestinationType.FBA, PricingDestinationType.OVERSEAS);
assertThat(PriceBookDtos.ParsedRate.class.getRecordComponents())
.extracting(component -> component.getName())
.contains("pricingTaxMode", "originWarehouseId", "destinationType", "destinationCode", "destinationWarehouseId", "destinationName", "regionCode");
assertThat(PriceBookDtos.EstimateRequest.class.getRecordComponents())
.extracting(component -> component.getName())
.contains("destinationType", "destinationWarehouseId", "destinationName");
assertThat(PriceBookDtos.CurrentChannel.class.getRecordComponents())
.extracting(component -> component.getName())
.contains("pricingTaxModes");
}
@Test
@DisplayName("渠道税模式只汇总当前版本和起运仓的生效价格行")
void scopesChannelPricingTaxModesToActiveRows() throws Exception {
Select select = PriceBookQueryMapper.class
.getMethod("findChannelTaxModes", long.class, long.class)
.getAnnotation(Select.class);
assertThat(String.join("\n", select.value()))
.contains("r.price_book_version_id = #{versionId}")
.contains("r.origin_warehouse_id = #{originWarehouseId}")
.contains("r.status = 'ACTIVE'")
.contains("r.deleted = 0")
.contains("r.pricing_tax_mode IS NOT NULL")
.contains("GROUP BY r.channel_code, r.pricing_tax_mode");
}
@Test
@DisplayName("OpenAPI冻结待确认上传、发布和客户参考估价路径")
void documentsPriceBookLifecycle() throws Exception {
String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(openapi)
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books:")
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}:")
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/publish:")
.contains("/api/merchant/v1/first-leg/pricing/channels:")
.contains("/api/merchant/v1/first-leg/pricing/estimate:")
.contains("PriceBookStatus")
.contains("FBA_WAREHOUSE_RATE")
.contains("REGION_WEIGHT_TIER")
.contains("pricingTaxMode")
.contains("PricingDestinationType")
.contains("MerchantPricingChannel")
.contains("pricingTaxModes")
.contains("destinationWarehouseId")
.contains("parseIssuesJson")
.contains("PriceBookVersionDetail")
.contains("PriceBookParseIssue")
.contains("/api/platform/v1/fba-pricing/carrier-rates/price-books/{versionId}/source")
.contains("确认并立即生效")
.contains("SUPERSEDED");
}
}
@@ -0,0 +1,50 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class FbaWarehouseRateEstimatorTest {
private final FbaWarehouseRateEstimator estimator = new FbaWarehouseRateEstimator();
@Test
void shouldUseGreaterOfActualAndVolumeWeightForKgRate() {
var estimate = estimator.estimate(rate("KG", "12.50"),
new BigDecimal("20"), new BigDecimal("0.20"));
assertThat(estimate.quantity()).isEqualByComparingTo("33.400000");
assertThat(estimate.unit()).isEqualTo("KG");
assertThat(estimate.unitPrice()).isEqualByComparingTo("12.50");
assertThat(estimate.amount()).isEqualByComparingTo("417.50");
assertThat(estimate.currency()).isEqualTo("CNY");
}
@Test
void shouldUseGreaterOfVolumeAndWeightConversionForCbmRate() {
var estimate = estimator.estimate(rate("CBM", "880"),
new BigDecimal("363"), new BigDecimal("0.50"));
assertThat(estimate.quantity()).isEqualByComparingTo("1.000000");
assertThat(estimate.amount()).isEqualByComparingTo("880.00");
}
@Test
void shouldRejectMissingWeightAndVolume() {
assertThatThrownBy(() -> estimator.estimate(rate("KG", "10"), null, BigDecimal.ZERO))
.isInstanceOf(BizException.class)
.hasMessageContaining("至少填写一项");
}
private static RateRow rate(String unit, String price) {
RateRow row = new RateRow();
row.setChargeUnit(unit);
row.setUnitPrice(new BigDecimal(price));
row.setCurrency("CNY");
return row;
}
}
@@ -0,0 +1,35 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.PriceRegion;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.NullSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class PriceRegionResolverTest {
private final PriceRegionResolver resolver = new PriceRegionResolver();
@ParameterizedTest
@CsvSource({
"90001, WEST",
"60601, CENTRAL",
"10001, EAST",
"'US 46201', EAST"
})
void shouldResolveUnitedStatesPostalCode(String postalCode, PriceRegion expected) {
assertThat(resolver.fromPostalCode(postalCode)).isEqualTo(expected);
}
@ParameterizedTest
@NullSource
@ValueSource(strings = {"", "USA"})
void shouldRejectPostalCodeWithoutDigits(String postalCode) {
assertThatThrownBy(() -> resolver.fromPostalCode(postalCode))
.isInstanceOf(BizException.class)
.hasMessageContaining("无法确定海派区域");
}
}
@@ -0,0 +1,53 @@
package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class RegionWeightTierEstimatorTest {
private final RegionWeightTierEstimator estimator = new RegionWeightTierEstimator();
private final List<RateRow> tiers = List.of(tier("12", "15"), tier("51", "13"), tier("101", "11"));
@Test
void shouldChooseHighestTierNotExceedingChargeableWeightAtBoundaries() {
assertThat(estimator.estimate(tiers, new BigDecimal("12"), null).unitPrice())
.isEqualByComparingTo("15");
assertThat(estimator.estimate(tiers, new BigDecimal("50.999999"), null).unitPrice())
.isEqualByComparingTo("15");
assertThat(estimator.estimate(tiers, new BigDecimal("51"), null).unitPrice())
.isEqualByComparingTo("13");
assertThat(estimator.estimate(tiers, new BigDecimal("101"), null).unitPrice())
.isEqualByComparingTo("11");
}
@Test
void shouldUseVolumeWeightBeforeSelectingTier() {
var estimate = estimator.estimate(tiers, new BigDecimal("20"), new BigDecimal("0.61"));
assertThat(estimate.quantity()).isEqualByComparingTo("101.870000");
assertThat(estimate.unitPrice()).isEqualByComparingTo("11");
assertThat(estimate.amount()).isEqualByComparingTo("1120.57");
}
@Test
void shouldRejectWeightBelowLowestTier() {
assertThatThrownBy(() -> estimator.estimate(tiers, new BigDecimal("11.999999"), null))
.isInstanceOf(BizException.class)
.hasMessageContaining("最低重量阶梯");
}
private static RateRow tier(String from, String price) {
RateRow row = new RateRow();
row.setChargeUnit("KG");
row.setWeightFromKg(new BigDecimal(from));
row.setUnitPrice(new BigDecimal(price));
row.setCurrency("CNY");
return row;
}
}
@@ -0,0 +1,206 @@
package com.baiyue.firstleg.pricing.importer;
import com.baiyue.firstleg.domain.PricingTaxMode;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PriceWorkbookParserTest {
private static final List<String> STANDARD_SHEETS = List.of(
"美森正班海卡", "美森加班海卡", "EXX海卡", "合德海卡", "纽约EXX海铁",
"洛杉矶普船海卡", "洛杉矶普船全卡", "芝加哥普船海铁", "休斯顿普船海卡",
"纽约普船海卡", "萨凡纳普船海卡");
@Test
void parsesFifteenChannelsAndIgnoresMalformedQuickQueryTable() throws Exception {
Path workbook = malformedWorkbook();
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(workbook);
assertEquals(15, result.channels().size());
assertFalse(result.hasBlockingErrors());
assertTrue(result.ignoredSheets().contains("快速查询"));
PriceWorkbookParseResult.ParsedChannel matson = result.channels().stream()
.filter(channel -> channel.channelCode().equals("MATSON_CLX_TRUCK")).findFirst().orElseThrow();
List<PriceWorkbookParseResult.ParsedRate> ont = matson.rates().stream()
.filter(rate -> "ONT8".equals(rate.destinationCode())).toList();
assertEquals(3, ont.size());
assertTrue(ont.stream().anyMatch(rate -> rate.originWarehouseKey().equals("YIWU")
&& rate.taxMode() == PricingTaxMode.TAX_EXCLUDED
&& rate.chargeUnit().equals("CBM")
&& rate.unitPrice().compareTo(new BigDecimal("1790")) == 0));
assertTrue(ont.stream().anyMatch(rate -> rate.originWarehouseKey().equals("DONGGUAN")
&& rate.taxMode() == PricingTaxMode.TAX_INCLUDED
&& rate.unitPrice().compareTo(new BigDecimal("10.7")) == 0));
PriceWorkbookParseResult.ParsedChannel parcel = result.channels().stream()
.filter(channel -> channel.channelCode().equals("MATSON_CLX_PARCEL")).findFirst().orElseThrow();
assertEquals(18, parcel.rates().size());
assertTrue(parcel.rates().stream().allMatch(rate -> rate.taxMode() == PricingTaxMode.TAX_INCLUDED));
assertTrue(parcel.rates().stream().anyMatch(rate -> rate.originWarehouseKey().equals("DONGGUAN")
&& rate.regionCode().equals("EAST")
&& rate.minimumWeightKg().compareTo(BigDecimal.valueOf(101)) == 0), parcel.rates().toString());
PriceWorkbookParseResult.ParsedChannel hedeParcel = result.channels().stream()
.filter(channel -> channel.channelCode().equals("HEDE_PARCEL")).findFirst().orElseThrow();
assertTrue(hedeParcel.rates().stream().anyMatch(rate -> rate.originWarehouseKey().equals("YIWU")
&& rate.regionCode().equals("WEST")
&& rate.minimumWeightKg().compareTo(BigDecimal.valueOf(12)) == 0
&& rate.unitPrice().compareTo(new BigDecimal("15.8")) == 0), hedeParcel.rates().toString());
}
@Test
void keepsUnknownDestinationAsBlockingMappingIssueInsteadOfDroppingTheRow() throws Exception {
Path workbook = minimalStandardWorkbook("未知仓组", "1790", "10.2", "10.7");
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(workbook);
assertTrue(result.hasBlockingErrors());
assertTrue(result.issues().stream().anyMatch(issue -> issue.code().equals("DESTINATION_MAPPING_REQUIRED")));
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
.anyMatch(rate -> rate.rawDestinationGroup().equals("未知仓组") && rate.destinationCode() == null));
}
@Test
void parsesPlatformOverseasWarehouseRowsWithoutGuessingWarehouseIdOrCode() throws Exception {
Path workbook = minimalStandardWorkbook("海外仓(洛杉矶仓)", "1790", "10.2", "10.7");
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(workbook);
assertFalse(result.hasBlockingErrors());
List<PriceWorkbookParseResult.ParsedRate> rates = result.channels().stream()
.flatMap(channel -> channel.rates().stream()).toList();
assertEquals(3, rates.size());
assertTrue(rates.stream().allMatch(rate -> "OVERSEAS".equals(rate.destinationType())
&& "洛杉矶仓".equals(rate.destinationName())
&& rate.destinationCode() == null
&& rate.rawDestinationGroup().equals("海外仓(洛杉矶仓)")));
}
@Test
void parsesTheRealWorkbookWhenAnExternalPathIsProvided() throws Exception {
String configured = System.getProperty("price.workbook");
Assumptions.assumeTrue(configured != null && !configured.isBlank());
PriceWorkbookParseResult result = new PriceWorkbookParser().parse(Path.of(configured));
System.out.printf("REAL_WORKBOOK channels=%d rates=%d issues=%d blocking=%d ignored=%s%n",
result.channels().size(), result.rateCount(), result.issues().size(),
result.issues().stream().filter(issue -> issue.severity() == PriceWorkbookParseResult.Severity.ERROR).count(),
result.ignoredSheets());
result.issues().forEach(issue -> System.out.printf("REAL_ISSUE %s %s sheet=%s row=%s raw=%s%n",
issue.severity(), issue.code(), issue.sheetName(), issue.rowNumber(), issue.rawValue()));
assertEquals(15, result.channels().size());
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
.anyMatch(rate -> "ONT8".equals(rate.destinationCode())));
assertTrue(result.channels().stream().anyMatch(channel -> channel.channelCode().equals("OA_PARCEL")));
assertFalse(result.hasBlockingErrors());
assertFalse(result.issues().stream().anyMatch(issue ->
issue.code().equals("OVERSEAS_WAREHOUSE_NOT_INCLUDED")));
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
.anyMatch(rate -> "OVERSEAS".equals(rate.destinationType())
&& "洛杉矶仓".equals(rate.destinationName())
&& rate.destinationCode() == null));
assertTrue(result.channels().stream().flatMap(channel -> channel.rates().stream())
.anyMatch(rate -> "OVERSEAS".equals(rate.destinationType())
&& "纽约仓".equals(rate.destinationName())
&& rate.destinationCode() == null));
}
private static Path malformedWorkbook() throws IOException {
Path valid = minimalWorkbook();
Path malformed = Files.createTempFile("price-parser-malformed-", ".xlsx");
try (ZipInputStream input = new ZipInputStream(Files.newInputStream(valid));
OutputStream output = Files.newOutputStream(malformed);
ZipOutputStream zip = new ZipOutputStream(output)) {
ZipEntry entry;
while ((entry = input.getNextEntry()) != null) {
zip.putNextEntry(new ZipEntry(entry.getName()));
input.transferTo(zip);
zip.closeEntry();
}
zip.putNextEntry(new ZipEntry("xl/tables/table1.xml"));
zip.write("<table xmlns=\"http://schemas.openxmlformats.org/spreadsheetml/2006/main\" ref=\"5:31\"/>".getBytes());
zip.closeEntry();
} finally {
Files.deleteIfExists(valid);
}
return malformed;
}
private static Path minimalWorkbook() throws IOException {
Path file = Files.createTempFile("price-parser-", ".xlsx");
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
for (int i = 0; i < STANDARD_SHEETS.size(); i++) {
String sheetName = STANDARD_SHEETS.get(i);
var sheet = workbook.createSheet(sheetName);
sheet.createRow(1).createCell(0).setCellValue("船名航次:DANIEL K. INOUYE V.100E 上海出,预计开船日期:2026年8月12日");
boolean shifted = sheetName.equals("纽约EXX海铁");
int base = shifted ? 1 : 0;
sheet.createRow(3).createCell(base).setCellValue("FBA仓库");
sheet.getRow(3).createCell(base + 1).setCellValue("华东仓(义乌)");
sheet.getRow(3).createCell(base + 3).setCellValue("华南仓(深圳)");
var row = sheet.createRow(5);
row.createCell(base).setCellValue("ONT8/LGB8");
row.createCell(base + 1).setCellValue(1790);
row.createCell(base + 2).setCellValue(10.2);
row.createCell(base + 3).setCellValue(10.7);
row.createCell(base + 4).setCellValue("20天左右");
row.createCell(base + 5).setCellValue("周日全天");
row.createCell(base + 6).setCellValue("MATSON 上海周三开");
}
var parcel = workbook.createSheet("海派");
parcel.createRow(4).createCell(2).setCellValue("MASTON 周三开");
parcel.getRow(4).createCell(5).setCellValue("MASTON 周四开");
parcel.getRow(4).createCell(8).setCellValue("合德 周四开");
parcel.getRow(4).createCell(11).setCellValue("普船统配");
parcel.createRow(5).createCell(2).setCellValue("船开后15天左右");
parcel.createRow(8).createCell(0).setCellValue("美西");
parcel.createRow(9).createCell(0).setCellValue("美中");
parcel.createRow(10).createCell(0).setCellValue("美东");
for (int row = 8; row <= 10; row++) {
for (int col = 2; col <= 13; col++) parcel.getRow(row).createCell(col).setCellValue(15 + col / 10.0);
}
parcel.createRow(13).createCell(2).setCellValue("MASTON 周三开");
parcel.createRow(14).createCell(2).setCellValue("船开后15天左右");
parcel.createRow(17).createCell(0).setCellValue("美西");
parcel.createRow(18).createCell(0).setCellValue("美中");
parcel.createRow(19).createCell(0).setCellValue("美东");
for (int row = 17; row <= 19; row++) {
for (int col = 2; col <= 13; col++) parcel.getRow(row).createCell(col).setCellValue(16 + col / 10.0);
}
var quick = workbook.createSheet("快速查询");
quick.createRow(4).createCell(0).setCellValue("列1");
try (OutputStream output = Files.newOutputStream(file)) { workbook.write(output); }
}
return file;
}
private static Path minimalStandardWorkbook(String destination, String east, String yiwuTax, String dongguanTax) throws IOException {
Path file = Files.createTempFile("price-parser-unknown-", ".xlsx");
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
var sheet = workbook.createSheet("美森正班海卡");
sheet.createRow(3).createCell(0).setCellValue("FBA仓库");
var row = sheet.createRow(5);
row.createCell(0).setCellValue(destination);
row.createCell(1).setCellValue(Double.parseDouble(east));
row.createCell(2).setCellValue(Double.parseDouble(yiwuTax));
row.createCell(3).setCellValue(Double.parseDouble(dongguanTax));
try (OutputStream output = Files.newOutputStream(file)) { workbook.write(output); }
}
return file;
}
}
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("船期价格表SQL契约")
class PriceBookSqlContractTest {
@Test
@DisplayName("基线包含整表版本、固定渠道、起运仓别名和两类价格明细")
void shouldProvidePriceBookTables() throws Exception {
String schema = new ClassPathResource("db/phase2-inbound-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(schema)
.contains("CREATE TABLE IF NOT EXISTS fl_price_channel_catalog")
.contains("CREATE TABLE IF NOT EXISTS fl_price_origin_alias")
.contains("CREATE TABLE IF NOT EXISTS fl_price_book_version")
.contains("CREATE TABLE IF NOT EXISTS fl_price_book_channel")
.contains("price_book_version_id")
.contains("price_model VARCHAR(32)")
.contains("pricing_tax_mode VARCHAR(16)")
.contains("source_sheet_name VARCHAR(128)")
.contains("source_row_number INT UNSIGNED")
.contains("destination_type VARCHAR(16)")
.contains("destination_warehouse_id BIGINT UNSIGNED")
.contains("destination_name VARCHAR(128)")
.contains("parse_issues_json JSON NULL")
.contains("DEFAULT 'PENDING_CONFIRMATION'");
}
}
@@ -2,14 +2,16 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.CustomerChargeRuleEntity;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -24,7 +26,6 @@ import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
@@ -33,7 +34,6 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -48,6 +48,7 @@ class FbaPricingServiceTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 3, 10, 0);
@MockBean private FbaPricingRepository repository;
@MockBean private InboundFeeDetailService feeDetails;
@MockBean private ActivePriceBookEstimator priceBookEstimator;
@Resource private FbaPricingService service;
@Test
@@ -63,15 +64,15 @@ class FbaPricingServiceTest {
}
@Test
@DisplayName("包税KG按配置系数取体积重较大值并按规则向上舍入")
void calculatesTaxIncludedKgWithConfiguredFactorAndPrecision() {
@DisplayName("包税KG按生效价格表计费且不依赖客户规则或承运商")
void calculatesTaxIncludedKgWithoutCustomerRuleOrCarrier() {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "1.0001");
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
CarrierFbaRateEntity rate = rate("KG", "2.345678", "10.00");
inbound.setCarrierId(null);
inbound.setCarrierName(null);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate));
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("KG", "167.017", "2.345678", "391.77", "10.00"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-1");
@@ -79,19 +80,26 @@ class FbaPricingServiceTest {
assertThat(response.chargeableQuantity()).isEqualByComparingTo("167.017");
assertThat(response.subtotalAmount()).isEqualByComparingTo("391.77");
assertThat(response.totalAmount()).isEqualByComparingTo("401.77");
assertThat(response.priceBookVersionId()).isEqualTo(701L);
assertThat(response.pricingTaxMode()).isEqualTo("TAX_INCLUDED");
assertThat(response.nextProcessStatus()).isEqualTo("FEE_CONFIRMING");
ArgumentCaptor<InboundFeeEntity> fee = ArgumentCaptor.forClass(InboundFeeEntity.class);
verify(repository).insertFee(fee.capture());
assertThat(fee.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
assertThat(fee.getValue().getPricingTaxMode()).isEqualTo("TAX_INCLUDED");
assertThat(fee.getValue().getChargeRuleId()).isNull();
assertThat(fee.getValue().getCarrierId()).isNull();
}
@Test
@DisplayName("不包税CBM按配置系数折算且精度边界向上取整")
void calculatesTaxExcludedCbmAtRoundingBoundary() {
@DisplayName("不包税CBM使用生效价格表返回的计费量")
void calculatesTaxExcludedCbmFromActivePriceBook() {
readyRepository();
InboundPricingEntity inbound = inbound("363.001", "0.5000");
CustomerChargeRuleEntity rule = rule("CBM", "363.000000", 4, "CEILING");
CarrierFbaRateEntity rate = rate("CBM", "100.000000", "0.00");
inbound.setPricingTaxMode("TAX_EXCLUDED");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
when(repository.matchRates(inbound, "CBM", NOW)).thenReturn(List.of(rate));
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("CBM", "1.0001", "100.000000", "100.01", "0.00"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-2");
@@ -106,11 +114,9 @@ class FbaPricingServiceTest {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "1.0000");
inbound.setProcessStatus("WAIT_SPLIT");
CustomerChargeRuleEntity rule = rule("KG", "167.000000", 3, "CEILING");
CarrierFbaRateEntity rate = rate("KG", "2.000000", "0.00");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(rule));
when(repository.matchRates(inbound, "KG", NOW)).thenReturn(List.of(rate));
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("KG", "167.000", "2.000000", "334.00", "0.00"));
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split");
@@ -156,26 +162,21 @@ class FbaPricingServiceTest {
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "calc-3"))
.isInstanceOf(BizException.class)
.hasMessageContaining("审核通过");
verify(repository, never()).matchRules(any(), any());
verify(priceBookEstimator, never()).estimate(any());
}
@Test
@DisplayName("无客户规则和多客户规则都明确拒绝")
void rejectsMissingAndAmbiguousRules() {
@DisplayName("当前生效价格表无匹配价格时明确拒绝")
void rejectsMissingActivePriceBookRate() {
readyRepository();
InboundPricingEntity inbound = inbound("10", "1");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of());
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
.isInstanceOf(BizException.class).hasMessageContaining("未匹配到有效客户计费规则");
when(priceBookEstimator.estimate(any())).thenThrow(
new BizException(ResultCode.NOT_FOUND, "当前条件没有匹配价格"));
org.mockito.Mockito.reset(repository);
readyRepository();
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.matchRules(inbound, NOW)).thenReturn(List.of(
rule("KG", "167", 3, "CEILING"), rule("KG", "168", 3, "CEILING")));
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "many"))
.isInstanceOf(BizException.class).hasMessageContaining("多条有效客户计费规则");
assertThatThrownBy(() -> service.calculate(TENANT, OPERATOR, INBOUND, NOW, "none"))
.isInstanceOf(BizException.class).hasMessageContaining("没有匹配价格");
verify(repository, never()).insertFee(any());
}
@Test
@@ -194,7 +195,7 @@ class FbaPricingServiceTest {
assertThat(response.feeId()).isEqualTo(501L);
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository, never()).matchRules(any(), any());
verify(priceBookEstimator, never()).estimate(any());
verify(repository, never()).insertFee(any());
}
@@ -299,29 +300,25 @@ class FbaPricingServiceTest {
value.setProcessStatus("WAIT_PRICING"); value.setMeasureReviewStatus("APPROVED");
value.setFeeStatus("UNPRICED"); value.setCustomerId(11L); value.setCustomerName("客户A");
value.setWarehouseId(21L); value.setWarehouseName("义乌仓"); value.setBusinessType("FBA_SEA");
value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A");
value.setTaxMode("TAX_INCLUDED"); value.setDestinationType("FBA"); value.setDestinationCode("ONT8");
value.setChannelCode("MATSON_TRUCK"); value.setCarrierId(31L); value.setCarrierName("船司A");
value.setTaxMode("BUY_DECLARATION"); value.setPricingTaxMode("TAX_INCLUDED");
value.setDestinationType("FBA"); value.setDestinationCode("ONT8");
value.setActualGrossWeightKg(new BigDecimal(weight)); value.setActualVolumeCbm(new BigDecimal(volume));
return value;
}
private CustomerChargeRuleEntity rule(String mode, String factor, int scale, String rounding) {
CustomerChargeRuleEntity value = new CustomerChargeRuleEntity();
value.setId(101L); value.setRuleCode("CR-001"); value.setRuleVersion(1);
value.setChargeMode(mode); value.setConversionFactor(new BigDecimal(factor));
value.setRoundingScale(scale); value.setRoundingMode(rounding); value.setCurrency("USD");
return value;
}
private CarrierFbaRateEntity rate(String unit, String price, String additional) {
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
value.setId(201L); value.setRateCode("RT-001"); value.setRateVersion(1);
value.setOriginWarehouseId(21L); value.setOriginWarehouseName("义乌仓");
value.setChannelCode("SEA-TAX"); value.setCarrierId(31L); value.setCarrierName("船司A");
value.setDestinationCode("ONT8"); value.setChargeUnit(unit); value.setUnitPrice(new BigDecimal(price));
value.setCurrency("USD"); value.setAdditionalFee(new BigDecimal(additional));
value.setEffectiveFrom(NOW.minusDays(1)); value.setEffectiveTo(NOW.plusDays(1));
return value;
private ActivePriceBookEstimator.Estimate estimate(String unit, String quantity, String price,
String amount, String additional) {
RateRow rate = new RateRow();
rate.setId(201L); rate.setPriceBookVersionId(701L); rate.setRateCode("PB-7-1"); rate.setRateVersion(1);
rate.setOriginWarehouseId(21L); rate.setOriginWarehouseName("义乌仓"); rate.setChannelCode("MATSON_TRUCK");
rate.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name()); rate.setDestinationType("FBA");
rate.setDestinationCode("ONT8"); rate.setPricingTaxMode("TAX_INCLUDED"); rate.setChargeUnit(unit);
rate.setUnitPrice(new BigDecimal(price)); rate.setCurrency("USD");
rate.setAdditionalFee(new BigDecimal(additional)); rate.setEffectiveFrom(NOW.minusDays(1));
return new ActivePriceBookEstimator.Estimate(701L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE,
PricingTaxMode.TAX_INCLUDED, rate, new BigDecimal(quantity), unit,
new BigDecimal(price), new BigDecimal(amount), "USD");
}
private InboundPricingIdempotencyEntity completed(String operation, long feeId) {
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.firstleg.dto.OrderApprovedInboundCommand;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
@@ -14,7 +16,10 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
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.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -46,14 +51,52 @@ class InboundOrderCreationServiceTest {
verify(histories, times(1)).insert(any(InboundStatusHistoryEntity.class));
assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("RK-CUSTOM-77");
assertThat(inboundCaptor.getValue().getCarrierId()).isNull();
assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
assertThat(inboundCaptor.getValue().getPricingTaxMode()).isEqualTo("TAX_EXCLUDED");
assertThat(createdId).isEqualTo(inboundCaptor.getValue().getId());
assertThat(replayedId).isEqualTo(900L);
}
@Test
void shouldRejectReplayWithDifferentInboundCode() {
InboundOrderEntity existing = inbound(900L, "RK-OLD", "UNPRICED", null, null);
when(orders.selectOne(any(Wrapper.class))).thenReturn(existing);
assertThatThrownBy(() -> service.createFromApprovedOrder(approvedCommand("RK-NEW")))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(orders, never()).insert(any(InboundOrderEntity.class));
}
@Test
void shouldConvergeOnConcurrentDuplicateInsertWinner() {
InboundOrderEntity winner = inbound(901L, "RK-CUSTOM-77", "UNPRICED", null, null);
when(orders.selectOne(any(Wrapper.class))).thenReturn(null, winner);
doThrow(new org.springframework.dao.DuplicateKeyException("concurrent insert"))
.when(orders).insert(any(InboundOrderEntity.class));
assertThat(service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"))).isEqualTo(901L);
verify(histories, never()).insert(any(InboundStatusHistoryEntity.class));
}
private static InboundOrderEntity inbound(long id, String code, String feeStatus,
Long carrierId, String carrierName) {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(id);
inbound.setTenantId(10001L);
inbound.setOrderId(77L);
inbound.setInboundCode(code);
inbound.setFeeStatus(feeStatus);
inbound.setCarrierId(carrierId);
inbound.setCarrierName(carrierName);
return inbound;
}
private static OrderApprovedInboundCommand approvedCommand(String inboundCode) {
return new OrderApprovedInboundCommand(
10001L, 77L, "FL-77", inboundCode, 7L, "测试客户", 9L, "义乌仓",
null, null, null, "FBA", "AIR", "空运", null, null,
null, null, null, null, null, null, null, null, null, null);
"BUY_DECLARATION", "TAX_EXCLUDED", null, null, null, null, null, null, null, null, null);
}
}
@@ -0,0 +1,273 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PriceBookWarehouseClient;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PriceRegion;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator;
import com.baiyue.firstleg.pricing.PriceBookWarehouseRegionResolver;
import com.baiyue.firstleg.pricing.PriceRegionResolver;
import com.baiyue.firstleg.pricing.RegionWeightTierEstimator;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig({MerchantPriceBookService.class, ActivePriceBookEstimator.class,
FbaWarehouseRateEstimator.class, RegionWeightTierEstimator.class, PriceRegionResolver.class,
PriceBookWarehouseRegionResolver.class})
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class MerchantPriceBookServiceTest {
@MockBean private PriceBookQueryRepository repository;
@MockBean private PriceBookWarehouseClient warehouses;
@Resource private MerchantPriceBookService service;
@Test
void shouldReturnEmptyChannelsWithoutActiveVersion() {
when(repository.activeVersionId()).thenReturn(null);
assertThat(service.channels(10001L, 10L)).isEmpty();
verify(repository, never()).channels(1L, 10L);
verify(repository, never()).channelTaxModes(1L, 10L);
}
@Test
void shouldExposeOnlyChannelsFromLatestActiveVersionAfterSwitch() {
when(repository.activeVersionId()).thenReturn(1L, 2L);
when(repository.channels(1L, 10L)).thenReturn(List.of(channel(1L, "OLD", PriceModel.FBA_WAREHOUSE_RATE)));
when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "NEW", PriceModel.FBA_WAREHOUSE_RATE)));
assertThat(service.channels(10001L, 10L)).extracting(value -> value.channelCode())
.containsExactly("OLD");
assertThat(service.channels(10001L, 10L)).extracting(value -> value.channelCode())
.containsExactly("NEW");
}
@Test
void shouldExposeDistinctPricingTaxModesForActiveOriginChannels() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.channels(7L, 10L)).thenReturn(List.of(
channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE),
channel(7L, "OA_PARCEL", PriceModel.REGION_WEIGHT_TIER)));
when(repository.channelTaxModes(7L, 10L)).thenReturn(List.of(
taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_EXCLUDED),
taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_INCLUDED),
taxMode("MATSON_CLX_TRUCK", PricingTaxMode.TAX_INCLUDED),
taxMode("OA_PARCEL", PricingTaxMode.TAX_INCLUDED)));
var channels = service.channels(10001L, 10L);
assertThat(channels.get(0).pricingTaxModes())
.containsExactly(PricingTaxMode.TAX_INCLUDED, PricingTaxMode.TAX_EXCLUDED);
assertThat(channels.get(1).pricingTaxModes()).containsExactly(PricingTaxMode.TAX_INCLUDED);
}
@Test
void shouldKeepActiveChannelWhenItsTaxModeProjectionIsEmpty() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.channels(7L, 10L)).thenReturn(List.of(
channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE)));
when(repository.channelTaxModes(7L, 10L)).thenReturn(List.of());
assertThat(service.channels(10001L, 10L)).singleElement()
.extracting(value -> value.pricingTaxModes())
.isEqualTo(List.of());
}
@Test
void shouldRejectForgedOrRemovedChannel() {
when(repository.activeVersionId()).thenReturn(2L);
when(repository.channels(2L, 10L)).thenReturn(List.of(channel(2L, "ACTIVE_CHANNEL",
PriceModel.FBA_WAREHOUSE_RATE)));
assertThatThrownBy(() -> service.estimate(10001L,
request("FORGED", PricingDestinationType.FBA, "ONT8", null, null,
PricingTaxMode.TAX_INCLUDED, "10", "0.1")))
.isInstanceOf(BizException.class)
.hasMessageContaining("已下线");
}
@Test
void shouldEstimateFbaDestinationWithIndependentPricingTaxMode() {
stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE);
when(repository.fbaRates(7L, 10L, "MATSON_TRUCK", "ONT8", "TAX_INCLUDED"))
.thenReturn(List.of(rate(PriceModel.FBA_WAREHOUSE_RATE, "KG", null, "12.50")));
var result = service.estimate(10001L,
request(" matson_truck ", PricingDestinationType.FBA, "ont8", null, null,
PricingTaxMode.TAX_INCLUDED, "20", "0.2"));
assertThat(result.priceBookVersionId()).isEqualTo(7L);
assertThat(result.pricingTaxMode()).isEqualTo(PricingTaxMode.TAX_INCLUDED);
assertThat(result.chargeableQuantity()).isEqualByComparingTo("33.400000");
assertThat(result.estimatedAmount()).isEqualByComparingTo("417.50");
assertThat(result.disclaimer()).isEqualTo(MerchantPriceBookService.DISCLAIMER);
}
@Test
void shouldEstimateBoundOverseasWarehouse() {
stubChannel(7L, "EXX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE);
when(repository.overseasRates(7L, 10L, "EXX_TRUCK", 901L, "TAX_EXCLUDED"))
.thenReturn(List.of(rate(PriceModel.FBA_WAREHOUSE_RATE, "CBM", null, "880")));
var result = service.estimate(10001L,
request("EXX_TRUCK", PricingDestinationType.OVERSEAS, null, 901L, null,
PricingTaxMode.TAX_EXCLUDED, "363", "0.5"));
assertThat(result.chargeUnit()).isEqualTo("CBM");
assertThat(result.chargeableQuantity()).isEqualByComparingTo("1.000000");
assertThat(result.estimatedAmount()).isEqualByComparingTo("880.00");
}
@Test
void shouldUseExplicitRegionForTierEstimate() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED"))
.thenReturn(tiers());
var result = service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST,
PricingTaxMode.TAX_INCLUDED, "51", null));
assertThat(result.priceModel()).isEqualTo(PriceModel.REGION_WEIGHT_TIER);
assertThat(result.unitPrice()).isEqualByComparingTo("13");
assertThat(result.estimatedAmount()).isEqualByComparingTo("663.00");
}
@Test
void shouldUseIndependentPricingTaxModeForTierEstimateWhenConfigured() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED"))
.thenReturn(tiers());
var result = service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST,
PricingTaxMode.TAX_INCLUDED, "51", null));
assertThat(result.pricingTaxMode()).isEqualTo(PricingTaxMode.TAX_INCLUDED);
verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "WEST", "TAX_INCLUDED");
}
@Test
void shouldResolveTierRegionFromPlatformFbaWarehousePostalCode() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
when(repository.regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED"))
.thenReturn(tiers());
var warehouse = new PriceBookWarehouseClient.Warehouse(501L, 0L, "FBA-NJ", "NJ FBA",
"FBA", "US", "ACTIVE");
var address = new PriceBookWarehouseClient.Address("US", "NJ", "Newark", null,
"07114", "address", null);
when(warehouses.active("internal-token", 0L, 501L))
.thenReturn(ApiResponse.success(new PriceBookWarehouseClient.Detail(warehouse, List.of(address))));
var result = service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, null, 501L, null,
PricingTaxMode.TAX_INCLUDED, "12", null));
assertThat(result.unitPrice()).isEqualByComparingTo("15");
verify(repository).regionTiers(8L, 10L, "MATSON_PARCEL", "EAST", "TAX_INCLUDED");
}
@Test
void shouldRejectMissingRequiredEstimateFields() {
stubChannel(7L, "MATSON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE);
assertThatThrownBy(() -> service.estimate(10001L,
request("MATSON_TRUCK", PricingDestinationType.FBA, "ONT8", null, null,
null, "10", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("包税或不包税");
}
@Test
void shouldNotGuessRegionWithoutExplicitRegionOrFbaWarehouse() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
assertThatThrownBy(() -> service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, null,
PricingTaxMode.TAX_INCLUDED, "51", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("目的仓区域或平台FBA仓");
}
@Test
void shouldRejectTierEstimateWithoutPricingTaxMode() {
stubChannel(8L, "MATSON_PARCEL", PriceModel.REGION_WEIGHT_TIER);
assertThatThrownBy(() -> service.estimate(10001L,
request("MATSON_PARCEL", PricingDestinationType.FBA, "ONT8", null, PriceRegion.WEST,
null, "51", null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("包税或不包税");
}
private void stubChannel(long versionId, String code, PriceModel model) {
when(repository.activeVersionId()).thenReturn(versionId);
when(repository.channels(versionId, 10L)).thenReturn(List.of(channel(versionId, code, model)));
}
private static EstimateRequest request(String channelCode, PricingDestinationType destinationType,
String destinationCode, Long destinationWarehouseId,
PriceRegion destinationRegion, PricingTaxMode pricingTaxMode,
String weight, String volume) {
return new EstimateRequest(10L, channelCode, destinationCode, destinationType,
destinationWarehouseId, null, destinationRegion, pricingTaxMode,
decimal(weight), decimal(volume));
}
private static ChannelRow channel(long versionId, String code, PriceModel model) {
ChannelRow row = new ChannelRow();
row.setPriceBookVersionId(versionId);
row.setChannelCode(code);
row.setChannelName(code);
row.setPriceModel(model.name());
return row;
}
private static ChannelTaxModeRow taxMode(String channelCode, PricingTaxMode pricingTaxMode) {
ChannelTaxModeRow row = new ChannelTaxModeRow();
row.setChannelCode(channelCode);
row.setPricingTaxMode(pricingTaxMode.name());
return row;
}
private static List<RateRow> tiers() {
return List.of(
rate(PriceModel.REGION_WEIGHT_TIER, "KG", "12", "15"),
rate(PriceModel.REGION_WEIGHT_TIER, "KG", "51", "13"),
rate(PriceModel.REGION_WEIGHT_TIER, "KG", "101", "11"));
}
private static RateRow rate(PriceModel model, String unit, String from, String price) {
RateRow row = new RateRow();
row.setPriceModel(model.name());
row.setChargeUnit(unit);
row.setWeightFromKg(decimal(from));
row.setUnitPrice(new BigDecimal(price));
row.setCurrency("CNY");
return row;
}
private static BigDecimal decimal(String value) {
return value == null ? null : new BigDecimal(value);
}
}
@@ -0,0 +1,285 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PriceBookFileClient;
import com.baiyue.firstleg.client.PriceBookWarehouseClient;
import com.baiyue.firstleg.domain.PriceBookStatus;
import com.baiyue.firstleg.domain.PriceModel;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookConfirmationRequest;
import com.baiyue.firstleg.dto.PriceBookDtos;
import com.baiyue.firstleg.entity.CarrierFbaRateEntity;
import com.baiyue.firstleg.entity.PriceBookChannelEntity;
import com.baiyue.firstleg.entity.PriceBookVersionEntity;
import com.baiyue.firstleg.entity.PriceChannelCatalogEntity;
import com.baiyue.firstleg.repository.PriceBookRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.util.Arrays;
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.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PlatformPriceBookServiceTest {
@Mock private PriceBookRepository repository;
@Mock private PriceBookFileClient files;
@Mock private PriceBookWarehouseClient warehouses;
@Mock private TransactionTemplate transactions;
@Mock private TransactionStatus transactionStatus;
private PlatformPriceBookService service;
@BeforeEach
void setUp() {
service = new PlatformPriceBookService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "files", files);
ReflectionTestUtils.setField(service, "warehouses", warehouses);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "transactions", transactions);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@Test
void fileServiceFailureMarksAllocatedVersionFailed() {
executeTransactionsImmediately();
org.mockito.Mockito.doAnswer(invocation -> {
PriceBookVersionEntity value = invocation.getArgument(0);
value.setId(9007199254740993L);
return 1;
}).when(repository).insertVersion(any());
when(repository.nextVersionNo()).thenReturn(1);
when(files.upload(any(), any(Long.class), any(Long.class), any())).thenReturn(null);
var upload = new MockMultipartFile("file", "price.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1});
assertThatThrownBy(() -> service.upload(7L, upload))
.isInstanceOf(BizException.class)
.hasMessageContaining("原文件保存失败");
ArgumentCaptor<PriceBookVersionEntity> saved = ArgumentCaptor.forClass(PriceBookVersionEntity.class);
verify(repository).updateVersion(saved.capture());
assertThat(saved.getValue().getId()).isEqualTo(9007199254740993L);
assertThat(saved.getValue().getStatus()).isEqualTo(PriceBookStatus.FAILED.name());
assertThat(saved.getValue().getBlockingErrorCount()).isEqualTo(1);
}
@Test
void parseFailureKeepsImmutableSourceFileMetadataOnFailedVersion() {
executeTransactionsImmediately();
org.mockito.Mockito.doAnswer(invocation -> {
PriceBookVersionEntity value = invocation.getArgument(0);
value.setId(88L);
return 1;
}).when(repository).insertVersion(any());
when(repository.nextVersionNo()).thenReturn(2);
when(files.upload(any(), any(Long.class), any(Long.class), any())).thenReturn(ApiResponse.success(
new PriceBookFileClient.FileMetadata(501L, 88L, "price.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 3L, "abc123", "FBA_PRICE_BOOK_SOURCE")));
when(files.download(any(), any(Long.class), any(Long.class), any(Long.class)))
.thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3}));
var invalid = new MockMultipartFile("file", "price.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1, 2, 3});
assertThatThrownBy(() -> service.upload(7L, invalid))
.isInstanceOf(BizException.class);
ArgumentCaptor<PriceBookVersionEntity> saved = ArgumentCaptor.forClass(PriceBookVersionEntity.class);
verify(repository).updateVersion(saved.capture());
assertThat(saved.getValue().getStatus()).isEqualTo(PriceBookStatus.FAILED.name());
assertThat(saved.getValue().getSourceFileId()).isEqualTo(501L);
assertThat(saved.getValue().getSourceSha256()).isEqualTo("abc123");
assertThat(saved.getValue().getParseIssuesJson()).contains("UPLOAD_OR_PARSE_FAILED");
}
@Test
void exposesOnlyUploadConfirmationAndReadOperations() {
assertThat(Arrays.stream(PlatformPriceBookService.class.getDeclaredMethods())
.filter(method -> java.lang.reflect.Modifier.isPublic(method.getModifiers()))
.map(java.lang.reflect.Method::getName).toList())
.containsExactlyInAnyOrder("list", "upload", "detail", "publish", "source");
}
@Test
void pendingDetailWarnsThatMissingActiveChannelWillBeRemoved() {
PriceBookVersionEntity pendingVersion = version(13L, PriceBookStatus.PENDING_CONFIRMATION);
PriceBookVersionEntity active = version(12L, PriceBookStatus.ACTIVE);
when(repository.findVersion(13L)).thenReturn(pendingVersion);
when(repository.findActive()).thenReturn(active);
when(repository.listChannels(13L)).thenReturn(List.of());
when(repository.listChannels(12L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK")));
when(repository.listRates(13L)).thenReturn(List.of());
var detail = service.detail(13L);
assertThat(detail.version().status()).isEqualTo(PriceBookStatus.PENDING_CONFIRMATION);
assertThat(detail.issues()).extracting(PriceBookDtos.ParseIssue::issueCode)
.containsExactly("CHANNEL_MISSING_FROM_PENDING_UPLOAD");
}
@Test
void sourceDownloadIsProxiedWithSafeAttachmentHeaders() {
PriceBookVersionEntity version = version(14L, PriceBookStatus.SUPERSEDED);
version.setSourceFileId(601L);
version.setOriginalFileName("历史价格.xlsx");
when(repository.findVersion(14L)).thenReturn(version);
when(files.download("internal-token", 7L, 14L, 601L))
.thenReturn(ResponseEntity.ok().header(HttpHeaders.CONTENT_TYPE, "application/octet-stream")
.body(new byte[]{9, 8, 7}));
ResponseEntity<byte[]> response = service.source(14L, 7L);
assertThat(response.getBody()).containsExactly(9, 8, 7);
assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION))
.startsWith("attachment;").contains("filename*=");
assertThat(response.getHeaders().getFirst("X-Content-Type-Options")).isEqualTo("nosniff");
}
@Test
void confirmationMapsWarehousesBeforeReplacingActiveVersion() {
PriceBookVersionEntity pendingVersion = version(21L, PriceBookStatus.PENDING_CONFIRMATION);
PriceBookVersionEntity active = version(20L, PriceBookStatus.ACTIVE);
when(repository.findVersion(21L)).thenReturn(pendingVersion);
when(repository.listChannels(21L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK")));
when(repository.listRates(21L)).thenReturn(List.of(rate(77L)));
when(repository.activeCatalog()).thenReturn(List.of(catalog("MATSON_CLX_TRUCK")));
when(repository.listActiveForUpdate()).thenReturn(List.of(active));
when(warehouses.active("internal-token", 0L, 501L)).thenReturn(ApiResponse.success(
new PriceBookWarehouseClient.Detail(new PriceBookWarehouseClient.Warehouse(501L, 0L,
"CN-YIWU-01", "义乌仓", "DOMESTIC", "CN", "ACTIVE"), List.of())));
org.mockito.Mockito.doAnswer(invocation -> {
PriceBookVersionEntity value = invocation.getArgument(0);
value.setStatus(PriceBookStatus.ACTIVE.name());
return 1;
}).when(repository).activate(any(), any(), any(Long.class));
service.publish(21L, 7L, confirmation("CN-YIWU-01", 501L));
InOrder order = inOrder(repository);
order.verify(repository).lockPlatformVersions();
order.verify(repository).findVersion(21L);
verify(repository).deletePendingChildren(21L);
verify(repository).supersede(any(PriceBookVersionEntity.class), any(), any(Long.class));
verify(repository).activate(any(PriceBookVersionEntity.class), any(), any(Long.class));
ArgumentCaptor<CarrierFbaRateEntity> pendingRate = ArgumentCaptor.forClass(CarrierFbaRateEntity.class);
verify(repository).insertRate(pendingRate.capture());
assertThat(pendingRate.getValue().getStatus()).isEqualTo(PriceBookStatus.PENDING_CONFIRMATION.name());
}
@Test
void missingWarehouseMappingCannotPublish() {
PriceBookVersionEntity pendingVersion = version(22L, PriceBookStatus.PENDING_CONFIRMATION);
when(repository.findVersion(22L)).thenReturn(pendingVersion);
when(repository.listChannels(22L)).thenReturn(List.of(channel("MATSON_CLX_TRUCK")));
when(repository.listRates(22L)).thenReturn(List.of(rate(78L)));
when(repository.activeCatalog()).thenReturn(List.of(catalog("MATSON_CLX_TRUCK")));
assertThatThrownBy(() -> service.publish(22L, 7L,
new PriceBookConfirmationRequest(List.of(), List.of())))
.isInstanceOf(BizException.class)
.hasMessageContaining("阻断错误");
verify(repository, never()).activate(any(), any(), any(Long.class));
verify(repository, never()).deletePendingChildren(22L);
}
@Test
void duplicatePublishIsRejectedAfterPublishLock() {
when(repository.findVersion(31L)).thenReturn(version(31L, PriceBookStatus.ACTIVE));
assertThatThrownBy(() -> service.publish(31L, 7L, confirmation("CN-YIWU-01", 501L)))
.isInstanceOf(BizException.class)
.hasMessageContaining("待确认上传记录");
InOrder order = inOrder(repository);
order.verify(repository).lockPlatformVersions();
order.verify(repository).findVersion(31L);
verify(repository, never()).activate(any(), any(), any(Long.class));
}
private static PriceBookVersionEntity version(long id, PriceBookStatus status) {
PriceBookVersionEntity value = new PriceBookVersionEntity();
value.setId(id);
value.setTenantId(0L);
value.setVersionNo((int) id);
value.setOriginalFileName("price.xlsx");
value.setStatus(status.name());
value.setChannelCount(0);
value.setRateCount(0);
value.setBlockingErrorCount(0);
value.setWarningCount(0);
value.setDeleted(false);
return value;
}
private static PriceBookChannelEntity channel(String code) {
PriceBookChannelEntity value = new PriceBookChannelEntity();
value.setChannelCode(code);
value.setChannelName(code);
value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name());
value.setSourceSheetName(code);
return value;
}
private static PriceChannelCatalogEntity catalog(String code) {
PriceChannelCatalogEntity value = new PriceChannelCatalogEntity();
value.setChannelCode(code);
value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name());
value.setStatus("ACTIVE");
return value;
}
private static CarrierFbaRateEntity rate(long id) {
CarrierFbaRateEntity value = new CarrierFbaRateEntity();
value.setId(id);
value.setChannelCode("MATSON_CLX_TRUCK");
value.setPriceModel(PriceModel.FBA_WAREHOUSE_RATE.name());
value.setOriginWarehouseCode("CN-YIWU-01");
value.setOriginWarehouseName("义乌仓");
value.setDestinationType(PricingDestinationType.FBA.name());
value.setDestinationCode("ONT8");
value.setPricingTaxMode(PricingTaxMode.TAX_INCLUDED.name());
value.setChargeUnit("KG");
value.setUnitPrice(new BigDecimal("10"));
value.setCurrency("CNY");
value.setSourceSheetName("美森正班海卡");
value.setSourceRowNumber(5);
value.setSourceDestinationText("ONT8");
return value;
}
private static PriceBookConfirmationRequest confirmation(String code, long id) {
return new PriceBookConfirmationRequest(
List.of(new PriceBookConfirmationRequest.OriginMapping(code, id)), List.of());
}
private void executeTransactionsImmediately() {
when(transactions.execute(any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
TransactionCallback<Object> callback = invocation.getArgument(0);
return callback.doInTransaction(transactionStatus);
});
}
}