应付账单初始化

This commit is contained in:
caojia
2026-11-04 16:04:38 +08:00
committed by liuminghui
parent 04be15deab
commit 51a89b18e1
90 changed files with 15661 additions and 6 deletions
@@ -0,0 +1,94 @@
package com.bms.web.controller.data;
import java.util.List;
import java.util.Locale;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.bms.common.annotation.Log;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
import com.bms.common.exception.ServiceException;
import com.bms.price.domain.PayPostcodeGroup;
import com.bms.price.domain.PayPostcodeLine;
import com.bms.price.service.IPayPostcodeService;
/**
* 数据管理下的应付邮编配置。Excel 导入后按国家和附加费金额合并。
*/
@RestController
@RequestMapping("/data/payPostcode")
public class PayPostcodeController extends BaseController
{
@Autowired
private IPayPostcodeService payPostcodeService;
@PreAuthorize("@ss.hasPermi('data:payPostcode:list')")
@GetMapping("/list")
public TableDataInfo list(PayPostcodeGroup query)
{
startPage();
List<PayPostcodeGroup> list = payPostcodeService.selectGroupList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('data:payPostcode:query')")
@GetMapping("/{groupId}")
public AjaxResult getInfo(@PathVariable Long groupId)
{
return success(payPostcodeService.selectGroupById(groupId));
}
@PreAuthorize("@ss.hasPermi('data:payPostcode:edit')")
@Log(title = "应付邮编配置", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody PayPostcodeGroup group)
{
return toAjax(payPostcodeService.updateGroup(group));
}
@PreAuthorize("@ss.hasPermi('data:payPostcode:query')")
@GetMapping("/line")
public TableDataInfo lines(PayPostcodeLine query)
{
startPage();
List<PayPostcodeLine> list = payPostcodeService.selectLineList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('data:payPostcode:import')")
@Log(title = "应付邮编配置", businessType = BusinessType.IMPORT)
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public AjaxResult importExcel(MultipartFile file) throws Exception
{
if (file == null || file.isEmpty())
{
throw new ServiceException("请选择 Excel 文件");
}
String name = file.getOriginalFilename() == null ? "" : file.getOriginalFilename().toLowerCase(Locale.ROOT);
if (!name.endsWith(".xlsx"))
{
throw new ServiceException("只支持 xlsx 文件");
}
return success(payPostcodeService.importExcel(file.getBytes(), file.getOriginalFilename(), getUsername()));
}
@PreAuthorize("@ss.hasPermi('data:payPostcode:remove')")
@Log(title = "应付邮编配置", businessType = BusinessType.DELETE)
@DeleteMapping("/{groupIds}")
public AjaxResult remove(@PathVariable Long[] groupIds)
{
return toAjax(payPostcodeService.deleteByIds(groupIds));
}
}
@@ -0,0 +1,83 @@
package com.bms.web.controller.price;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.price.domain.PayBill;
import com.bms.price.domain.PayBillCommand;
import com.bms.price.service.IPayBillService;
/**
* 账单管理下的应付账单。
*/
@RestController
@RequestMapping("/price/payBill")
public class PayBillController extends BaseController
{
@Autowired
private IPayBillService payBillService;
@PreAuthorize("@ss.hasPermi('price:payBill:list')")
@GetMapping("/list")
public TableDataInfo list(PayBill query)
{
startPage();
List<PayBill> list = payBillService.selectPayBillList(query);
return getDataTable(list);
}
@PreAuthorize("@ss.hasPermi('price:payBill:query')")
@GetMapping("/{billId}")
public AjaxResult detail(@PathVariable Long billId)
{
return success(payBillService.selectPayBillDetail(billId));
}
@PreAuthorize("@ss.hasPermi('price:payBill:query')")
@PostMapping("/preview")
public AjaxResult preview(@RequestBody PayBillCommand command)
{
return success(payBillService.preview(command));
}
@PreAuthorize("@ss.hasPermi('price:payBill:add')")
@PostMapping
public AjaxResult generate(@RequestBody PayBillCommand command)
{
return success(payBillService.generate(command));
}
@PreAuthorize("@ss.hasPermi('price:payBill:confirm')")
@PutMapping("/confirm/{billId}")
public AjaxResult confirm(@PathVariable Long billId)
{
payBillService.confirm(billId);
return success();
}
@PreAuthorize("@ss.hasPermi('price:payBill:confirm')")
@PutMapping("/paid/{billId}")
public AjaxResult paid(@PathVariable Long billId)
{
payBillService.markPaid(billId);
return success();
}
@PreAuthorize("@ss.hasPermi('price:payBill:void')")
@PutMapping("/void")
public AjaxResult voidBill(@RequestBody PayBillCommand command)
{
payBillService.voidBill(command);
return success();
}
}
@@ -0,0 +1,227 @@
package com.bms.web.controller.price;
import java.util.List;
import java.util.Locale;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.bms.common.annotation.Log;
import com.bms.common.core.controller.BaseController;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.page.TableDataInfo;
import com.bms.common.enums.BusinessType;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.poi.ExcelUtil;
import com.bms.price.domain.ChannelProductMap;
import com.bms.price.domain.PayProduct;
import com.bms.price.domain.PayProductTerm;
import com.bms.price.domain.PaySaveCommand;
import com.bms.price.pay.PayDetailExporter;
import com.bms.price.service.IPayOrderCostService;
import com.bms.price.service.IPayProductService;
import jakarta.servlet.http.HttpServletResponse;
/**
* 价格基础下的应付配置。导入先预览,确认后才入库。
*/
@RestController
@RequestMapping("/price/pay")
public class PayProductController extends BaseController
{
@Autowired
private IPayProductService payProductService;
@Autowired
private IPayOrderCostService payOrderCostService;
@PreAuthorize("@ss.hasPermi('price:pay:list')")
@GetMapping("/list")
public TableDataInfo list(PayProduct query)
{
startPage();
List<PayProduct> list = payProductService.selectPayProductList(query);
return getDataTable(list);
}
/** 一个产品的价格版本和有效期 */
@PreAuthorize("@ss.hasAnyPermi('price:pay:query,price:pay:edit')")
@GetMapping("/term/list")
public AjaxResult termList(@RequestParam Long productId)
{
return success(payProductService.listTerms(productId));
}
@PreAuthorize("@ss.hasPermi('price:pay:edit')")
@Log(title = "应付有效期", businessType = BusinessType.UPDATE)
@PutMapping("/term")
public AjaxResult updateTerm(@RequestBody PayProductTerm term)
{
return toAjax(payProductService.updateTerm(term));
}
@PreAuthorize("@ss.hasPermi('price:pay:edit')")
@Log(title = "应付有效期", businessType = BusinessType.UPDATE)
@PutMapping("/term/status")
public AjaxResult changeTermStatus(@RequestBody PayProductTerm term)
{
return toAjax(payProductService.changeTermStatus(term));
}
/** 按订单的渠道服务商和应付产品生成应付账单 */
@PreAuthorize("@ss.hasAnyPermi('price:billing:bill,price:pay:edit')")
@Log(title = "应付账单", businessType = BusinessType.INSERT)
@PostMapping("/order/{billId}")
public AjaxResult generateOrderCost(@PathVariable String billId)
{
return success(payOrderCostService.generate(billId));
}
@PreAuthorize("@ss.hasPermi('price:pay:query')")
@GetMapping("/{productId}")
public AjaxResult getInfo(@PathVariable Long productId)
{
return success(payProductService.selectPayProductDetail(productId));
}
/** 下拉:已导入的公共模板,供渠道页选择。payType 为空时按应付 AP */
@PreAuthorize("@ss.hasAnyPermi('price:pay:query,data:serviceChannel:list,data:serviceChannel:edit')")
@GetMapping("/options")
public AjaxResult options(@RequestParam(value = "payType", required = false) String payType)
{
return success(payProductService.selectTemplateOptions(payType));
}
/** 渠道当前价格副本 */
@PreAuthorize("@ss.hasAnyPermi('price:pay:query,data:serviceChannel:query,data:serviceChannel:edit')")
@GetMapping("/channel/{channelId}")
public AjaxResult channelPrice(@PathVariable String channelId)
{
return success(payProductService.selectChannelPrice(channelId));
}
/** 渠道只绑定产品编号,以后按有效期取价 */
@PreAuthorize("@ss.hasAnyPermi('price:pay:edit,data:serviceChannel:edit')")
@Log(title = "渠道应付产品", businessType = BusinessType.UPDATE)
@PutMapping("/channel/{channelId}/bind")
public AjaxResult bindChannel(@PathVariable String channelId, @RequestBody ChannelProductMap link)
{
String payType = link == null ? null : link.getPayType();
if (link == null || link.getProductId() == null)
{
payProductService.unbindChannel(channelId, payType);
return success();
}
payProductService.bindChannel(channelId, link.getProductId(), link.getPriceSetId(), payType);
return success(payProductService.selectChannelPrice(channelId));
}
/** 对照两个价格版本 */
@PreAuthorize("@ss.hasPermi('price:pay:query')")
@GetMapping("/compare")
public AjaxResult compare(@RequestParam Long leftId, @RequestParam Long rightId)
{
return success(payProductService.compare(leftId, rightId));
}
/** 给已绑定的产品追加一版,产品编号不变 */
@PreAuthorize("@ss.hasAnyPermi('price:pay:import,data:serviceChannel:edit')")
@Log(title = "渠道应付价格", businessType = BusinessType.INSERT)
@PostMapping("/channel/{channelId}")
public AjaxResult saveChannel(@PathVariable String channelId, @RequestBody PayProduct product)
{
return success(payProductService.saveChannelVersion(channelId, product));
}
/** 重新导入渠道价格,生成新版本,不改模板 */
@PreAuthorize("@ss.hasAnyPermi('price:pay:import,data:serviceChannel:edit')")
@Log(title = "渠道应付价格", businessType = BusinessType.IMPORT)
@PostMapping(value = "/channel/{channelId}/reimport", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public AjaxResult reimportChannel(@PathVariable String channelId, MultipartFile file) throws Exception
{
assertWorkbook(file);
return success(payProductService.reimportChannel(channelId, file.getBytes(), file.getOriginalFilename()));
}
/** 按字典匹配工作表。dictValue 为空时读取文件里所有能对上的表 */
@PreAuthorize("@ss.hasPermi('price:pay:import')")
@Log(title = "应付配置", businessType = BusinessType.IMPORT)
@PostMapping(value = "/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public AjaxResult preview(MultipartFile file, @RequestParam(value = "dictValue", required = false) String dictValue) throws Exception
{
assertWorkbook(file);
return success(payProductService.preview(file.getBytes(), file.getOriginalFilename(), dictValue));
}
/** 修改已导入的公共模板 */
@PreAuthorize("@ss.hasPermi('price:pay:import')")
@Log(title = "应付配置", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@RequestBody PayProduct product)
{
return toAjax(payProductService.updateDetail(product));
}
/** 保存预览中勾选的产品 */
@PreAuthorize("@ss.hasPermi('price:pay:import')")
@Log(title = "应付配置", businessType = BusinessType.INSERT)
@PostMapping("/save")
public AjaxResult save(@RequestBody PaySaveCommand command)
{
return success(payProductService.save(command));
}
@PreAuthorize("@ss.hasPermi('price:pay:remove')")
@Log(title = "应付配置", businessType = BusinessType.DELETE)
@DeleteMapping("/{productIds}")
public AjaxResult remove(@PathVariable Long[] productIds)
{
return toAjax(payProductService.deletePayProducts(productIds));
}
@PreAuthorize("@ss.hasPermi('price:pay:export')")
@Log(title = "应付配置", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, PayProduct query)
{
List<PayProduct> list = payProductService.selectPayProductList(query);
new ExcelUtil<>(PayProduct.class).exportExcel(response, list, "应付产品");
}
/** 按当前搜索条件导出产品、重量段、附加费和其他配置 */
@PreAuthorize("@ss.hasPermi('price:pay:export')")
@Log(title = "应付配置明细", businessType = BusinessType.EXPORT)
@PostMapping("/exportDetail")
public void exportDetail(HttpServletResponse response, PayProduct query) throws Exception
{
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
PayDetailExporter.write(response.getOutputStream(), payProductService.selectPayProductDetails(query));
}
private void assertWorkbook(MultipartFile file)
{
if (file == null || file.isEmpty())
{
throw new ServiceException("请选择价卡文件");
}
String filename = file.getOriginalFilename();
if (filename == null || !filename.toLowerCase(Locale.ROOT).endsWith(".xlsx"))
{
throw new ServiceException("请上传xlsx价卡");
}
if (file.getSize() > 30L * 1024 * 1024)
{
throw new ServiceException("价卡文件不能超过30MB");
}
}
}
@@ -0,0 +1,150 @@
package com.bms.price.pay;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.PayProduct;
import com.bms.price.domain.PaySurcharge;
import com.bms.price.domain.PayWeightBand;
class PayCostCalculatorTest
{
@Test
void royalMailChargesTicketPriceAndMatchingSurcharges()
{
PayOrderCostResult result = PayCostCalculator.calculate(royalMail(), new BigDecimal("4"),
new BigDecimal("50"), new BigDecimal("40"), new BigDecimal("30"), "IV1 1AA", null, 1);
assertEquals(new BigDecimal("3.42"), result.getPayTotal());
assertEquals("GBP", result.getCurrency());
assertTrue(hasFee(result, "基础运费", "1.62"));
assertTrue(hasFee(result, "偏远地区附加费", "1.80"));
assertTrue(result.getFees().stream().noneMatch(fee -> fee.getFeeName().contains("超大")));
assertTrue(result.getSkipped().stream().anyMatch(text -> text.contains("签名费")));
}
@Test
void overweightOutsideTheCardDoesNotInventFreight()
{
PayOrderCostResult result = PayCostCalculator.calculate(royalMail(), new BigDecimal("21"),
null, null, null, "SW1A 1AA", null, 1);
assertTrue(result.getFees().isEmpty());
assertTrue(result.getMessage().contains("超出"));
}
@Test
void oversizeUsesTheHigherPercentWhenBothTiersMatch()
{
PayOrderCostResult result = PayCostCalculator.calculate(spainOneZone(), new BigDecimal("1"),
new BigDecimal("130"), new BigDecimal("50"), new BigDecimal("40"), "28001", null, 1);
assertTrue(hasFee(result, "基础运费", "2.27"));
assertTrue(hasFee(result, "特大包裹收费", "1.59"));
assertEquals(1, result.getFees().stream().filter(fee -> "特大包裹收费".equals(fee.getFeeName())).count());
}
@Test
void postcodeOutsideTheRemoteListUsesMainland()
{
PayProduct product = evri();
PayOrderCostResult result = PayCostCalculator.calculate(product, new BigDecimal("0.522"),
new BigDecimal("30.1"), new BigDecimal("24"), new BigDecimal("15.1"), "LL22 8PJ",
"UK Mainland\n英国大陆", null, 1);
assertEquals(new BigDecimal("1.57"), result.getPayTotal());
assertTrue(hasFee(result, "基础运费", "1.57"));
}
@Test
void severalZonesCannotBePricedFromTheOrder()
{
PayProduct product = spainOneZone();
PayWeightBand rural = band("0.01", "30", "3.12", "Rural Resto");
product.getWeightBands().add(rural);
PayOrderCostResult result = PayCostCalculator.calculate(product, new BigDecimal("1"),
null, null, null, "28001", null, 1);
assertTrue(result.getFees().isEmpty());
assertTrue(result.getMessage().contains("多个地区"));
}
private static boolean hasFee(PayOrderCostResult result, String name, String amount)
{
return result.getFees().stream().anyMatch(fee -> name.equals(fee.getFeeName())
&& fee.getAmount().compareTo(new BigDecimal(amount)) == 0);
}
private static PayProduct royalMail()
{
PayProduct product = new PayProduct();
product.setProductName("英国RM");
product.setWeightBands(List.of(
band("0", "5", "1.62", ""),
band("5.001", "10", "2.26", ""),
band("10.001", "15", "3.51", ""),
band("15.001", "20", "5.18", "")));
product.getWeightBands().forEach(band -> {
band.setChargeName("每票");
band.setPriceUnit("GBP/票");
});
product.setSurcharges(List.of(
fee(11L, "偏远地区附加费", "1.80", "GBP", "POSTCODE", "IV,HS,KA27-28"),
fee(12L, "偏远地区附加费", "1.00", "GBP", "POSTCODE", "BT,TR21-25"),
fee(13L, "签名费", "0.70", "GBP", "MANUAL", null),
fee(14L, "超大附加费(包裹)", "12.50", "GBP", "SIZE", "box:61*46*46;volume:0.129"),
fee(15L, "超重附加费(包裹)", "12.50", "GBP", "WEIGHT", "20")));
return product;
}
private static PayProduct spainOneZone()
{
PayProduct product = new PayProduct();
product.setProductName("西邮-西班牙(主岛)");
PayWeightBand band = band("0.01", "30", "2.27", "MAD-BCN Dense");
band.setChargeName("每件");
band.setPriceUnit("EUR/件");
product.setWeightBands(new ArrayList<>(List.of(band)));
product.setSurcharges(List.of(
fee(21L, "特大包裹收费", "35", "%", "SIZE", "side:120;sum:210;join:or"),
fee(22L, "特大包裹收费", "70", "%", "SIZE", "side:120;sum:210;join:and")));
product.getSurcharges().get(0).setChargeText("运费另加35%");
product.getSurcharges().get(1).setChargeText("运费另加70%");
return product;
}
private static PayProduct evri()
{
PayProduct product = new PayProduct();
product.setProductName("英国evri");
product.setCurrency("GBP");
product.setWeightBands(new ArrayList<>(List.of(
band("0", "2", "1.57", "UK Mainland\n英国大陆"),
band("0", "2", "4.78", "Highlands& Islands\n高地和岛屿"))));
product.getWeightBands().forEach(row -> row.setPriceUnit("GBP/ITEM"));
return product;
}
private static PayWeightBand band(String min, String max, String price, String zone)
{
PayWeightBand band = new PayWeightBand();
band.setWeightMin(new BigDecimal(min));
band.setWeightMax(new BigDecimal(max));
band.setWeightLabel(min + "-" + max);
band.setPrice(new BigDecimal(price));
band.setZoneName(zone);
return band;
}
private static PaySurcharge fee(Long id, String name, String amount, String currency, String rule, String param)
{
PaySurcharge fee = new PaySurcharge();
fee.setSurchargeId(id);
fee.setFeeName(name);
fee.setAmount(new BigDecimal(amount));
fee.setCurrency(currency);
fee.setChargeText(amount + " " + currency);
fee.setCalcRule(rule);
fee.setCalcParam(param);
return fee;
}
}
@@ -0,0 +1,188 @@
package com.bms.price.pay;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Arrays;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import com.bms.price.domain.PayPostcodeGroup;
/**
* 用 10 月价卡核对邮编星号、附加费合并和分区原文。
*/
class PayPostcodeImporterTest
{
private final PayPostcodeImporter importer = new PayPostcodeImporter();
@Test
void stripsStarAndMergesSameFee() throws Exception
{
List<PayPostcodeGroup> groups = importer.parse(Files.readAllBytes(file("32、")), "32.xlsx");
List<PayPostcodeGroup> fees = groups.stream().filter(item -> "FEE".equals(item.getConfigKind()) && "英国附加费".equals(item.getSheetName())).toList();
assertEquals(4, fees.size(), fees.stream().map(item -> item.getFeeName() + "=" + item.getFeeText() + " x" + item.getLineCount()).toList().toString());
PayPostcodeGroup remote195 = one(fees, "偏远附加费", "1.95GBP/票");
PayPostcodeGroup remote115 = one(fees, "偏远附加费", "1.15GBP/票");
PayPostcodeGroup evri = one(fees, "偏远附加费", "5.95GBP/票");
PayPostcodeGroup air = one(fees, "空气减排附加费", "0.45GBP/票");
assertEquals("Royal Mail", remote195.getChannelName());
assertEquals("Royal Mail", remote115.getChannelName());
assertEquals("EVRI", evri.getChannelName());
assertEquals("EVRI", air.getChannelName());
assertEquals(15, remote195.getLineCount());
assertTrue(remote195.getLines().stream().anyMatch(line -> "PA60-78".equals(line.getPostcode())));
assertTrue(remote195.getLines().stream().anyMatch(line -> "PA80".equals(line.getPostcode())));
assertTrue(remote195.getLines().stream().anyMatch(line -> "KA27-28".equals(line.getPostcode())));
assertEquals(6, remote115.getLineCount());
assertTrue(remote115.getLines().stream().anyMatch(line -> "AB35-38".equals(line.getPostcode())));
assertTrue(remote115.getLines().stream().anyMatch(line -> "AB53-56".equals(line.getPostcode())));
assertTrue(evri.getLineCount() > 100 && evri.getLineCount() < 500);
assertTrue(evri.getLines().stream().anyMatch(line -> "GY".equals(line.getPostcode()) && "GY*".equals(line.getPostcodeRaw())));
assertTrue(air.getLineCount() > 70000);
assertEquals("英国", air.getCountryName());
assertEquals("REMOTE", remote195.getCoverage());
assertEquals("REMOTE", remote115.getCoverage());
assertEquals("REMOTE", evri.getCoverage());
assertEquals("", air.getCoverage());
assertTrue(groups.stream().noneMatch(item -> "Evri 48H".equals(item.getSheetName())));
PayPostcodeGroup italy = groups.stream().filter(item -> "iMile邮编分区".equals(item.getSheetName()) && "IT".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("ZONE", italy.getConfigKind());
assertEquals("", italy.getCoverage());
assertEquals("IT-iMile", italy.getChannelName());
assertTrue(italy.getLines().stream().anyMatch(line -> "00010".equals(line.getPostcode()) && "province(no dense)".equals(line.getZoneName()) && "Lazio".equals(line.getRegionName()) && "ROMA".equals(line.getCityName())));
assertTrue(groups.stream().anyMatch(item -> "iMile邮编分区".equals(item.getSheetName()) && "DE".equals(item.getCountryCode()) && "DE-iMile".equals(item.getChannelName()) && item.getLines().stream().anyMatch(line -> "01001".equals(line.getPostcode()))));
PayPostcodeGroup spain = groups.stream().filter(item -> "CTT邮编分区".equals(item.getSheetName())).findFirst().orElseThrow();
assertEquals("西班牙", spain.getCountryName());
assertEquals("CTT", spain.getChannelName());
assertTrue(spain.getLines().stream().anyMatch(line -> "01110".equals(line.getPostcode()) && "Low".equals(line.getZoneName()) && "SANTIKURUTZE KANPEZU".equals(line.getCityName())));
PayPostcodeGroup france = groups.stream().filter(item -> "Colissimo邮编分区".equals(item.getSheetName())).findFirst().orElseThrow();
assertEquals("Colissimo", france.getChannelName());
assertTrue(france.getLines().stream().anyMatch(line -> "77244".equals(line.getPostcode()) && "Moyenne Densité".equals(line.getZoneName())));
PayPostcodeGroup poste = groups.stream().filter(item -> "PosteItaliane邮编分区".equals(item.getSheetName()) && item.getLines().stream().anyMatch(line -> "City (dense)".equals(line.getZoneName()))).findFirst().orElseThrow();
assertTrue(poste.getLines().stream().noneMatch(line -> "City".equals(line.getZoneName())));
}
@Test
void stripStarOnlyRemovesTrailingMark()
{
assertEquals("GY", PayPostcodeImporter.stripStar("GY*"));
assertEquals("PO30", PayPostcodeImporter.stripStar("PO30*"));
assertEquals("KA27-28", PayPostcodeImporter.stripStar("KA27-28"));
assertEquals("Royal Mail", PayPostcodeImporter.channelOf(Arrays.asList("Royal Mail偏远地区附加费")));
assertEquals("EVRI", PayPostcodeImporter.channelOf(Arrays.asList("EVRI偏远&空气减排地区附加费")));
assertEquals("CTT", PayPostcodeImporter.channelOf(Arrays.asList("Código Postal", "CTT Density")));
assertEquals("GOFO", PayPostcodeImporter.channelOf(Arrays.asList("法国GOFO可达邮编")));
assertEquals("REACH", PayPostcodeImporter.coverageOf("法国GOFO可达邮编", "", ""));
assertEquals("UNREACH", PayPostcodeImporter.coverageOf("德国DPD挂号(DEDPD)不可达邮编", "", ""));
assertEquals("REMOTE", PayPostcodeImporter.coverageOf("evri 偏远", "", ""));
assertEquals("", PayPostcodeImporter.coverageOf("英国附加费", "EVRI偏远&空气减排地区附加费", "空气减排附加费"));
List<PayPostcodeImporter.PostcodeToken> kept = PayPostcodeImporter.expandPostcode("KA27-28");
assertEquals("KA27-28", kept.get(0).postcode);
assertEquals("", kept.get(0).end);
List<PayPostcodeImporter.PostcodeToken> range = PayPostcodeImporter.expandPostcode("57030-57039");
assertEquals("57030", range.get(0).postcode);
assertEquals("57039", range.get(0).end);
}
@Test
void reachableUnreachableAndRemoteKeepTheirOwnCountry() throws Exception
{
List<PayPostcodeGroup> may = importer.parse(Files.readAllBytes(file("20、")), "20.xlsx");
PayPostcodeGroup cirro = may.stream().filter(item -> item.getSheetName().contains("Cirro可派")).findFirst().orElseThrow();
assertEquals("REACH", cirro.getCoverage());
assertEquals("法国", cirro.getCountryName());
assertEquals("Cirro", cirro.getChannelName());
assertTrue(cirro.getLines().stream().anyMatch(line -> "94350".equals(line.getPostcode()) && "Zone1".equals(line.getZoneName()) && "Paris".equals(line.getRegionName())));
PayPostcodeGroup germany = may.stream().filter(item -> "西班牙GLS 偏远邮编".equals(item.getSheetName()) && "DE".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("REMOTE", germany.getCoverage());
assertEquals("德国", germany.getCountryName());
assertEquals("GLS", germany.getChannelName());
assertTrue(germany.getLines().stream().anyMatch(line -> "18565".equals(line.getPostcode()) && "Hiddensee".equals(line.getRegionName())));
PayPostcodeGroup italyRemote = may.stream().filter(item -> "西班牙GLS 偏远邮编".equals(item.getSheetName()) && "IT".equals(item.getCountryCode())).findFirst().orElseThrow();
assertTrue(italyRemote.getLines().stream().anyMatch(line -> "57030".equals(line.getPostcode()) && "57039".equals(line.getPostcodeEnd())));
PayPostcodeGroup blocked = may.stream().filter(item -> item.getSheetName().contains("不可达")).findFirst().orElseThrow();
assertEquals("UNREACH", blocked.getCoverage());
assertEquals("德国", blocked.getCountryName());
assertEquals("DPD", blocked.getChannelName());
assertTrue(blocked.getLines().stream().anyMatch(line -> "18565".equals(line.getPostcode()) && "18565".equals(line.getPostcodeEnd())));
PayPostcodeGroup denmark = may.stream().filter(item -> item.getSheetName().contains("岛屿") && "DK".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("REMOTE", denmark.getCoverage());
assertEquals("丹麦", denmark.getCountryName());
assertEquals("DPD", denmark.getChannelName());
assertTrue(denmark.getLines().stream().anyMatch(line -> "4592".equals(line.getPostcode())));
PayPostcodeGroup estonia = may.stream().filter(item -> item.getSheetName().contains("岛屿") && "EE".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("爱沙尼亚", estonia.getCountryName());
assertEquals("REMOTE", estonia.getCoverage());
assertTrue(estonia.getLines().stream().anyMatch(line -> "88001".equals(line.getPostcode()) && "88005".equals(line.getPostcodeEnd())));
List<PayPostcodeGroup> yide = importer.parse(Files.readAllBytes(file("26、")), "26.xlsx");
PayPostcodeGroup gofo = yide.stream().filter(item -> item.getSheetName().contains("GOFO可达")).findFirst().orElseThrow();
assertEquals("REACH", gofo.getCoverage());
assertEquals("法国", gofo.getCountryName());
assertEquals("GOFO", gofo.getChannelName());
assertTrue(gofo.getLines().stream().anyMatch(line -> "94350".equals(line.getPostcode()) && "Paris".equals(line.getRegionName())));
PayPostcodeGroup evriRemote = yide.stream().filter(item -> "evri 偏远".equals(item.getSheetName())).findFirst().orElseThrow();
assertEquals("REMOTE", evriRemote.getCoverage());
assertEquals("英国", evriRemote.getCountryName());
assertEquals("evri", evriRemote.getChannelName());
assertTrue(evriRemote.getLines().stream().anyMatch(line -> "GY".equals(line.getPostcode()) && "GY*".equals(line.getPostcodeRaw())));
PayPostcodeGroup peuItaly = yide.stream().filter(item -> item.getSheetName().contains("不可达") && "IT".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("UNREACH", peuItaly.getCoverage());
assertEquals("意大利", peuItaly.getCountryName());
assertTrue(peuItaly.getLines().stream().anyMatch(line -> "23041".equals(line.getPostcode())));
List<PayPostcodeGroup> weishi = importer.parse(Files.readAllBytes(file("8、")), "8.xlsx");
PayPostcodeGroup cttZone = weishi.stream().filter(item -> "西班牙CTT邮编分区".equals(item.getSheetName())).findFirst().orElseThrow();
assertEquals("末端", cttZone.getServiceType());
assertEquals("西班牙", cttZone.getCatalogCountry());
assertEquals("CTT", cttZone.getProductName());
assertEquals("西班牙CTT", cttZone.getQuoteSheet());
assertEquals("", cttZone.getCoverage() == null ? "" : cttZone.getCoverage());
assertTrue(cttZone.getLines().stream().anyMatch(line -> "01110".equals(line.getPostcode()) && "Low".equals(line.getZoneName()) && "ALAVA".equals(line.getRegionName())));
PayPostcodeGroup cirroMay = weishi.stream().filter(item -> item.getSheetName().contains("Cirro可派")).findFirst().orElseThrow();
assertEquals("末端", cirroMay.getServiceType());
assertEquals("法国", cirroMay.getCatalogCountry());
assertEquals("cirro", cirroMay.getProductName());
assertEquals("法国Cirro", cirroMay.getQuoteSheet());
assertEquals("REACH", cirroMay.getCoverage());
PayPostcodeGroup glsMay = weishi.stream().filter(item -> "西班牙GLS 偏远邮编".equals(item.getSheetName()) && "DE".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("西班牙", glsMay.getCatalogCountry());
assertEquals("GLS", glsMay.getProductName());
assertEquals("西班牙GLS", glsMay.getQuoteSheet());
assertEquals("德国", glsMay.getCountryName());
assertEquals("REMOTE", glsMay.getCoverage());
PayPostcodeGroup blockedMay = weishi.stream().filter(item -> item.getSheetName().contains("不可达")).findFirst().orElseThrow();
assertEquals("德国", blockedMay.getCatalogCountry());
assertEquals("DPD", blockedMay.getProductName());
assertTrue(blockedMay.getQuoteSheet().contains("DEDPD"));
assertEquals("UNREACH", blockedMay.getCoverage());
PayPostcodeGroup islandMay = weishi.stream().filter(item -> item.getSheetName().contains("岛屿") && "DK".equals(item.getCountryCode())).findFirst().orElseThrow();
assertEquals("DPD国际件", islandMay.getProductName());
assertEquals("德国DPD挂号-国际件", islandMay.getQuoteSheet());
assertEquals("REMOTE", islandMay.getCoverage());
assertTrue(weishi.stream().noneMatch(item -> item.getSheetName().contains("危机燃油") || item.getSheetName().contains("高风险")));
PayPostcodeGroup peuFrance = yide.stream().filter(item -> item.getSheetName().contains("不可达") && "FR".equals(item.getCountryCode())).findFirst().orElseThrow();
assertTrue(peuFrance.getLines().stream().anyMatch(line -> "98700".equals(line.getPostcode()) && "98799".equals(line.getPostcodeEnd())));
}
private static PayPostcodeGroup one(List<PayPostcodeGroup> fees, String name, String text)
{
return fees.stream().filter(item -> name.equals(item.getFeeName()) && text.equals(item.getFeeText())).findFirst().orElseThrow();
}
private Path file(String prefix) throws Exception
{
try (Stream<Path> files = Files.list(Path.of("D:/应付供应商")))
{
return files.filter(path -> path.getFileName().toString().startsWith(prefix) && path.toString().endsWith(".xlsx")).findFirst().orElseThrow();
}
}
}
@@ -0,0 +1,631 @@
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.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import com.bms.price.domain.PayPreview;
import com.bms.price.domain.PayProduct;
import com.bms.price.domain.PaySurcharge;
import com.bms.price.domain.PayWeightBand;
/**
* 用现有应付价卡核对目录产品和重量段、附加费是否被读出来。
*/
class PayWorkbookParserTest
{
private final PayWorkbookParser parser = new PayWorkbookParser();
@Test
void octoberCatalogKeepsTwelveProductSheets() throws Exception
{
PayPreview preview = parse("9、");
assertEquals(12, preview.getProducts().size());
PayProduct evri = product(preview, "Evri 48H");
assertTrue(evri.getWeightBands().stream().anyMatch(band -> decimal(band, "1.51") && "英国大陆".equals(zoneTail(band))));
assertTrue(evri.getSurcharges().stream().anyMatch(fee -> "战争附加费".equals(fee.getFeeName()) && fee.getChargeText().contains("2.5")));
PayProduct ctt = product(preview, "CTT 48H");
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> band.getWeightLabel() != null && band.getWeightLabel().contains("首重") && decimal(band, "2.05")));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "每增加 1Kg".equals(band.getWeightLabel()) || (band.getWeightLabel() != null && band.getWeightLabel().contains("每增加"))));
}
@Test
void mayCardReadsDhlAndDpdWithoutZoneSheets() throws Exception
{
PayPreview preview = parse("8、");
assertEquals(8, preview.getProducts().size());
PayProduct dhl = product(preview, "DHL泛欧");
assertEquals("威狮", dhl.getSupplierName());
assertTrue(dhl.getRelatedSheet() != null && dhl.getRelatedSheet().contains("高风险"));
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> decimal(band, "9.71") && band.getCountryName() != null && band.getCountryName().contains("丹麦")),
dhl.getParseStatus() + " " + dhl.getSheetName() + " " + dhl.getWeightBands().stream().limit(6)
.map(band -> band.getCountryName() + "/" + band.getChargeName() + "/" + band.getPrice()).toList());
assertEquals("末端", dhl.getServiceType());
assertEquals("德国", dhl.getCountryScope());
assertEquals("DHL泛欧", dhl.getSheetName());
PaySurcharge afghan = dhl.getSurcharges().stream()
.filter(fee -> "高风险地区附加费".equals(fee.getFeeName()) && "AF".equals(fee.getCalcParam())).findFirst().orElseThrow();
assertEquals("Afghanistan", afghan.getZoneName());
assertEquals("RMB", afghan.getCurrency());
assertTrue(afghan.getChargeText().contains("10.4") && afghan.getChargeText().contains("RMB/kg"));
assertTrue(decimalFee(afghan, "10.4"));
assertEquals("MANUAL", afghan.getCalcRule());
assertTrue(afghan.getFeeDesc().contains("非欧洲"));
PayProduct dpd = preview.getProducts().stream().filter(item -> "DPD".equals(item.getProductName())).findFirst().orElseThrow();
assertEquals("末端", dpd.getServiceType());
assertEquals("德国", dpd.getCountryScope());
assertTrue(dpd.getSheetName().contains("DEDPD"));
assertTrue(dpd.getRelatedSheet().contains("不可达"));
assertTrue(dpd.getRelatedSheet().contains("危机燃油"));
assertTrue(dpd.getWeightBands().stream().anyMatch(band -> decimal(band, "3.29")));
assertTrue(dpd.getSurcharges().stream().anyMatch(fee -> "道路通行费".equals(fee.getFeeName()) && decimalFee(fee, "0.14")));
assertTrue(dpd.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("旺季") && fee.getEffectiveStart() != null));
PaySurcharge bracket = dpd.getSurcharges().stream()
.filter(fee -> "危机燃油附加费".equals(fee.getFeeName()) && "1.95|2".equals(fee.getCalcParam())).findFirst().orElseThrow();
assertEquals("1.95 ≤ 油价 < 2", bracket.getChargeText());
assertTrue(decimalFee(bracket, "0.0379"));
assertEquals("MANUAL", bracket.getCalcRule());
PaySurcharge zero = dpd.getSurcharges().stream()
.filter(fee -> "危机燃油附加费".equals(fee.getFeeName()) && "|1.35".equals(fee.getCalcParam())).findFirst().orElseThrow();
assertEquals("油价 < 1.35", zero.getChargeText());
assertTrue(decimalFee(zero, "0"));
PayProduct ctt = product(preview, "CTT");
assertEquals("末端", ctt.getServiceType());
assertEquals("西班牙", ctt.getCountryScope());
assertEquals("西班牙CTT", ctt.getSheetName());
assertTrue(ctt.getRelatedSheet().contains("西班牙CTT邮编分区"));
PayProduct international = product(preview, "DPD国际件");
assertEquals("德国", international.getCountryScope());
assertTrue(international.getRelatedSheet().contains("岛屿"));
assertTrue(international.getSurcharges().stream().noneMatch(fee -> "1.95|2".equals(fee.getCalcParam())));
}
@Test
void upsZonesStayLeftToRightAndColissimoFuelIsMarked() throws Exception
{
PayPreview preview = parse("32、");
PayProduct ups = product(preview, "德国UPS");
assertEquals("Zone 1", ups.getWeightBands().get(0).getZoneName());
assertEquals("DE", ups.getWeightBands().get(0).getCountryCode());
assertTrue(decimal(ups.getWeightBands().get(0), "3.9"));
assertEquals("Zone 2", ups.getWeightBands().get(1).getZoneName());
assertTrue(ups.getWeightBands().get(1).getCountryCode().startsWith("BE,LU"));
assertTrue(decimal(ups.getWeightBands().get(1), "7.58"));
assertTrue(ups.getWeightBands().get(4).getZoneName().contains("Zone 5"));
assertTrue(decimal(ups.getWeightBands().get(4), "9.45"));
assertTrue(ups.getSurcharges().stream().anyMatch(fee -> "报价说明".equals(fee.getFeeName())
&& fee.getChargeText().contains("不含管理费") && fee.getChargeText().contains("不含燃油")));
PaySurcharge handling = ups.getSurcharges().stream()
.filter(fee -> fee.getFeeName().startsWith("额外处理费") && (fee.getSectionName() == null || !fee.getSectionName().contains("高峰")))
.findFirst().orElseThrow();
assertTrue(handling.getChargeText().contains("23.65") && handling.getChargeText().contains("26.7"));
assertTrue(handling.getFeeDesc().contains("是否加收燃油:是"));
PaySurcharge peak = ups.getSurcharges().stream()
.filter(fee -> "高峰附加费".equals(fee.getSectionName()) && fee.getFeeName().startsWith("额外处理费"))
.findFirst().orElseThrow();
assertTrue(peak.getChargeText().contains("7.35"));
assertEquals(java.time.LocalDate.of(2026, 9, 28),
peak.getEffectiveStart().toInstant().atZone(java.time.ZoneId.of("GMT+8")).toLocalDate());
assertEquals(java.time.LocalDate.of(2027, 1, 17),
peak.getEffectiveEnd().toInstant().atZone(java.time.ZoneId.of("GMT+8")).toLocalDate());
PayProduct colissimo = product(preview, "Colissimo");
PaySurcharge fuel = colissimo.getSurcharges().stream().filter(fee -> fee.getFeeName().contains("燃油")).findFirst().orElseThrow();
assertTrue(fuel.getChargeText().contains("不含燃油"));
assertTrue(fuel.getChargeText().contains("不含去碳"));
assertTrue(colissimo.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().startsWith("去碳附加费") && decimalFee(fee, "0.05")));
assertTrue(colissimo.getWeightBands().stream().anyMatch(band -> decimal(band, "3.03")));
assertTrue(colissimo.getWeightBands().stream().noneMatch(band -> band.getChargeName() != null && band.getChargeName().contains("燃油")));
}
@Test
void imilePostcodesResolveZoneAndGermanCoverage() throws Exception
{
PayPreview preview = parse("32、");
PayProduct italy = product(preview, "IT-iMile");
assertTrue(italy.getRelatedSheet().contains("iMile邮编分区"));
assertTrue(italy.getExtraLines().size() > 4000);
assertTrue(italy.getExtraLines().stream().anyMatch(line -> "邮编".equals(line.getLineType()) && line.getLineText().contains("00010") && line.getLineText().contains("Province") && line.getLineText().contains("ROMA")));
assertTrue(italy.getExtraLines().stream().anyMatch(line -> line.getLineText().endsWith(" | City")));
assertTrue(italy.getExtraLines().stream().anyMatch(line -> line.getLineText().endsWith(" | Rural")));
assertTrue(italy.getWeightBands().stream().anyMatch(band -> "City".equals(band.getZoneName()) && decimal(band, "2.85")));
assertTrue(italy.getWeightBands().stream().anyMatch(band -> "Rural".equals(band.getZoneName()) && decimal(band, "3.68")));
PayProduct germany = product(preview, "DE-iMile");
assertTrue(germany.getRelatedSheet().contains("iMile邮编分区"));
assertTrue(germany.getExtraLines().size() > 7000);
assertTrue(germany.getExtraLines().stream().anyMatch(line -> line.getLineText().contains("01001") && line.getLineText().contains("Active")));
assertTrue(germany.getExtraLines().stream().noneMatch(line -> line.getLineText().contains("Lazio") || line.getLineText().contains("City")));
}
@Test
void gweFileOverridesOnlyPanEuroSupplier() throws Exception
{
PayPreview preview = parse("8.1");
PayProduct dhl = product(preview, "DHL泛欧");
assertEquals("GWE", dhl.getSupplierName());
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> decimal(band, "9.79")),
dhl.getParseStatus() + " sur=" + dhl.getSurchargeCount() + " extra=" + dhl.getExtraCount() + " " + dhl.getRemark() + " "
+ dhl.getWeightBands().stream().limit(8)
.map(band -> band.getCountryName() + "/" + band.getWeightLabel() + "/" + band.getChargeName() + "/" + band.getPrice()).toList());
PayProduct ctt = product(preview, "CTT");
assertEquals("威狮", ctt.getSupplierName());
}
@Test
void wholeWorkbookPreviewSerializes() throws Exception
{
PayPreview preview = parse("9、");
String json = tools.jackson.databind.json.JsonMapper.builder().build().writeValueAsString(preview);
assertTrue(json.contains("\"productName\":\"Evri 48H\""));
assertTrue(json.contains("2026-10-01") || json.contains("effectiveDate"));
}
@Test
void yideCatalogIncludesPostcodeProducts() throws Exception
{
PayPreview preview = parse("26、");
assertEquals(10, preview.getProducts().size());
assertTrue(preview.getProducts().stream().anyMatch(item -> item.getExtraCount() != null && item.getExtraCount() > 0));
PayProduct evri = preview.getProducts().stream().filter(item -> item.getSheetName().contains("英国evri")).findFirst().orElseThrow();
assertFalse(evri.getWeightBands().isEmpty());
assertFalse(evri.getSurcharges().isEmpty());
}
@Test
void dictMatchReadsNamedSheetAndSkipsOthers() throws Exception
{
Path file = Path.of("D:/payment/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"));
assertEquals(1, preview.getProducts().size());
PayProduct evri = preview.getProducts().get(0);
assertEquals("Evri 48H", evri.getDictValue());
assertEquals("Evri 48H", evri.getSheetName());
assertFalse(evri.getWeightBands().isEmpty());
}
@Test
void panEuroKeepsTwoChargesPerCountryAndEverySurcharge() throws Exception
{
Path file = Path.of("D:/payment/DHL泛欧.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayProduct dhl = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "DHL泛欧",
List.of("DHL泛欧")).getProducts().get(0);
assertPanEuroPrices(dhl);
assertEquals(15, dhl.getSurcharges().size(), dhl.getSurcharges().stream()
.map(fee -> fee.getFeeName() + "/" + fee.getChargeText()).toList().toString());
}
@Test
void panEuroSheetInCatalogDoesNotCopyPricesAndKeepsHighRisk() throws Exception
{
Path dir = Path.of("D:/应付供应商");
Assumptions.assumeTrue(Files.isDirectory(dir));
Path file;
try (Stream<Path> files = Files.list(dir))
{
file = files.filter(path -> path.getFileName().toString().startsWith("8、") && path.toString().endsWith(".xlsx"))
.findFirst().orElseThrow();
}
PayProduct dhl = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "DHL泛欧",
List.of("DHL泛欧")).getProducts().get(0);
assertPanEuroPrices(dhl);
assertTrue(dhl.getRelatedSheet() != null && dhl.getRelatedSheet().contains("高风险"));
PaySurcharge afghan = dhl.getSurcharges().stream()
.filter(fee -> "高风险地区附加费".equals(fee.getFeeName()) && "AF".equals(fee.getCalcParam())).findFirst().orElseThrow();
assertEquals("Afghanistan", afghan.getZoneName());
assertTrue(decimalFee(afghan, "10.4"));
assertTrue(dhl.getSurcharges().stream().filter(fee -> "高风险地区附加费".equals(fee.getFeeName()) && "AF".equals(fee.getCalcParam())).count() == 1);
}
@Test
void nlDhlVolumeStaysOnTheWeightBand() throws Exception
{
Path file = Path.of("D:/payment/NL-DHL.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "NL-DHL",
List.of("NL-DHL"));
PayProduct dhl = preview.getProducts().get(0);
assertEquals(10, dhl.getWeightBands().size(), dhl.getWeightBands().stream()
.map(band -> band.getWeightLabel() + "/" + band.getChargeName() + "/" + band.getZoneName() + "/" + band.getPrice()
+ "/" + band.getVolumeText())
.toList().toString());
assertTrue(dhl.getWeightBands().stream().noneMatch(band -> textOf(band.getChargeName()).contains("长")
|| textOf(band.getZoneName()).contains("长")));
assertTrue(dhl.getWeightBands().stream().allMatch(band -> band.getVolumeText() != null && band.getVolumeText().endsWith("cm³")));
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> "0<W≤5".equals(band.getWeightLabel())
&& decimal(band, "3.49") && "10000cm³".equals(band.getVolumeText())
&& "处理费".equals(band.getChargeName()) && "EUR/ITEM".equals(band.getPriceUnit())));
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> "20<W≤31.5".equals(band.getWeightLabel())
&& decimal(band, "11.38") && "432000cm³".equals(band.getVolumeText())));
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> "0<W≤5".equals(band.getWeightLabel())
&& decimal(band, "4.93") && "10000cm³".equals(band.getVolumeText())
&& band.getCountryName().contains("比利时") && "BE".equals(band.getCountryCode())));
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> "20<W≤31.5".equals(band.getWeightLabel())
&& decimal(band, "19.88") && "432000cm³".equals(band.getVolumeText())));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().startsWith("超尺寸附加费")));
}
@Test
void royalMailReadsRemoteAreaTableAndSkipsRemarks() throws Exception
{
Path file = Path.of("D:/payment/英国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());
PayProduct rm = preview.getProducts().get(0);
assertEquals(4, rm.getWeightBands().size());
assertTrue(rm.getWeightBands().stream().anyMatch(band -> "0-5".equals(band.getWeightLabel()) && decimal(band, "1.62")
&& band.getWeightMin() != null && band.getWeightMin().compareTo(java.math.BigDecimal.ZERO) == 0
&& band.getWeightMax() != null && band.getWeightMax().compareTo(new java.math.BigDecimal("5")) == 0
&& "每票".equals(band.getChargeName()) && "GBP/票".equals(band.getPriceUnit())));
assertTrue(rm.getWeightBands().stream().anyMatch(band -> "15.001-20".equals(band.getWeightLabel()) && decimal(band, "5.18")
&& band.getWeightMin() != null && band.getWeightMin().compareTo(new java.math.BigDecimal("15.001")) == 0
&& band.getWeightMax() != null && band.getWeightMax().compareTo(new java.math.BigDecimal("20")) == 0));
List<PaySurcharge> remote = rm.getSurcharges().stream().filter(fee -> "偏远地区附加费".equals(fee.getFeeName())).toList();
assertEquals(3, remote.size(), remote.stream().map(fee -> fee.getChargeText() + " / " + fee.getFeeDesc()).toList().toString());
assertTrue(remote.stream().anyMatch(fee -> fee.getChargeText().contains("1.80") && fee.getFeeDesc().contains("Area A")
&& fee.getFeeDesc().contains("Highlands") && fee.getFeeDesc().contains("IV")));
assertTrue(remote.stream().anyMatch(fee -> fee.getChargeText().contains("1.80") && fee.getFeeDesc().contains("Isle of Man")
&& fee.getFeeDesc().contains("ZE")));
assertTrue(remote.stream().anyMatch(fee -> fee.getChargeText().contains("1.00") && fee.getFeeDesc().contains("Area B")
&& fee.getFeeDesc().contains("BT")));
assertTrue(remote.stream().allMatch(fee -> "POSTCODE".equals(fee.getCalcRule())));
assertTrue(remote.stream().anyMatch(fee -> fee.getCalcParam() != null && fee.getCalcParam().contains("IV")));
assertTrue(remote.stream().anyMatch(fee -> fee.getCalcParam() != null && fee.getCalcParam().contains("BT")));
assertTrue(rm.getSurcharges().stream().anyMatch(fee -> "签名费".equals(fee.getFeeName()) && fee.getChargeText().contains("0.7")
&& "MANUAL".equals(fee.getCalcRule())));
assertTrue(rm.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("超大附加费") && fee.getFeeDesc().contains("61")
&& "SIZE".equals(fee.getCalcRule()) && fee.getCalcParam() != null && fee.getCalcParam().contains("box:61")));
assertTrue(rm.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("超重")
&& "WEIGHT".equals(fee.getCalcRule()) && "20".equals(fee.getCalcParam())));
assertTrue(rm.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("重复单号") && fee.getChargeText().contains("23")));
assertTrue(rm.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("未预报") && fee.getChargeText().contains("1.50")));
assertTrue(rm.getSurcharges().stream().noneMatch(fee -> fee.getFeeDesc() != null && (fee.getFeeDesc().contains("计费重量")
|| fee.getFeeDesc().contains("赔偿条款") || fee.getFeeDesc().contains("royalmail.com"))));
assertTrue(rm.getServiceNote() == null || rm.getServiceNote().isBlank());
assertTrue(rm.getExtraLines().isEmpty());
}
@Test
void evriOtherFeesKeepOneRowPerRegion() throws Exception
{
Path file = Path.of("D:/payment/英国evri.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "英国evri", List.of("英国evri"));
PayProduct evri = preview.getProducts().get(0);
List<PaySurcharge> oversize = evri.getSurcharges().stream()
.filter(fee -> "超尺寸费".equals(fee.getFeeName())).toList();
assertEquals(6, oversize.size(), oversize.stream().map(fee -> fee.getZoneName() + "=" + fee.getChargeText()).toList().toString());
assertTrue(oversize.stream().anyMatch(fee -> fee.getZoneName().contains("英国大陆") && fee.getChargeText().contains("6.99")));
assertTrue(oversize.stream().anyMatch(fee -> fee.getZoneName().contains("高地") && fee.getChargeText().contains("9.67")));
assertTrue(oversize.stream().anyMatch(fee -> fee.getZoneName().contains("马恩岛") && fee.getChargeText().contains("9.67")));
assertTrue(evri.getSurcharges().stream().anyMatch(fee -> "周末服务费".equals(fee.getFeeName())
&& fee.getZoneName().contains("北爱尔兰") && fee.getChargeText().contains("3.02")));
assertTrue(evri.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().startsWith("旺季附加费")
&& fee.getZoneName().contains("英国大陆") && fee.getChargeText().contains("0.15")));
assertTrue(evri.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().startsWith("旺季附加费")
&& fee.getZoneName().contains("马恩岛") && fee.getChargeText().contains("5.5")));
assertTrue(evri.getSurcharges().stream().noneMatch(fee -> fee.getFeeName() != null
&& (fee.getFeeName().contains("服务说明") || fee.getFeeName().contains("索赔"))));
}
@Test
void spainMainlandKeepsAreasAndTwoSurcharges() throws Exception
{
Path file = Path.of("D:/payment/西邮-西班牙(主岛).xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "西邮-西班牙(主岛)",
List.of("西邮-西班牙(主岛)"));
PayProduct spain = preview.getProducts().get(0);
assertEquals(160, spain.getWeightBands().size());
assertTrue(spain.getWeightBands().stream().anyMatch(band -> band.getWeightLabel().contains("0.01-0.25")
&& "MAD-BCN Dense".equals(band.getZoneName()) && decimal(band, "2.27") && "每件".equals(band.getChargeName())
&& "EUR/件".equals(band.getPriceUnit())));
assertTrue(spain.getWeightBands().stream().anyMatch(band -> band.getWeightLabel().contains("0.01-0.25")
&& "Rural Resto".equals(band.getZoneName()) && decimal(band, "3.12")));
assertTrue(spain.getWeightBands().stream().anyMatch(band -> band.getWeightLabel().contains("29-30")
&& "Denso Resto".equals(band.getZoneName()) && decimal(band, "10.08")));
assertTrue(spain.getSurcharges().stream().anyMatch(fee -> "重新贴尾程标签附加费".equals(fee.getFeeName())
&& fee.getChargeText().contains("0.35") && fee.getFeeDesc().contains("重新贴尾程标签")));
List<PaySurcharge> oversize = spain.getSurcharges().stream().filter(fee -> "特大包裹收费".equals(fee.getFeeName())).toList();
assertEquals(2, oversize.size(), oversize.stream().map(PaySurcharge::getChargeText).toList().toString());
assertTrue(oversize.stream().anyMatch(fee -> fee.getChargeText().contains("35%") && fee.getFeeDesc().contains("120")
&& "SIZE".equals(fee.getCalcRule()) && fee.getCalcParam() != null && fee.getCalcParam().contains("join:or")));
assertTrue(oversize.stream().anyMatch(fee -> fee.getChargeText().contains("70%")
&& "SIZE".equals(fee.getCalcRule()) && fee.getCalcParam() != null && fee.getCalcParam().contains("join:and")));
assertTrue(spain.getSurcharges().stream().anyMatch(fee -> "重新贴尾程标签附加费".equals(fee.getFeeName())
&& "MANUAL".equals(fee.getCalcRule())));
assertTrue(spain.getSurcharges().stream().noneMatch(fee -> fee.getFeeName() != null
&& (fee.getFeeName().contains("禁运") || fee.getFeeName().contains("条款"))));
}
@Test
void spainGlsSplitsCountryCodesAndServiceFees() throws Exception
{
Path file = Path.of("D:/payment/西班牙GLS.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "西班牙GLS",
List.of("西班牙GLS"));
PayProduct gls = preview.getProducts().get(0);
assertEquals("OK", gls.getParseStatus());
assertEquals(476, gls.getWeightBands().size());
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "DE".equals(band.getCountryCode()) && "德国".equals(band.getCountryName())
&& "A".equals(band.getZoneName()) && "1KG".equals(band.getWeightLabel()) && decimal(band, "5.12")
&& band.getWeightMin().compareTo(java.math.BigDecimal.ZERO) == 0
&& band.getWeightMax().compareTo(java.math.BigDecimal.ONE) == 0
&& "每件".equals(band.getChargeName()) && "EUR/件".equals(band.getPriceUnit())));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "NL".equals(band.getCountryCode()) && "荷兰".equals(band.getCountryName())
&& "A".equals(band.getZoneName()) && decimal(band, "5.12")));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "PL".equals(band.getCountryCode()) && "波兰".equals(band.getCountryName())));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "FR".equals(band.getCountryCode()) && "法国".equals(band.getCountryName())
&& "B".equals(band.getZoneName()) && "1KG".equals(band.getWeightLabel()) && decimal(band, "6.38")));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "HU".equals(band.getCountryCode()) && "匈牙利".equals(band.getCountryName())
&& "D".equals(band.getZoneName()) && "3KG".equals(band.getWeightLabel()) && decimal(band, "8.15")));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "GB".equals(band.getCountryCode()) && "英国".equals(band.getCountryName())
&& "E".equals(band.getZoneName()) && "1KG".equals(band.getWeightLabel()) && decimal(band, "7.11")));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "FR2".equals(band.getCountryCode()) && "法国-科西嘉岛".equals(band.getCountryName())
&& "G".equals(band.getZoneName()) && "1KG".equals(band.getWeightLabel()) && decimal(band, "16.01")));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "CY".equals(band.getCountryCode()) && "塞浦路斯".equals(band.getCountryName())
&& "H".equals(band.getZoneName()) && decimal(band, "13.01")));
assertTrue(gls.getWeightBands().stream().anyMatch(band -> "IT".equals(band.getCountryCode()) && "Ad.kg.".equals(band.getWeightLabel())
&& decimal(band, "1.07") && "每公斤".equals(band.getChargeName()) && "EUR/公斤".equals(band.getPriceUnit())
&& band.getWeightMin() == null));
assertEquals(4, gls.getSurcharges().size());
assertTrue(gls.getSurcharges().stream().anyMatch(fee -> "取还服务".equals(fee.getFeeName()) && fee.getChargeText().contains("7")
&& fee.getFeeDesc().contains("送货上门") && decimalFee(fee, "7")));
assertTrue(gls.getSurcharges().stream().anyMatch(fee -> "取货发货服务".equals(fee.getFeeName()) && decimalFee(fee, "7")));
assertTrue(gls.getSurcharges().stream().anyMatch(fee -> "现金服务".equals(fee.getFeeName()) && fee.getFeeDesc().contains("交货付款")
&& decimalFee(fee, "6")));
assertTrue(gls.getSurcharges().stream().anyMatch(fee -> "灵活配送服务".equals(fee.getFeeName()) && "free".equalsIgnoreCase(fee.getChargeText())
&& decimalFee(fee, "0") && fee.getFeeDesc().contains("灵活性")));
assertTrue(gls.getSurcharges().stream().noneMatch(fee -> fee.getFeeName() != null && (fee.getFeeName().contains("格陵兰")
|| fee.getFeeName().contains("转账") || fee.getFeeName().contains("备注") || fee.getFeeName().contains("能源"))));
assertTrue(gls.getServiceNote() == null || gls.getServiceNote().isBlank());
}
@Test
void ctt48hFileKeepsSurchargeTablesOutOfWeightBands() throws Exception
{
Path file = Path.of("D:/payment/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);
assertTrue(ctt.getWeightBands().stream().allMatch(band -> "派送费".equals(band.getChargeName())),
ctt.getWeightBands().stream().map(band -> band.getChargeName() + "/" + band.getZoneName() + "/" + band.getPrice()).toList().toString());
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> decimal(band, "2.05") && band.getZoneName().contains("马德里")));
assertTrue(ctt.getWeightBands().stream().noneMatch(band -> bandText(band).contains("偏远") || bandText(band).contains("额外")
|| bandText(band).toLowerCase().contains("line-haul")));
PaySurcharge balearic = ctt.getSurcharges().stream()
.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")));
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公里")));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "超重附加费".equals(fee.getFeeName()) && decimalFee(fee, "5.98")));
}
@Test
void spainCttMainlandReadsBillableConcepts() throws Exception
{
Path file = Path.of("D:/payment/西班牙CTT带电专线(主要地区).xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(),
"西班牙CTT带电专线(主要地区)", List.of("西班牙CTT带电专线(主要地区)"));
PayProduct ctt = preview.getProducts().get(0);
assertEquals("OK", ctt.getParseStatus());
assertEquals("单票价", ctt.getChargeType());
assertTrue(ctt.getExtraPerKg() == null || ctt.getExtraPerKg().isBlank());
assertEquals(150, ctt.getWeightBands().size());
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "1".equals(band.getWeightLabel())
&& "Mad High Density".equals(band.getZoneName()) && decimal(band, "2.11")));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "30".equals(band.getWeightLabel())
&& "Portugal".equals(band.getZoneName()) && decimal(band, "11.96")));
assertEquals(15, ctt.getSurcharges().size());
assertEquals(5, ctt.getSurcharges().stream().filter(fee -> "Extra Line-Haul (*)".equals(fee.getFeeName())).count());
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "Extra Line-Haul (*)".equals(fee.getFeeName())
&& "Mad High Density".equals(fee.getZoneName()) && decimalFee(fee, "0.12") && "EUR".equals(fee.getCurrency())
&& "MANUAL".equals(fee.getCalcRule())));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "Extra Line-Haul Additional Kg (**)".equals(fee.getFeeName())
&& "Portugal".equals(fee.getZoneName()) && decimalFee(fee, "0.08")));
List<PaySurcharge> pudo = ctt.getSurcharges().stream().filter(fee -> "Pudo".equals(fee.getFeeName())).toList();
assertEquals(1, pudo.size());
assertTrue(pudo.get(0).getChargeText().contains("0.10") && pudo.get(0).getChargeText().contains("欧元"));
assertTrue(decimalFee(pudo.get(0), "0.10"));
assertEquals("", pudo.get(0).getZoneName() == null ? "" : pudo.get(0).getZoneName());
assertEquals(1, ctt.getSurcharges().stream().filter(fee -> fee.getFeeName().contains("COD")).count());
assertEquals(1, ctt.getSurcharges().stream().filter(fee -> "Reverse Logistics".equals(fee.getFeeName())).count());
assertEquals(1, ctt.getSurcharges().stream().filter(fee -> "Fuel rate".equals(fee.getFeeName())).count());
assertEquals(1, ctt.getSurcharges().stream().filter(fee -> "Volumetric weight".equals(fee.getFeeName())).count());
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("COD")
&& fee.getChargeText().contains("1%") && fee.getAmount() == null));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "Volumetric weight".equals(fee.getFeeName())
&& fee.getChargeText().contains("90cm") && fee.getAmount() == null && "MANUAL".equals(fee.getCalcRule())));
assertTrue(ctt.getSurcharges().stream().noneMatch(fee -> fee.getFeeName() != null && (fee.getFeeName().contains("计费重量")
|| fee.getFeeName().contains("4.95") || fee.getFeeName().contains("丢货") || fee.getFeeName().contains("退件"))));
}
@Test
void spainCttIslandsReadsFiveRateTables() throws Exception
{
Path file = Path.of("D:/payment/西班牙CTT带电专线(岛屿+其他地区).xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(),
"西班牙CTT带电专线(岛屿+其他地区)", List.of("西班牙CTT带电专线(岛屿+其他地区)"));
PayProduct ctt = preview.getProducts().get(0);
assertEquals("OK", ctt.getParseStatus());
assertEquals("EUR", ctt.getCurrency());
assertEquals("续重计费", ctt.getChargeType());
assertTrue(covers(ctt.getExtraPerKg(), "1.86"));
assertTrue(covers(ctt.getExtraPerKg(), "5.79"));
assertEquals(48, ctt.getWeightBands().size());
assertEquals(4, ctt.getSurcharges().size());
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "2. INSULAR RATES / STANDARD".equals(band.getSectionName())
&& "Balearic Islands Economy 48H / Majorca".equals(band.getZoneName()) && "西班牙".equals(band.getCountryName()) && "ES".equals(band.getCountryCode())
&& "Up to 1 kg".equals(band.getWeightLabel()) && decimal(band, "3.39") && "每件".equals(band.getChargeName())
&& "EUR/件".equals(band.getPriceUnit()) && band.getWeightMax().compareTo(java.math.BigDecimal.ONE) == 0));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "2. INSULAR RATES / STANDARD".equals(band.getSectionName())
&& "Canary Islands / Minor Islands".equals(band.getZoneName()) && "Additional Kg".equals(band.getWeightLabel())
&& decimal(band, "1.86") && "每公斤".equals(band.getChargeName()) && band.getWeightMin() == null));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "2. INSULAR RATES / EXPRESS".equals(band.getSectionName())
&& "Canary Islands / Mayor Islands".equals(band.getZoneName()) && "Up to 1 kg".equals(band.getWeightLabel())
&& decimal(band, "7.46")));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "Price WITH CUSTOMS CLEARANCE / STANDARD".equals(band.getSectionName())
&& "Canary Islands (*) / Mayor Islands".equals(band.getZoneName()) && decimal(band, "5.33")));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "Price WITH CUSTOMS CLEARANCE / EXPRESS".equals(band.getSectionName())
&& "Portuguese Island / Island".equals(band.getZoneName()) && "Additional Kg".equals(band.getWeightLabel())
&& decimal(band, "5.79")));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> band.getSectionName().contains("CEUTA")
&& "Ceuta".equals(band.getZoneName()) && "Up to 1 kg".equals(band.getWeightLabel()) && decimal(band, "7.61")));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> band.getSectionName().contains("CEUTA")
&& "Andorra".equals(band.getZoneName()) && "Additional Kg".equals(band.getWeightLabel()) && decimal(band, "2.85")));
assertTrue(ctt.getSurcharges().stream().allMatch(fee -> "Cubic Capacity".equals(fee.getFeeName()) && fee.getAmount() == null
&& "MANUAL".equals(fee.getCalcRule())));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "2. INSULAR RATES / STANDARD".equals(fee.getSectionName())
&& fee.getChargeText().contains("167 kg/m3") && fee.getChargeText().contains("333 kg/m3")
&& fee.getFeeDesc().contains("Balearic Islands Economy 48H / Majorca")
&& fee.getFeeDesc().contains("Canary Islands / Interislands")));
assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "2. INSULAR RATES / EXPRESS".equals(fee.getSectionName())
&& fee.getFeeDesc().contains("Portuguese Island / Island")));
assertTrue(ctt.getSurcharges().stream().noneMatch(fee -> fee.getSectionName() != null && fee.getSectionName().contains("CEUTA")));
assertTrue(ctt.getWeightBands().stream().noneMatch(band -> band.getZoneName() != null && band.getZoneName().contains("19E")));
assertTrue(ctt.getSurcharges().stream().noneMatch(fee -> fee.getFeeName() != null && (fee.getFeeName().contains("计费重量")
|| fee.getFeeName().contains("4.95") || fee.getFeeName().contains("退货"))));
}
@Test
void spainCttFillsCountryAndContinuedWeight() throws Exception
{
Path file = Path.of("D:/payment/西班牙CTT.xlsx");
Assumptions.assumeTrue(Files.exists(file));
PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "西班牙CTT",
List.of("西班牙CTT"));
PayProduct ctt = preview.getProducts().get(0);
assertEquals("OK", ctt.getParseStatus());
assertEquals("西班牙", ctt.getCountryScope());
assertEquals("续重计费", ctt.getChargeType());
assertTrue(covers(ctt.getExtraPerKg(), "0.82"));
assertEquals(72, ctt.getWeightBands().size());
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "西班牙".equals(band.getCountryName()) && "ES".equals(band.getCountryCode())
&& "Mainland / Mad High Density".equals(band.getZoneName()) && "1kg".equals(band.getWeightLabel())
&& decimal(band, "2.1") && "每件".equals(band.getChargeName())
&& band.getWeightMin().compareTo(java.math.BigDecimal.ZERO) == 0
&& band.getWeightMax().compareTo(java.math.BigDecimal.ONE) == 0));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "Mainland / Portugal".equals(band.getZoneName())
&& "5kg".equals(band.getWeightLabel()) && decimal(band, "3.95")
&& band.getWeightMin().compareTo(new java.math.BigDecimal("4")) == 0));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "Balearic Islands Economy 48H / Majorca".equals(band.getZoneName())
&& "Kg.ad.".equals(band.getWeightLabel()) && decimal(band, "0.82") && "每公斤".equals(band.getChargeName())
&& band.getWeightMin() == null));
assertTrue(ctt.getWeightBands().stream().anyMatch(band -> "Canary Islands / Minor Islands".equals(band.getZoneName())
&& "1kg".equals(band.getWeightLabel()) && decimal(band, "13.11")));
assertTrue(ctt.getWeightBands().stream().noneMatch(band -> band.getWeightLabel() != null && band.getWeightLabel().contains("预估")));
}
private PayPreview parse(String prefix) throws Exception
{
Path dir = Path.of("D:/应付供应商");
Path file;
try (Stream<Path> files = Files.list(dir))
{
file = files.filter(path -> path.getFileName().toString().startsWith(prefix) && path.toString().endsWith(".xlsx"))
.findFirst().orElseThrow();
}
return parser.parse(Files.readAllBytes(file), file.getFileName().toString());
}
private void assertPanEuroPrices(PayProduct dhl)
{
assertEquals(52, dhl.getWeightBands().size(), dhl.getWeightBands().stream()
.map(band -> band.getCountryCode() + "/" + band.getChargeName() + "/" + band.getPrice()).toList().toString());
assertEquals(26, dhl.getWeightBands().stream().map(PayWeightBand::getCountryCode).distinct().count());
assertEquals(26, dhl.getWeightBands().stream().filter(band -> "处理费".equals(band.getChargeName())).count());
assertEquals(26, dhl.getWeightBands().stream().filter(band -> "速递运费".equals(band.getChargeName())).count());
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> "DK".equals(band.getCountryCode())
&& "处理费".equals(band.getChargeName()) && decimal(band, "9.71") && "EUR/ITEM".equals(band.getPriceUnit())));
assertTrue(dhl.getWeightBands().stream().anyMatch(band -> "DK".equals(band.getCountryCode())
&& "速递运费".equals(band.getChargeName()) && decimal(band, "0.23") && "EUR/KG".equals(band.getPriceUnit())));
assertTrue(dhl.getServiceNote() != null && dhl.getServiceNote().contains("报价已含燃油"));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "其他费用".equals(fee.getSectionName())
&& "道路通行费".equals(fee.getFeeName()) && decimalFee(fee, "0.19")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "旺季附加费".equals(fee.getFeeName()) && decimalFee(fee, "0.19")
&& fee.getEffectiveStart() != null));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "高峰旺季附加费".equals(fee.getFeeName()) && decimalFee(fee, "0.5")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "超尺寸超重费".equals(fee.getFeeName()) && decimalFee(fee, "14")
&& "产生时收取".equals(fee.getChargeTiming())));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "路由码选择附加费".equals(fee.getFeeName()) && decimalFee(fee, "0.49")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "重量信息修正附加费".equals(fee.getFeeName()) && decimalFee(fee, "0.99")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "未预报附加费".equals(fee.getFeeName())
&& fee.getChargeText().contains("31.5kg")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "托收附加费".equals(fee.getFeeName()) && decimalFee(fee, "2.2")
&& textOf(fee.getFeeDesc()).contains("Supplemental")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "提供虚假信息罚款费".equals(fee.getFeeName()) && decimalFee(fee, "18")
&& textOf(fee.getFeeDesc()).contains("故意")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "高风险地区附加费".equals(fee.getFeeName())
&& textOf(fee.getChargeText()).contains("高风险")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "退件费".equals(fee.getFeeName()) && decimalFee(fee, "20")
&& textOf(fee.getFeeDesc()).contains("空运")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "退件费".equals(fee.getFeeName()) && decimalFee(fee, "10")
&& textOf(fee.getFeeDesc()).contains("陆运")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "重派费".equals(fee.getFeeName())
&& textOf(fee.getChargeText()).contains("同正向运费")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "退件操作费".equals(fee.getFeeName()) && decimalFee(fee, "0.5")));
assertTrue(dhl.getSurcharges().stream().anyMatch(fee -> "重派操作费".equals(fee.getFeeName()) && decimalFee(fee, "1")));
}
private PayProduct product(PayPreview preview, String name)
{
return preview.getProducts().stream().filter(item -> name.equals(item.getProductName())).findFirst().orElseThrow();
}
private boolean covers(String extraPerKg, String price)
{
if (extraPerKg == null || extraPerKg.isBlank())
{
return false;
}
java.math.BigDecimal value = new java.math.BigDecimal(price);
String[] parts = extraPerKg.split("~");
java.math.BigDecimal low = new java.math.BigDecimal(parts[0]);
java.math.BigDecimal high = new java.math.BigDecimal(parts[parts.length - 1]);
return low.compareTo(value) <= 0 && high.compareTo(value) >= 0;
}
private String textOf(String value)
{
return value == null ? "" : value;
}
private boolean decimal(PayWeightBand band, String price)
{
return band.getPrice() != null && band.getPrice().compareTo(new java.math.BigDecimal(price)) == 0;
}
private boolean decimalFee(PaySurcharge fee, String amount)
{
return fee.getAmount() != null && fee.getAmount().compareTo(new java.math.BigDecimal(amount)) == 0;
}
private String bandText(PayWeightBand band)
{
return (band.getChargeName() == null ? "" : band.getChargeName()) + " "
+ (band.getSectionName() == null ? "" : band.getSectionName()) + " "
+ (band.getZoneName() == null ? "" : band.getZoneName());
}
private String zoneTail(PayWeightBand band)
{
String zone = band.getZoneName() == null ? "" : band.getZoneName();
return zone.contains("英国大陆") ? "英国大陆" : zone;
}
}
@@ -0,0 +1,72 @@
package com.bms.price.pay;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import com.bms.price.domain.PayPostcodeLine;
class PayZoneMatcherTest
{
private static final List<String> ZONES = List.of(
"UK Mainland\n英国大陆",
"Highlands& Islands\n高地和岛屿",
"Channel Islands\n(海峡群岛,包括Isle of Scilly锡利岛)",
"Northern Ireland\n北爱尔兰",
"BFPO\n(军事地址)",
"Isle of Man\n马恩岛");
@Test
void welshPostcodeFallsBackToMainland()
{
PayZoneMatcher.Decision decision = PayZoneMatcher.resolve(ZONES, evriLines(), "LL22 8PJ");
assertNull(decision.getMessage());
assertEquals("UK Mainland\n英国大陆", decision.getZoneName());
}
@Test
void northernIrelandAndHighlandsComeFromThePostcodeBook()
{
assertEquals("Northern Ireland\n北爱尔兰", PayZoneMatcher.resolve(ZONES, evriLines(), "BT1 1AA").getZoneName());
assertEquals("Highlands& Islands\n高地和岛屿", PayZoneMatcher.resolve(ZONES, evriLines(), "IV1 1AA").getZoneName());
assertEquals("Channel Islands\n(海峡群岛,包括Isle of Scilly锡利岛)",
PayZoneMatcher.resolve(ZONES, evriLines(), "TR21 1AA").getZoneName());
assertEquals("Isle of Man\n马恩岛", PayZoneMatcher.resolve(ZONES, evriLines(), "IM1 1AA").getZoneName());
assertEquals("BFPO\n(军事地址)", PayZoneMatcher.resolve(ZONES, evriLines(), "BF1 0AA").getZoneName());
}
@Test
void productNameKeepsTheCarrierToken()
{
assertEquals(List.of("evri"), PayZoneMatcher.searchTokens("英国evri"));
assertEquals(List.of("ubi", "evri"), PayZoneMatcher.searchTokens("UBI英国EVRi普"));
}
@Test
void missingPostcodeIsRejected()
{
PayZoneMatcher.Decision decision = PayZoneMatcher.resolve(ZONES, evriLines(), " ");
assertTrue(decision.getMessage().contains("没有邮编"));
}
private static List<PayPostcodeLine> evriLines()
{
return List.of(
line("Channel Islands", "GY"),
line("Isle of Man", "IM"),
line("Northern Ireland", "BT"),
line("Scilly Isles", "TR21"),
line("Scottish H&I (Finance)", "IV"),
line("Scottish H&I (Finance)", "HS"),
line("Shetlands", "ZE"));
}
private static PayPostcodeLine line(String region, String postcode)
{
PayPostcodeLine line = new PayPostcodeLine();
line.setRegionName(region);
line.setPostcode(postcode);
return line;
}
}