优化
This commit is contained in:
+1
@@ -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;
|
||||
}
|
||||
}
|
||||
+206
@@ -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) {
|
||||
|
||||
+44
-1
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user