偏远地区邮编判断调整

This commit is contained in:
liuminghui
2026-11-09 15:58:27 +08:00
parent 9220696519
commit b8b943ef51
26 changed files with 1431 additions and 60 deletions
@@ -27,6 +27,12 @@ public class PayBillController extends BaseController
@Autowired
private IPayBillService payBillService;
/**
* 分页查询应付账单。
*
* @param query 账单号、供应商、状态、币种,空着的条件不过滤
* @return 账单列表,不含费用明细
*/
@PreAuthorize("@ss.hasPermi('price:payBill:list')")
@GetMapping("/list")
public TableDataInfo list(PayBill query)
@@ -36,6 +42,12 @@ public class PayBillController extends BaseController
return getDataTable(list);
}
/**
* 查询一张应付账单的明细和操作日志。
*
* @param billId 应付账单ID
* @return 账单头、费用行和日志
*/
@PreAuthorize("@ss.hasPermi('price:payBill:query')")
@GetMapping("/{billId}")
public AjaxResult detail(@PathVariable Long billId)
@@ -43,6 +55,12 @@ public class PayBillController extends BaseController
return success(payBillService.selectPayBillDetail(billId));
}
/**
* 预览本次能生成账单的订单。只查询,不重算费用,也不写账单。
*
* @param command 查询方式,以及订单号、计费日或提单号
* @return 可生成订单、未找到的单号、已经入账的订单
*/
@PreAuthorize("@ss.hasPermi('price:payBill:query')")
@PostMapping("/preview")
public AjaxResult preview(@RequestBody PayBillCommand command)
@@ -50,6 +68,12 @@ public class PayBillController extends BaseController
return success(payBillService.preview(command));
}
/**
* 生成应付账单。先重算每票订单的尾程应付,再把当时的费用抄进草稿账单。
*
* @param command 供应商,以及按订单号、计费日或提单号划定的范围
* @return 刚生成的草稿账单
*/
@PreAuthorize("@ss.hasPermi('price:payBill:add')")
@PostMapping
public AjaxResult generate(@RequestBody PayBillCommand command)
@@ -57,6 +81,11 @@ public class PayBillController extends BaseController
return success(payBillService.generate(command));
}
/**
* 确认草稿账单。确认后才可以登记付款。
*
* @param billId 应付账单ID
*/
@PreAuthorize("@ss.hasPermi('price:payBill:confirm')")
@PutMapping("/confirm/{billId}")
public AjaxResult confirm(@PathVariable Long billId)
@@ -65,6 +94,11 @@ public class PayBillController extends BaseController
return success();
}
/**
* 把已确认的账单登记为已付款。已付款的账单不能直接作废。
*
* @param billId 应付账单ID
*/
@PreAuthorize("@ss.hasPermi('price:payBill:confirm')")
@PutMapping("/paid/{billId}")
public AjaxResult paid(@PathVariable Long billId)
@@ -73,6 +107,11 @@ public class PayBillController extends BaseController
return success();
}
/**
* 作废应付账单。账单和费用行保留,只改状态并记下作废原因。
*
* @param command 账单ID和作废原因
*/
@PreAuthorize("@ss.hasPermi('price:payBill:void')")
@PutMapping("/void")
public AjaxResult voidBill(@RequestBody PayBillCommand command)
@@ -1,12 +1,15 @@
package com.bms.price.pay;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import com.bms.price.domain.PayOrderCostResult;
import com.bms.price.domain.PayPostcodeGroup;
import com.bms.price.domain.PayPostcodeLine;
import com.bms.price.domain.PayProduct;
import com.bms.price.domain.PaySurcharge;
import com.bms.price.domain.PayWeightBand;
@@ -113,6 +116,133 @@ class PayCostCalculatorTest
assertTrue(hasFee(result, "基础运费", "1.57"));
}
@Test
void remoteZoneUsesMainlandFreightAndItsOwnSurcharge()
{
PayProduct product = new PayProduct();
product.setProductName("Evri 48H");
product.setCurrency("GBP");
product.setWeightBands(new ArrayList<>(List.of(
band("0", "1", "1.51", "UK Mainland"),
band("1", "2", "1.72", "UK Mainland"))));
PaySurcharge light = fee(1L, "偏远附加费", "5.95", "GBP", "ZONE", null);
light.setZoneName("Highlands& Islands");
light.setWeightMin(BigDecimal.ZERO);
light.setWeightMax(BigDecimal.ONE);
PaySurcharge heavy = fee(2L, "偏远附加费", "6.20", "GBP", "ZONE", null);
heavy.setZoneName("Highlands& Islands");
heavy.setWeightMin(BigDecimal.ONE);
heavy.setWeightMax(new BigDecimal("2"));
product.setSurcharges(List.of(light, heavy));
PayOrderCostResult remote = PayCostCalculator.calculate(product, new BigDecimal("0.5"),
null, null, null, "IV1 1AA", "Highlands& Islands", null, 1);
assertTrue(hasFee(remote, "基础运费", "1.51"));
assertTrue(hasFee(remote, "偏远附加费", "5.95"));
assertEquals(1, remote.getFees().stream().filter(fee -> "偏远附加费".equals(fee.getFeeName())).count());
PayOrderCostResult mainland = PayCostCalculator.calculate(product, new BigDecimal("0.5"),
null, null, null, "LL22 8PJ", "UK Mainland", null, 1);
assertTrue(hasFee(mainland, "基础运费", "1.51"));
assertTrue(mainland.getFees().stream().noneMatch(fee -> "偏远附加费".equals(fee.getFeeName())));
}
@Test
void spanishPrefixChargesFirstWeightThenExtraKg()
{
PayProduct product = new PayProduct();
product.setProductName("CTT 48H");
product.setCurrency("EUR");
product.setWeightBands(new ArrayList<>(List.of(band("0", "5", "2.05", "马德里"))));
PaySurcharge first = fee(1L, "偏远附加费", "7.1", "EUR", "POSTCODE", "07");
first.setZoneName("Balearic Islands");
first.setPostcodeParam("07");
first.setWeightMin(BigDecimal.ZERO);
first.setWeightMax(BigDecimal.ONE);
PaySurcharge extra = fee(2L, "偏远附加费", "4", "EUR", "EXTRA_KG", "1");
extra.setZoneName("Balearic Islands");
extra.setPostcodeParam("07");
extra.setPriceUnit("EUR/KG");
product.setSurcharges(List.of(first, extra));
PayOrderCostResult light = PayCostCalculator.calculate(product, new BigDecimal("0.8"),
null, null, null, "07008", "马德里", null, 1);
assertTrue(hasFee(light, "基础运费", "2.05"));
assertTrue(hasFee(light, "偏远附加费", "7.1"));
assertEquals(1, light.getFees().stream().filter(fee -> "偏远附加费".equals(fee.getFeeName())).count());
PayOrderCostResult heavy = PayCostCalculator.calculate(product, new BigDecimal("3"),
null, null, null, "07008", "马德里", null, 1);
assertTrue(hasFee(heavy, "基础运费", "2.05"));
assertTrue(hasFee(heavy, "偏远附加费", "7.1"));
assertTrue(hasFee(heavy, "偏远附加费", "8"));
PayOrderCostResult mainland = PayCostCalculator.calculate(product, new BigDecimal("3"),
null, null, null, "28001", "马德里", null, 1);
assertTrue(mainland.getFees().stream().noneMatch(fee -> "偏远附加费".equals(fee.getFeeName())));
assertTrue(PaySurchargeRules.matchesPostcode("07,35,38,51,52,9", "35001"));
assertTrue(PaySurchargeRules.matchesPostcode("07,35,38,51,52,9", "9000"));
assertFalse(PaySurchargeRules.matchesPostcode("07,35,38,51,52,9", "28001"));
assertTrue(PaySurchargeRules.matchesPostcode("IV,HS,KA27-28", "KA27 8AA"));
assertEquals("07", PaySurchargeRules.remotePrefixes("Balearic Islands 07邮编开头"));
assertEquals("35,38", PaySurchargeRules.remotePrefixes("Canary Islands 35、38邮编开头"));
assertEquals("9", PaySurchargeRules.remotePrefixes("Portuguese Island 9邮编开头"));
assertEquals("", PaySurchargeRules.remotePrefixes("岛屿邮编地区"));
}
@Test
void productPostcodeBookChoosesTheMatchingRemoteFee()
{
List<PayRemoteBook.Hit> hits = List.of(new PayRemoteBook.Hit("偏远附加费", new BigDecimal("1.80"), "", "Highlands"));
PayOrderCostResult result = PayCostCalculator.calculate(royalMail(), new BigDecimal("4"),
null, null, null, "IV1 1AA", null, null, 1, hits);
assertTrue(hasFee(result, "偏远地区附加费", "1.80"));
assertTrue(result.getFees().stream().noneMatch(fee -> "偏远地区附加费".equals(fee.getFeeName())
&& fee.getAmount().compareTo(new BigDecimal("1.00")) == 0));
}
@Test
void linkedRemoteBookWithoutAHitDoesNotUseThePriceCardList()
{
PayOrderCostResult result = PayCostCalculator.calculate(royalMail(), new BigDecimal("4"),
null, null, null, "IV1 1AA", null, null, 1, List.of());
assertTrue(result.getFees().stream().noneMatch(fee -> fee.getFeeName().contains("偏远")));
assertTrue(hasFee(result, "基础运费", "1.62"));
}
@Test
void remoteBookChargesAManualIslandFeeAndPerKg()
{
PayProduct product = new PayProduct();
product.setProductName("德国DPD挂号-国际件");
product.setCurrency("EUR");
product.setWeightBands(new ArrayList<>(List.of(band("0", "5", "3.20", ""))));
PaySurcharge island = fee(31L, "岛屿附加费", "12", "EUR", "MANUAL", null);
PaySurcharge perKg = fee(32L, "偏远附加费", "0.35", "EUR", "MANUAL", null);
perKg.setPriceUnit("EUR/KG");
product.setSurcharges(List.of(island, perKg));
List<PayRemoteBook.Hit> hits = List.of(new PayRemoteBook.Hit("", null, "", "岛屿"));
PayOrderCostResult result = PayCostCalculator.calculate(product, new BigDecimal("2"),
null, null, null, "1234", null, null, 1, hits);
assertTrue(hasFee(result, "岛屿附加费", "12"));
assertTrue(hasFee(result, "偏远附加费", "0.70"));
}
@Test
void remoteHitsFollowTheProductGroupAndCountry()
{
PayPostcodeGroup germany = group(44L, "西班牙GLS", "REMOTE", "", "DE");
PayPostcodeGroup france = group(45L, "西班牙GLS", "REMOTE", "", "FR");
PayPostcodeLine berlin = line(44L, "10115");
PayPostcodeLine paris = line(45L, "75001");
List<PayRemoteBook.Hit> de = PayRemoteBook.hits(List.of(germany, france), List.of(berlin, paris), "10115", "DE", "德国");
assertEquals(1, de.size());
List<PayRemoteBook.Hit> none = PayRemoteBook.hits(List.of(germany, france), List.of(berlin, paris), "28001", "ES", "西班牙");
assertTrue(none.isEmpty());
PayPostcodeGroup delivery = group(42L, "西班牙CTT", "", "", "ES");
assertTrue(PayRemoteBook.hits(List.of(delivery), List.of(line(42L, "28001")), "28001", "ES", "西班牙") == null);
}
@Test
void severalZonesCannotBePricedFromTheOrder()
{
@@ -192,6 +322,26 @@ class PayCostCalculatorTest
return band;
}
private static PayPostcodeGroup group(Long id, String quoteSheet, String coverage, String feeName, String country)
{
PayPostcodeGroup group = new PayPostcodeGroup();
group.setGroupId(id);
group.setQuoteSheet(quoteSheet);
group.setCoverage(coverage);
group.setFeeName(feeName);
group.setCountryCode(country);
group.setConfigKind("ZONE");
return group;
}
private static PayPostcodeLine line(Long groupId, String postcode)
{
PayPostcodeLine line = new PayPostcodeLine();
line.setGroupId(groupId);
line.setPostcode(postcode);
return line;
}
private static PaySurcharge fee(Long id, String name, String amount, String currency, String rule, String param)
{
PaySurcharge fee = new PaySurcharge();
@@ -204,7 +204,7 @@ class PayWorkbookParserTest
@Test
void dictMatchReadsNamedSheetAndSkipsOthers() throws Exception
{
Path file = Path.of("D:/payment/Evri 48H.xlsx");
Path file = priceCard("Evri 48H.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "Evri 48H",
List.of("Evri 48H", "DHL Paket"));
@@ -213,6 +213,14 @@ class PayWorkbookParserTest
assertEquals("Evri 48H", evri.getDictValue());
assertEquals("Evri 48H", evri.getSheetName());
assertFalse(evri.getWeightBands().isEmpty());
assertTrue(evri.getWeightBands().stream().allMatch(band -> "派送费".equals(band.getChargeName())));
assertTrue(evri.getWeightBands().stream().noneMatch(band -> bandText(band).contains("偏远") || bandText(band).contains("Highlands")));
assertTrue(evri.getWeightBands().stream().anyMatch(band -> decimal(band, "1.51") && band.getZoneName().contains("Mainland")));
PaySurcharge highlands = evri.getSurcharges().stream()
.filter(fee -> "偏远附加费".equals(fee.getFeeName()) && fee.getZoneName().contains("Highlands") && decimalFee(fee, "5.95"))
.findFirst().orElseThrow();
assertEquals("ZONE", highlands.getCalcRule());
assertTrue(highlands.getWeightMax() != null && highlands.getWeightMax().compareTo(java.math.BigDecimal.ONE) == 0);
}
@Test
@@ -280,7 +288,7 @@ class PayWorkbookParserTest
@Test
void royalMailReadsRemoteAreaTableAndSkipsRemarks() throws Exception
{
Path file = Path.of("D:/payment/英国RM.xlsx");
Path file = priceCard("英国RM.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "英国RM", List.of("英国RM"));
assertEquals(1, preview.getProducts().size());
@@ -318,6 +326,21 @@ class PayWorkbookParserTest
assertTrue(rm.getExtraLines().isEmpty());
}
@Test
void royalMail48hRemoteRowsMatchPostcodePrefixes() throws Exception
{
Path file = priceCard("Royal Mail 48H.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayProduct rm = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "Royal Mail 48H",
List.of("Royal Mail 48H")).getProducts().get(0);
List<PaySurcharge> remote = rm.getSurcharges().stream().filter(fee -> fee.getFeeName() != null && fee.getFeeName().contains("偏远")).toList();
assertFalse(remote.isEmpty(), rm.getSurcharges().stream().map(fee -> fee.getFeeName() + "/" + fee.getCalcRule() + "/" + fee.getCalcParam()).toList().toString());
assertTrue(remote.stream().allMatch(fee -> "POSTCODE".equals(fee.getCalcRule())),
remote.stream().map(fee -> fee.getFeeName() + "/" + fee.getCalcParam() + "/" + fee.getFeeDesc()).toList().toString());
assertTrue(remote.stream().anyMatch(fee -> fee.getCalcParam() != null && fee.getCalcParam().contains("IV") && !fee.getCalcParam().contains("邮编")));
assertTrue(rm.getWeightBands().stream().noneMatch(band -> bandText(band).contains("偏远")));
}
@Test
void evriOtherFeesKeepOneRowPerRegion() throws Exception
{
@@ -418,7 +441,7 @@ class PayWorkbookParserTest
@Test
void ctt48hFileKeepsSurchargeTablesOutOfWeightBands() throws Exception
{
Path file = Path.of("D:/payment/CTT 48H.xlsx");
Path file = priceCard("CTT 48H.xlsx");
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "CTT 48H",
List.of("CTT 48H"));
PayProduct ctt = preview.getProducts().get(0);
@@ -431,8 +454,17 @@ class PayWorkbookParserTest
.filter(fee -> "偏远附加费".equals(fee.getFeeName()) && fee.getZoneName().contains("Balearic") && decimalFee(fee, "7.1"))
.findFirst().orElseThrow();
assertTrue(balearic.getFeeDesc().contains("首重"));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "偏远附加费".equals(fee.getFeeName())
&& fee.getZoneName().contains("Canary") && decimalFee(fee, "4.5") && fee.getChargeText().contains("KG")));
assertEquals("POSTCODE", balearic.getCalcRule());
assertTrue(balearic.getCalcParam() != null && balearic.getCalcParam().contains("07"));
assertTrue(balearic.getWeightMax() != null && balearic.getWeightMax().compareTo(new java.math.BigDecimal("1")) == 0);
PaySurcharge canary = ctt.getSurcharges().stream()
.filter(fee -> "偏远附加费".equals(fee.getFeeName()) && fee.getZoneName().contains("Canary") && decimalFee(fee, "4.5")
&& fee.getChargeText().contains("KG"))
.findFirst().orElseThrow();
assertEquals("EXTRA_KG", canary.getCalcRule());
assertEquals("1", canary.getCalcParam());
assertTrue(canary.getPostcodeParam() != null && canary.getPostcodeParam().contains("35") && canary.getPostcodeParam().contains("38"));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("额外线路运输费") && "MANUAL".equals(fee.getCalcRule())));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("额外线路运输费") && decimalFee(fee, "0.09")));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("额外线路运输费")
&& fee.getFeeDesc().contains("800公里")));
@@ -442,7 +474,7 @@ class PayWorkbookParserTest
@Test
void spainCttMainlandReadsBillableConcepts() throws Exception
{
Path file = Path.of("D:/payment/西班牙CTT带电专线(主要地区).xlsx");
Path file = priceCard("西班牙CTT带电专线(主要地区).xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(),
"西班牙CTT带电专线(主要地区)", List.of("西班牙CTT带电专线(主要地区)"));
@@ -711,6 +743,16 @@ class PayWorkbookParserTest
return fee.getAmount() != null && fee.getAmount().compareTo(new java.math.BigDecimal(amount)) == 0;
}
private static Path priceCard(String name)
{
Path current = Path.of("D:/A-payment").resolve(name);
if (Files.exists(current))
{
return current;
}
return Path.of("D:/payment").resolve(name);
}
private String bandText(PayWeightBand band)
{
return (band.getChargeName() == null ? "" : band.getChargeName()) + " "