diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/data/PayPostcodeController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/data/PayPostcodeController.java new file mode 100644 index 00000000..5954a667 --- /dev/null +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/data/PayPostcodeController.java @@ -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 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 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)); + } +} diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PayBillController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PayBillController.java new file mode 100644 index 00000000..434d7755 --- /dev/null +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PayBillController.java @@ -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 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(); + } +} diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PayProductController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PayProductController.java new file mode 100644 index 00000000..4a1c98e9 --- /dev/null +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/price/PayProductController.java @@ -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 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 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"); + } + } +} diff --git a/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayCostCalculatorTest.java b/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayCostCalculatorTest.java new file mode 100644 index 00000000..52284ae0 --- /dev/null +++ b/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayCostCalculatorTest.java @@ -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; + } +} diff --git a/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayPostcodeImporterTest.java b/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayPostcodeImporterTest.java new file mode 100644 index 00000000..bf2372b8 --- /dev/null +++ b/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayPostcodeImporterTest.java @@ -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 groups = importer.parse(Files.readAllBytes(file("32、")), "32.xlsx"); + List 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 kept = PayPostcodeImporter.expandPostcode("KA27-28"); + assertEquals("KA27-28", kept.get(0).postcode); + assertEquals("", kept.get(0).end); + List range = PayPostcodeImporter.expandPostcode("57030-57039"); + assertEquals("57030", range.get(0).postcode); + assertEquals("57039", range.get(0).end); + } + + @Test + void reachableUnreachableAndRemoteKeepTheirOwnCountry() throws Exception + { + List 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 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 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 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 files = Files.list(Path.of("D:/应付供应商"))) + { + return files.filter(path -> path.getFileName().toString().startsWith(prefix) && path.toString().endsWith(".xlsx")).findFirst().orElseThrow(); + } + } +} diff --git a/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayWorkbookParserTest.java b/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayWorkbookParserTest.java new file mode 100644 index 00000000..d995a3d1 --- /dev/null +++ b/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayWorkbookParserTest.java @@ -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 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 "20 "0 "20 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 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 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 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 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 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; + } +} diff --git a/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayZoneMatcherTest.java b/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayZoneMatcherTest.java new file mode 100644 index 00000000..4445ab5c --- /dev/null +++ b/bms-back/bms-admin/src/test/java/com/bms/price/pay/PayZoneMatcherTest.java @@ -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 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 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; + } +} diff --git a/bms-back/bms-common/src/main/java/com/bms/common/utils/poi/ExcelGrid.java b/bms-back/bms-common/src/main/java/com/bms/common/utils/poi/ExcelGrid.java new file mode 100644 index 00000000..ffb1ace3 --- /dev/null +++ b/bms-back/bms-common/src/main/java/com/bms/common/utils/poi/ExcelGrid.java @@ -0,0 +1,290 @@ +package com.bms.common.utils.poi; + +import java.math.BigDecimal; +import java.util.Date; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.DateUtil; +import org.apache.poi.ss.usermodel.Hyperlink; +import org.apache.poi.xssf.usermodel.XSSFHyperlink; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.util.CellRangeAddress; +import com.bms.common.utils.DateUtils; + +/** + * 把一个工作表读成文本网格。 + * 合并单元格只保留左上角的原文,不把国家、处理费、超重费再填到每一段重量上。 + */ +public class ExcelGrid +{ + /** 单表最多读取的行数,避免样式撑满的空行把内存打满 */ + public static final int MAX_ROWS = 2500; + + /** 单表最多读取的列数。价卡分区列不会超过这个宽度 */ + public static final int MAX_COLS = 60; + + private final String[][] text; + + private final Double[][] number; + + private final String[][] linkSheet; + + /** 合并区域左上角横向占几列,其余单元格为 0 */ + private final int[][] colSpan; + + private final int rows; + + private final int cols; + + private ExcelGrid(String[][] text, Double[][] number, String[][] linkSheet, int[][] colSpan, int rows, int cols) + { + this.text = text; + this.number = number; + this.linkSheet = linkSheet; + this.colSpan = colSpan; + this.rows = rows; + this.cols = cols; + } + + /** + * 读取工作表。日期写成 yyyy-MM-dd,不把日期序列号当成金额。 + */ + public static ExcelGrid read(Sheet sheet) + { + return read(sheet, MAX_ROWS); + } + + /** + * 按指定行数上限读取。邮编分区表会超过价卡默认的 2500 行。 + */ + public static ExcelGrid read(Sheet sheet, int maxRows) + { + int limit = maxRows < 1 ? MAX_ROWS : maxRows; + int lastRow = sheet == null || sheet.getLastRowNum() < 0 ? -1 : Math.min(sheet.getLastRowNum(), limit - 1); + int cols = 0; + if (sheet != null) + { + for (int r = 0; r <= lastRow; r++) + { + Row row = sheet.getRow(r); + if (row == null || row.getLastCellNum() <= 0) + { + continue; + } + int lastCell = Math.min(row.getLastCellNum(), MAX_COLS); + for (int c = 0; c < lastCell; c++) + { + Cell cell = row.getCell(c); + if (hasValue(cell) && c + 1 > cols) + { + cols = c + 1; + } + } + } + } + int rows = lastRow + 1; + String[][] text = new String[Math.max(rows, 0)][Math.max(cols, 0)]; + Double[][] number = new Double[Math.max(rows, 0)][Math.max(cols, 0)]; + String[][] linkSheet = new String[Math.max(rows, 0)][Math.max(cols, 0)]; + int[][] colSpan = new int[Math.max(rows, 0)][Math.max(cols, 0)]; + if (sheet != null && rows > 0 && cols > 0) + { + for (int r = 0; r < rows; r++) + { + Row row = sheet.getRow(r); + if (row == null) + { + continue; + } + for (int c = 0; c < cols; c++) + { + fill(row.getCell(c), text, number, linkSheet, r, c); + } + } + for (CellRangeAddress region : sheet.getMergedRegions()) + { + int r0 = Math.max(region.getFirstRow(), 0); + int c0 = Math.max(region.getFirstColumn(), 0); + if (r0 >= rows || c0 >= cols) + { + continue; + } + int width = region.getLastColumn() - region.getFirstColumn() + 1; + if (width > 1) + { + colSpan[r0][c0] = width; + } + } + } + return new ExcelGrid(text, number, linkSheet, colSpan, rows, cols); + } + + public int rows() + { + return rows; + } + + public int cols() + { + return cols; + } + + public String text(int row, int col) + { + if (row < 0 || col < 0 || row >= rows || col >= cols) + { + return ""; + } + return text[row][col] == null ? "" : text[row][col]; + } + + public Double number(int row, int col) + { + if (row < 0 || col < 0 || row >= rows || col >= cols) + { + return null; + } + return number[row][col]; + } + + /** 单元格内部超链接指向的工作表名,没有链接时返回空串 */ + public String linkSheet(int row, int col) + { + if (row < 0 || col < 0 || row >= rows || col >= cols || linkSheet[row][col] == null) + { + return ""; + } + return linkSheet[row][col]; + } + + /** 该格是横向合并的左上角时,返回合并了几列;普通格返回 1 */ + public int colSpan(int row, int col) + { + if (row < 0 || col < 0 || row >= rows || col >= cols) + { + return 1; + } + return colSpan[row][col] > 1 ? colSpan[row][col] : 1; + } + + public boolean blankRow(int row) + { + if (row < 0 || row >= rows) + { + return true; + } + for (int c = 0; c < cols; c++) + { + if (!isBlank(text[row][c])) + { + return false; + } + } + return true; + } + + private static void fill(Cell cell, String[][] text, Double[][] number, String[][] linkSheet, int r, int c) + { + if (cell == null) + { + return; + } + linkSheet[r][c] = sheetName(cell.getHyperlink()); + CellType type = cell.getCellType(); + if (type == CellType.FORMULA) + { + type = cell.getCachedFormulaResultType(); + } + if (type == CellType.NUMERIC) + { + if (DateUtil.isCellDateFormatted(cell)) + { + Date date = cell.getDateCellValue(); + text[r][c] = date == null ? "" : DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD, date); + return; + } + double raw = cell.getNumericCellValue(); + number[r][c] = raw; + text[r][c] = BigDecimal.valueOf(raw).stripTrailingZeros().toPlainString(); + return; + } + if (type == CellType.STRING) + { + text[r][c] = clean(cell.getStringCellValue()); + number[r][c] = plainNumber(text[r][c]); + return; + } + if (type == CellType.BOOLEAN) + { + text[r][c] = Boolean.toString(cell.getBooleanCellValue()); + } + } + + private static boolean hasValue(Cell cell) + { + if (cell == null || cell.getCellType() == CellType.BLANK) + { + return false; + } + if (cell.getCellType() == CellType.STRING) + { + return !isBlank(cell.getStringCellValue()); + } + return true; + } + + private static Double plainNumber(String text) + { + if (text == null || !text.matches("-?\\d+(\\.\\d+)?")) + { + return null; + } + try + { + return Double.valueOf(text); + } + catch (NumberFormatException ex) + { + return null; + } + } + + /** 内部链接形如 目录!A1 或 'evri 偏远'!A1,这里只留工作表名 */ + static String sheetName(Hyperlink link) + { + if (link == null) + { + return ""; + } + String location = link instanceof XSSFHyperlink ? ((XSSFHyperlink) link).getLocation() : ""; + if (isBlank(location)) + { + location = link.getAddress(); + } + if (isBlank(location) || !location.contains("!")) + { + return ""; + } + String name = location.substring(0, location.lastIndexOf('!')).trim(); + if (name.startsWith("'") && name.endsWith("'") && name.length() >= 2) + { + name = name.substring(1, name.length() - 1).replace("''", "'"); + } + return name.trim(); + } + + private static String clean(String value) + { + if (value == null) + { + return ""; + } + return value.replace('\u00A0', ' ').replace('\r', ' ').trim(); + } + + private static boolean isBlank(String value) + { + return value == null || value.trim().isEmpty(); + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/masterdata/mapper/ServiceChannelMapper.java b/bms-back/bms-system/src/main/java/com/bms/masterdata/mapper/ServiceChannelMapper.java index 304dec9f..206fe6d6 100644 --- a/bms-back/bms-system/src/main/java/com/bms/masterdata/mapper/ServiceChannelMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/masterdata/mapper/ServiceChannelMapper.java @@ -27,6 +27,11 @@ public interface ServiceChannelMapper */ ServiceChannel selectServiceChannelByCode(String channelCode); + /** + * 按渠道名称精确查询,重名时返回多条供调用方拒绝。 + */ + List selectServiceChannelByName(String channelName); + /** * 产品渠道关系中引用该渠道编码的数量 */ diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/ChannelProductMap.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/ChannelProductMap.java new file mode 100644 index 00000000..adb921a7 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/ChannelProductMap.java @@ -0,0 +1,35 @@ +package com.bms.price.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +/** + * 渠道和产品的收付关系。payType 取字典 bms_pay_type。 + */ +@Data +public class ChannelProductMap +{ + private Long id; + + /** 渠道表主键 srv_service_channel.id */ + private String channelId; + + private Long productId; + + /** 应收套餐 prc_price_set.price_set_id */ + private Long priceSetId; + + /** AR 应收,AP 应付 */ + private String payType; + + private String createBy; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + + private String updateBy; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date updateTime; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBill.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBill.java new file mode 100644 index 00000000..b674b759 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBill.java @@ -0,0 +1,61 @@ +package com.bms.price.domain; + +import java.math.BigDecimal; +import java.util.Date; +import java.util.List; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +/** 应付账单。作废后仍保留。 */ +@Data +public class PayBill +{ + private Long billId; + + private String billNo; + + private Long supplierId; + + private String supplierName; + + private String currency; + + /** ORDER 订单号,TIME 计费日,LADING 提单 */ + private String sourceType; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date periodStart; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date periodEnd; + + private Integer orderCount; + + private BigDecimal totalAmount; + + /** DRAFT CONFIRMED PAID VOID */ + private String status; + + private String voidReason; + + private String voidBy; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date voidTime; + + private String createBy; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; + + private String updateBy; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date updateTime; + + private String remark; + + private List lines; + + private List logs; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillCommand.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillCommand.java new file mode 100644 index 00000000..0597e3cf --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillCommand.java @@ -0,0 +1,27 @@ +package com.bms.price.domain; + +import lombok.Data; + +/** 查询或生成应付账单。订单号和提单号可用逗号、空格、换行分隔。 */ +@Data +public class PayBillCommand +{ + /** ORDER、TIME、LADING */ + private String sourceType; + + private String orderText; + + private String beginDate; + + private String endDate; + + private String ladingText; + + private Long supplierId; + + private String remark; + + private String voidReason; + + private Long billId; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillLine.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillLine.java new file mode 100644 index 00000000..b3e94268 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillLine.java @@ -0,0 +1,41 @@ +package com.bms.price.domain; + +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +/** 应付账单上的一条费用快照。 */ +@Data +public class PayBillLine +{ + private Long lineId; + + private Long billId; + + private String orderBillId; + + private String orderNo; + + private String ladingNo; + + private Long ladingFeeId; + + private String feeName; + + private BigDecimal amount; + + private String currency; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date priceDate; + + private Integer versionNo; + + private String remoteFlag; + + private String densityLevel; + + /** ORDER 订单费用,LADING 留在主单上的费用 */ + private String lineSource; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillLog.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillLog.java new file mode 100644 index 00000000..544bc9cf --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillLog.java @@ -0,0 +1,27 @@ +package com.bms.price.domain; + +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +/** 应付账单操作记录。作废后不删除。 */ +@Data +public class PayBillLog +{ + private Long logId; + + private Long billId; + + private String operationType; + + private String fromStatus; + + private String toStatus; + + private String operationDesc; + + private String operatorName; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date createTime; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillOrder.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillOrder.java new file mode 100644 index 00000000..7ca399c7 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillOrder.java @@ -0,0 +1,48 @@ +package com.bms.price.domain; + +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +/** 生成应付账单前查到的一票订单。 */ +@Data +public class PayBillOrder +{ + private String billId; + + private String orderNo; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date bizDate; + + private BigDecimal payTotal; + + private String currency; + + private String ladingNo; + + private String trackingNo; + + private String channelName; + + private String productName; + + private String supplierName; + + private Integer pieces; + + private BigDecimal chargeWeight; + + private String countryCode; + + private String countryName; + + private String destPostcode; + + /** 已算出的应付费用,名称和金额用分号拼在一起 */ + private String feeText; + + /** 已挂在未作废账单上时的账单号 */ + private String occupiedBillNo; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillPreview.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillPreview.java new file mode 100644 index 00000000..f2c72759 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayBillPreview.java @@ -0,0 +1,18 @@ +package com.bms.price.domain; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +/** 应付账单生成前的查询结果。 */ +@Data +public class PayBillPreview +{ + private List orders = new ArrayList<>(); + + private List missingNos = new ArrayList<>(); + + private List occupied = new ArrayList<>(); + + private String message; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayCostLine.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayCostLine.java new file mode 100644 index 00000000..86d76d8a --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayCostLine.java @@ -0,0 +1,21 @@ +package com.bms.price.domain; + +import java.math.BigDecimal; +import lombok.Data; + +/** 一条算出的应付费用。 */ +@Data +public class PayCostLine +{ + private String feeType; + + private String feeName; + + private Long scId; + + private BigDecimal amount; + + private String currency; + + private String remark; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayExtraLine.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayExtraLine.java new file mode 100644 index 00000000..0bace8b0 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayExtraLine.java @@ -0,0 +1,21 @@ +package com.bms.price.domain; + +import lombok.Data; + +/** + * 产品报价表里的邮编或其他不能落进重量段、附加费的配置原文。 + */ +@Data +public class PayExtraLine +{ + private Long lineId; + + private Long productId; + + /** 邮编 或 配置 */ + private String lineType; + + private String lineText; + + private Integer sortNo; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayOrderCostResult.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayOrderCostResult.java new file mode 100644 index 00000000..0ddb8b5d --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayOrderCostResult.java @@ -0,0 +1,32 @@ +package com.bms.price.domain; + +import java.math.BigDecimal; +import java.util.List; +import lombok.Data; + +/** 按订单生成应付的结果。skipped 是价卡里有、但这票算不了的附加费。 */ +@Data +public class PayOrderCostResult +{ + private String billId; + + private String channelId; + + private String channelName; + + private Long productId; + + private String productName; + + private Integer versionNo; + + private String currency; + + private BigDecimal payTotal; + + private String message; + + private List fees; + + private List skipped; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPostcodeGroup.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPostcodeGroup.java new file mode 100644 index 00000000..b2121492 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPostcodeGroup.java @@ -0,0 +1,64 @@ +package com.bms.price.domain; + +import java.util.ArrayList; +import java.util.List; +import com.bms.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 应付邮编配置的一条分区或一条合并后的附加费。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class PayPostcodeGroup extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long groupId; + + private String sourceFile; + + private String fileMd5; + + private String sheetName; + + private String blockTitle; + + /** ZONE 邮编分区,FEE 附加费 */ + private String configKind; + + /** REACH 可达,UNREACH 不可达,REMOTE 偏远。普通分区为空 */ + private String coverage; + + /** 渠道名称,从表头识别,可在页面修改 */ + private String channelName; + + /** 目录里的服务类型,和报价产品对应 */ + private String serviceType; + + /** 目录里的国家。邮编目的国仍在 countryName,偏远表里两者可以不同 */ + private String catalogCountry; + + /** 目录里的销售产品 */ + private String productName; + + /** 目录里的报价工作表 */ + private String quoteSheet; + + private String countryName; + + private String countryCode; + + private String feeName; + + private String feeText; + + private Integer lineCount; + + /** 查询条件:邮编 */ + private String postcode; + + /** 导入时挂上的明细,列表查询不填充 */ + private List lines = new ArrayList<>(); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPostcodeImportResult.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPostcodeImportResult.java new file mode 100644 index 00000000..988136c0 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPostcodeImportResult.java @@ -0,0 +1,23 @@ +package com.bms.price.domain; + +import java.io.Serializable; +import lombok.Data; + +/** 一次 Excel 导入的汇总,不把全部邮编返回给页面。 */ +@Data +public class PayPostcodeImportResult implements Serializable +{ + private static final long serialVersionUID = 1L; + + private String sourceFile; + + private int groupCount; + + private int feeCount; + + private int zoneCount; + + private int lineCount; + + private String message; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPostcodeLine.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPostcodeLine.java new file mode 100644 index 00000000..0f5e5fe9 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPostcodeLine.java @@ -0,0 +1,41 @@ +package com.bms.price.domain; + +import java.io.Serializable; +import lombok.Data; + +/** + * 一条邮编。除末尾星号外,地区、分区和金额都保持 Excel 原文。 + */ +@Data +public class PayPostcodeLine implements Serializable +{ + private static final long serialVersionUID = 1L; + + private Long lineId; + + private Long groupId; + + private String countryName; + + private String countryCode; + + private String regionName; + + private String regionNameCn; + + private String cityName; + + private String regionCode; + + private String zoneName; + + private String postcode; + + private String postcodeRaw; + + private String postcodeEnd; + + private String extraText; + + private Integer sortNo; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPreview.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPreview.java new file mode 100644 index 00000000..62192e6e --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPreview.java @@ -0,0 +1,28 @@ +package com.bms.price.domain; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +/** + * 导入预览。此时还没有写入数据库。 + */ +@Data +public class PayPreview +{ + private String sourceFile; + + private String fileMd5; + + private String supplierName; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date effectiveDate; + + private List products = new ArrayList<>(); + + /** 没有对上字典、因此没有读取的工作表 */ + private List skippedSheets = new ArrayList<>(); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPriceCompare.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPriceCompare.java new file mode 100644 index 00000000..d773a2b2 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPriceCompare.java @@ -0,0 +1,17 @@ +package com.bms.price.domain; + +import java.util.List; +import lombok.Data; + +/** 旧版和新版价格对照。 */ +@Data +public class PayPriceCompare +{ + private PayProduct older; + + private PayProduct newer; + + private List weightDiffs; + + private List surchargeDiffs; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPriceDiff.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPriceDiff.java new file mode 100644 index 00000000..33fb2343 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayPriceDiff.java @@ -0,0 +1,19 @@ +package com.bms.price.domain; + +import lombok.Data; + +/** 两个价格版本里的一行差异。 */ +@Data +public class PayPriceDiff +{ + private String name; + + private String zoneName; + + /** 相同、变更、新增、删除。新增和删除相对新版本而言 */ + private String change; + + private String oldValue; + + private String newValue; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProduct.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProduct.java new file mode 100644 index 00000000..39d95093 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProduct.java @@ -0,0 +1,146 @@ +package com.bms.price.domain; + +import java.util.Date; +import java.util.List; +import com.bms.common.annotation.Excel; +import com.bms.common.core.domain.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 应付价卡产品。目录里的一个产品对应一行,重量段和附加费在子表。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class PayProduct extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long productId; + + @Excel(name = "产品名称", width = 24) + private String productName; + + /** 支付类型,字典 bms_pay_type:AR应收 AP应付 */ + private String productType; + + /** 字典 payment_product_type 的键值,和报价工作表对应 */ + private String dictValue; + + /** 复制来源。空表示这是公共模板 */ + private Long originId; + + /** 服务渠道 ID。空表示公共模板 */ + private String channelId; + + /** 版本号。模板是 1,渠道另存从 2 起 */ + private Integer versionNo; + + /** 列表只看公共模板 */ + private Boolean templateOnly; + + /** 列表每个产品只显示当前生效版;没有生效版时显示最新一版 */ + private Boolean currentOnly; + + /** 当前价格版本对应的记录。列表上的 productId 是不变的产品编号 */ + private Long versionProductId; + + /** 当前版本的有效期记录 */ + private Long termId; + + /** 0 生效,1 失效 */ + private String termStatus; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date termStart; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date termEnd; + + @Excel(name = "国家/口岸", width = 18) + private String countryScope; + + /** 字典 order_charge_type:续重计费、单票价、常规计重价 */ + @Excel(name = "费用类型", width = 14) + private String chargeType; + + /** 续重时每公斤加价。各分区相同是一个金额,不同是最小~最大 */ + @Excel(name = "每公斤加价", width = 14) + private String extraPerKg; + + @Excel(name = "服务类型", width = 16) + private String serviceType; + + @Excel(name = "分类", width = 16) + private String classification; + + @Excel(name = "目录备注", width = 28) + private String catalogRemark; + + @Excel(name = "更新内容", width = 18) + private String updateContent; + + @Excel(name = "重量限制", width = 14) + private String weightLimit; + + @Excel(name = "报价工作表", width = 24) + private String sheetName; + + @Excel(name = "关联分区表", width = 24) + private String relatedSheet; + + @Excel(name = "来源文件", width = 36) + private String sourceFile; + + private String fileMd5; + + @Excel(name = "价卡归属", width = 12) + private String supplierName; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + @Excel(name = "生效日期", width = 14, dateFormat = "yyyy-MM-dd") + private Date effectiveDate; + + @Excel(name = "币种", width = 10) + private String currency; + + @Excel(name = "服务说明", width = 40) + private String serviceNote; + + @Excel(name = "识别结果", width = 12) + private String parseStatus; + + @Excel(name = "重量段数", width = 12) + private Integer weightCount; + + @Excel(name = "附加费数", width = 12) + private Integer surchargeCount; + + @Excel(name = "其他配置数", width = 12) + private Integer extraCount; + + /** 查询条件:生效日起,格式 yyyy-MM-dd */ + private String effectiveBegin; + + /** 查询条件:生效日止 */ + private String effectiveEnd; + + /** 查询条件:附加费名称、收费标准、费用说明或时效原文 */ + private String surchargeName; + + /** 查询条件:重量段、费用项、分区或国家 */ + private String weightKeyword; + + /** 预览时标记库里是否已有同一文件、同一工作表、同一产品 */ + private Boolean duplicated; + + /** 已存在记录的主键,覆盖保存时使用 */ + private Long existsId; + + private List weightBands; + + private List surcharges; + + private List extraLines; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProductTerm.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProductTerm.java new file mode 100644 index 00000000..3039f14d --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayProductTerm.java @@ -0,0 +1,45 @@ +package com.bms.price.domain; + +import java.util.Date; +import com.bms.common.core.domain.BaseEntity; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 应付产品某一版价格的有效期。同一产品多次导入,每版一行。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class PayProductTerm extends BaseEntity +{ + private static final long serialVersionUID = 1L; + + private Long termId; + + /** 字典 payment_product_type,同一产品的各版本用它归组 */ + private String dictValue; + + private Long productId; + + private Integer versionNo; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date effectiveStart; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date effectiveEnd; + + /** 0 生效,1 失效 */ + private String status; + + private String productName; + + private String sourceFile; + + private Integer weightCount; + + private Integer surchargeCount; + + private String currency; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySaveCommand.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySaveCommand.java new file mode 100644 index 00000000..53d6b3da --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySaveCommand.java @@ -0,0 +1,17 @@ +package com.bms.price.domain; + +import java.util.ArrayList; +import java.util.List; +import lombok.Data; + +/** + * 预览确认后的保存请求。只保存前端勾选并回传的产品。 + */ +@Data +public class PaySaveCommand +{ + /** 为 true 时,同文件同表同名的旧产品先删后插 */ + private boolean overwrite; + + private List products = new ArrayList<>(); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySurcharge.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySurcharge.java new file mode 100644 index 00000000..ac14f5dc --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySurcharge.java @@ -0,0 +1,57 @@ +package com.bms.price.domain; + +import java.math.BigDecimal; +import java.util.Date; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +/** + * 应付产品附加费。生效开始和结束都空时,表示跟随价卡生效日,不另设窗口。 + */ +@Data +public class PaySurcharge +{ + private Long surchargeId; + + private Long productId; + + /** 所属价卡版本,跟产品版本一起变 */ + private Integer versionNo; + + private String sectionName; + + private String feeName; + + private String zoneName; + + /** 收费标准原文 */ + private String chargeText; + + /** 从收费标准里解析出的金额;百分比时这里是百分数 */ + private BigDecimal amount; + + private String currency; + + /** 费用说明 */ + private String feeDesc; + + /** 时效原文,解析不出日期时仍保留 */ + private String periodText; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date effectiveStart; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date effectiveEnd; + + /** 固定收取、产生时收取、旺季期间 */ + private String chargeTiming; + + /** ALWAYS、POSTCODE、WEIGHT、SIZE、RETURN、MANUAL */ + private String calcRule; + + /** 邮编列表、重量阈值或尺寸表达式 */ + private String calcParam; + + private Integer sortNo; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PayWeightBand.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayWeightBand.java new file mode 100644 index 00000000..fa06ae74 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PayWeightBand.java @@ -0,0 +1,48 @@ +package com.bms.price.domain; + +import java.math.BigDecimal; +import lombok.Data; + +/** + * 应付产品上的一行重量段、首重续重或分区单价。 + */ +@Data +public class PayWeightBand +{ + private Long bandId; + + private Long productId; + + /** 所属价卡版本,跟产品版本一起变 */ + private Integer versionNo; + + /** 报价区块,例如派送费、偏远附加费 */ + private String sectionName; + + private String countryName; + + private String countryCode; + + /** 价格列名称,例如英国大陆、马德里高密度 */ + private String zoneName; + + /** 重量段原文 */ + private String weightLabel; + + private BigDecimal weightMin; + + private BigDecimal weightMax; + + /** 体积上限,数字加单位,如 10000cm³。空表示这档不看体积 */ + private String volumeText; + + /** 处理费、派送费、续重等 */ + private String chargeName; + + private BigDecimal price; + + /** EUR/ITEM、GBP/KG 等 */ + private String priceUnit; + + private Integer sortNo; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPayBillFee.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPayBillFee.java index f44b52a7..1771e897 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPayBillFee.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/PrcPayBillFee.java @@ -23,6 +23,9 @@ public class PrcPayBillFee implements Serializable private String currency; private String roundMode; private String remark; + + /** CALC 计算,LADING 提单分摊,MANUAL 人工 */ + private String feeSource; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date createTime; } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/PrcProfitReportQuery.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/PrcProfitReportQuery.java index ab072bdf..8a9b4a09 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/PrcProfitReportQuery.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/domain/vo/PrcProfitReportQuery.java @@ -35,4 +35,6 @@ public class PrcProfitReportQuery extends BaseEntity private String arBillStatus; /** 是否按产品维度分组值下钻订单明细,允许分组字段为空。 */ private Boolean productDrilldown; + /** 仅用于拒绝已暂停的提成未计算订单下钻请求。 */ + private String detailType; } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/ChannelProductMapMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/ChannelProductMapMapper.java new file mode 100644 index 00000000..03cb50f7 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/ChannelProductMapMapper.java @@ -0,0 +1,18 @@ +package com.bms.price.mapper; + +import org.apache.ibatis.annotations.Param; +import com.bms.price.domain.ChannelProductMap; + +/** + * 渠道产品关系映射 + */ +public interface ChannelProductMapMapper +{ + ChannelProductMap selectByChannelAndType(@Param("channelId") String channelId, @Param("payType") String payType); + + int upsert(ChannelProductMap row); + + int deleteByChannelAndType(@Param("channelId") String channelId, @Param("payType") String payType); + + int deleteByProductId(@Param("productId") Long productId); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayBillMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayBillMapper.java new file mode 100644 index 00000000..84fd3760 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayBillMapper.java @@ -0,0 +1,33 @@ +package com.bms.price.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.bms.price.domain.PayBill; +import com.bms.price.domain.PayBillLine; +import com.bms.price.domain.PayBillLog; +import com.bms.price.domain.PayBillOrder; +import com.bms.price.domain.PrcPayBillFee; + +public interface PayBillMapper +{ + List selectPayBillList(PayBill query); + + PayBill selectPayBillById(Long billId); + + List selectLines(Long billId); + + List selectLogs(Long billId); + + List selectCandidateOrders(@Param("orderNos") List orderNos, @Param("beginDate") String beginDate, + @Param("endDate") String endDate, @Param("ladingNos") List ladingNos); + + List selectOrderFees(@Param("billIds") List billIds); + + int insertPayBill(PayBill bill); + + int updatePayBillStatus(PayBill bill); + + int insertLines(@Param("list") List list); + + int insertLog(PayBillLog log); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayPostcodeMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayPostcodeMapper.java new file mode 100644 index 00000000..7d06619f --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayPostcodeMapper.java @@ -0,0 +1,34 @@ +package com.bms.price.mapper; + +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.bms.price.domain.PayPostcodeGroup; +import com.bms.price.domain.PayPostcodeLine; + +/** 应付邮编配置 */ +public interface PayPostcodeMapper +{ + List selectGroupList(PayPostcodeGroup query); + + PayPostcodeGroup selectGroupById(Long groupId); + + int updateGroup(PayPostcodeGroup group); + + List selectLineList(PayPostcodeLine query); + + int insertGroup(PayPostcodeGroup group); + + int insertLines(@Param("list") List list); + + List selectGroupIds(@Param("fileMd5") String fileMd5, @Param("sheetName") String sheetName); + + List selectGroupsByTokens(@Param("tokens") List tokens); + + List selectRegionKeys(@Param("groupIds") List groupIds); + + List selectLinesByGroups(@Param("groupIds") List groupIds); + + int deleteLines(@Param("groupIds") List groupIds); + + int deleteGroups(@Param("groupIds") List groupIds); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayProductMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayProductMapper.java new file mode 100644 index 00000000..6cd96662 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayProductMapper.java @@ -0,0 +1,60 @@ +package com.bms.price.mapper; + +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.bms.price.domain.PayExtraLine; +import com.bms.price.domain.PayProduct; +import com.bms.price.domain.PaySurcharge; +import com.bms.price.domain.PayWeightBand; + +/** + * 应付价卡产品、重量段和附加费 + */ +public interface PayProductMapper +{ + List selectPayProductList(PayProduct query); + + PayProduct selectPayProductById(Long productId); + + Long selectExistingId(@Param("fileMd5") String fileMd5, @Param("sheetName") String sheetName, + @Param("productName") String productName); + + Long selectTemplateId(@Param("dictValue") String dictValue); + + List selectTemplateOptions(); + + Integer selectMaxVersion(@Param("originId") Long originId); + + int insertPayProduct(PayProduct product); + + int updatePayProduct(PayProduct product); + + int insertWeightBands(@Param("list") List list); + + int insertSurcharges(@Param("list") List list); + + int insertExtraLines(@Param("list") List list); + + List selectWeightBands(@Param("productIds") List productIds); + + List selectSurcharges(@Param("productIds") List productIds); + + List selectExtraLines(@Param("productIds") List productIds); + + int deleteWeightBands(@Param("productIds") List productIds); + + int deleteSurcharges(@Param("productIds") List productIds); + + int deleteExtraLines(@Param("productIds") List productIds); + + int deletePayProducts(@Param("productIds") List productIds); + + List selectFamilyIds(@Param("rootId") Long rootId); + + Long selectEffectiveVersionId(@Param("stableId") Long stableId, @Param("bizDate") Date bizDate); + + PayProduct selectInactiveCovering(@Param("stableId") Long stableId, @Param("bizDate") Date bizDate); + + Long selectLatestVersionId(@Param("stableId") Long stableId); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayProductTermMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayProductTermMapper.java new file mode 100644 index 00000000..3d7cc69c --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PayProductTermMapper.java @@ -0,0 +1,31 @@ +package com.bms.price.mapper; + +import java.util.Date; +import java.util.List; +import org.apache.ibatis.annotations.Param; +import com.bms.price.domain.PayProductTerm; + +/** + * 应付产品价格有效期 + */ +public interface PayProductTermMapper +{ + List selectByDict(@Param("dictValue") String dictValue); + + PayProductTerm selectById(Long termId); + + PayProductTerm selectByProductId(Long productId); + + int insertTerm(PayProductTerm term); + + int updatePeriod(PayProductTerm term); + + int updateStatus(PayProductTerm term); + + /** 新版本生效时,把同一产品仍生效的旧版本关掉,结束日收到新版本开始的前一天 */ + int closeOpenTerms(@Param("dictValue") String dictValue, @Param("endDate") Date endDate, @Param("updateBy") String updateBy); + + int deleteByProductIds(@Param("productIds") List productIds); + + int updateProductEffectiveDate(@Param("productId") Long productId, @Param("effectiveDate") Date effectiveDate); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java index 6bc1fe72..70900d78 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java @@ -134,4 +134,7 @@ public interface PrcOrderBillMapper @Param("surchargeAmount") BigDecimal surchargeAmount, @Param("incomeTotal") BigDecimal incomeTotal, @Param("payTotal") BigDecimal payTotal); + + /** 只更新应付合计,不改应收。 */ + int updatePayTotal(@Param("billId") String billId, @Param("payTotal") BigDecimal payTotal); } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayCostCalculator.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayCostCalculator.java new file mode 100644 index 00000000..7bcb2a96 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayCostCalculator.java @@ -0,0 +1,444 @@ +package com.bms.price.pay; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import com.bms.price.domain.PayCostLine; +import com.bms.price.domain.PayOrderCostResult; +import com.bms.price.domain.PayProduct; +import com.bms.price.domain.PaySurcharge; +import com.bms.price.domain.PayWeightBand; + +/** + * 用渠道上的应付产品,按订单重量、尺寸、邮编和是否退件计算应付。 + * 重量段价格默认是该段的一票价格;单位写明每公斤时才乘重量。 + */ +public final class PayCostCalculator +{ + private static final ZoneId SHANGHAI = ZoneId.of("GMT+8"); + + private PayCostCalculator() + { + } + + public static PayOrderCostResult calculate(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width, + BigDecimal height, String postcode, Date bizDate, Integer orderStatus) + { + return calculate(product, weight, length, width, height, postcode, null, bizDate, orderStatus); + } + + public static PayOrderCostResult calculate(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width, + BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus) + { + PayOrderCostResult result = new PayOrderCostResult(); + result.setFees(new ArrayList<>()); + result.setSkipped(new ArrayList<>()); + if (product == null) + { + result.setMessage("没有应付产品"); + return result; + } + result.setProductId(product.getProductId()); + result.setProductName(product.getProductName()); + if (weight == null || weight.compareTo(BigDecimal.ZERO) <= 0) + { + result.setMessage("订单没有计费重"); + return result; + } + List bands = forZone(usableBands(product.getWeightBands()), zoneName); + if (zoneName != null && !zoneName.isBlank() && bands.isEmpty()) + { + result.setMessage("邮编对应的地区「" + zoneName + "」不在价卡重量段里"); + return result; + } + Set zones = zones(bands); + if (zones.size() > 1) + { + result.setMessage("产品「" + product.getProductName() + "」有多个地区(" + String.join("、", zones) + + "),邮编没有对上其中的地区,无法确定运费"); + return result; + } + PayWeightBand band = matchBand(bands, weight); + if (band == null) + { + result.setMessage(outOfRange(product, bands, weight)); + return result; + } + String currency = currencyOf(product, band); + BigDecimal freight = bandAmount(band, weight); + String freightRemark = band.getWeightLabel() + " " + plain(band.getPrice()) + " " + blank(band.getPriceUnit()); + if (zoneName != null && !zoneName.isBlank()) + { + freightRemark = freightRemark + ",地区 " + zoneName.replace('\n', ' '); + } + PayCostLine freightLine = line("BASIC_FREIGHT", "基础运费", 0L, freight, currency, freightRemark); + result.getFees().add(freightLine); + result.setCurrency(currency); + addSurcharges(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, freight, currency, result); + BigDecimal total = BigDecimal.ZERO; + for (PayCostLine fee : result.getFees()) + { + total = total.add(fee.getAmount()); + } + result.setPayTotal(total.setScale(2, RoundingMode.HALF_UP)); + result.setMessage(summary(result)); + return result; + } + + private static List forZone(List bands, String zoneName) + { + if (zoneName == null || zoneName.isBlank()) + { + return bands; + } + List kept = new ArrayList<>(); + for (PayWeightBand band : bands) + { + if (PayZoneMatcher.sameZone(band.getZoneName(), zoneName)) + { + kept.add(band); + } + } + return kept; + } + + private static void addSurcharges(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width, + BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus, BigDecimal freight, String currency, + PayOrderCostResult result) + { + if (product.getSurcharges() == null) + { + return; + } + List sized = new ArrayList<>(); + for (PaySurcharge fee : product.getSurcharges()) + { + if (fee == null || !inForce(fee, bizDate)) + { + continue; + } + if (zoneName != null && !zoneName.isBlank() && fee.getZoneName() != null && !fee.getZoneName().isBlank() + && !PayZoneMatcher.sameZone(fee.getZoneName(), zoneName)) + { + continue; + } + String rule = fee.getCalcRule() == null || fee.getCalcRule().isBlank() ? PaySurchargeRules.MANUAL : fee.getCalcRule(); + String name = blank(fee.getFeeName()); + if (PaySurchargeRules.POSTCODE.equals(rule)) + { + if (postcode == null || postcode.isBlank()) + { + result.getSkipped().add(name + ":订单没有邮编"); + } + else if (PaySurchargeRules.matchesPostcode(fee.getCalcParam(), postcode)) + { + addFlat(result.getFees(), fee, currency, "邮编 " + postcode.trim()); + } + } + else if (PaySurchargeRules.WEIGHT.equals(rule)) + { + if (PaySurchargeRules.matchesWeight(fee.getCalcParam(), weight)) + { + addFlat(result.getFees(), fee, currency, "计费重超过 " + fee.getCalcParam() + "kg"); + } + } + else if (PaySurchargeRules.SIZE.equals(rule)) + { + if (length == null || width == null || height == null) + { + result.getSkipped().add(name + ":订单没有长宽高"); + } + else if (PaySurchargeRules.matchesSize(fee.getCalcParam(), length, width, height)) + { + PayCostLine matched = matchedFee(fee, freight, currency, "尺寸命中 " + fee.getCalcParam()); + if (matched != null) + { + sized.add(matched); + } + } + } + else if (PaySurchargeRules.RETURN.equals(rule)) + { + if (orderStatus != null && orderStatus == 9) + { + addFlat(result.getFees(), fee, currency, "订单已退件"); + } + } + else if (PaySurchargeRules.ALWAYS.equals(rule)) + { + PayCostLine matched = matchedFee(fee, freight, currency, blank(fee.getChargeText())); + if (matched != null) + { + result.getFees().add(matched); + } + } + else if (!name.isEmpty() && !name.contains("报价说明")) + { + result.getSkipped().add(name + ":价卡没有可计算的条件,需人工确认"); + } + } + result.getFees().addAll(keepHighestByName(sized)); + } + + /** 同一费用名的尺寸档同时命中时,只收较高的一档,例如 70% 覆盖 35%。 */ + private static List keepHighestByName(List lines) + { + List kept = new ArrayList<>(); + for (PayCostLine line : lines) + { + PayCostLine same = null; + for (PayCostLine exists : kept) + { + if (exists.getFeeName().equals(line.getFeeName())) + { + same = exists; + break; + } + } + if (same == null) + { + kept.add(line); + } + else if (line.getAmount().compareTo(same.getAmount()) > 0) + { + kept.set(kept.indexOf(same), line); + } + } + return kept; + } + + private static void addFlat(List fees, PaySurcharge fee, String currency, String remark) + { + PayCostLine line = matchedFee(fee, null, currency, remark); + if (line != null && !percent(fee)) + { + fees.add(line); + } + } + + private static PayCostLine matchedFee(PaySurcharge fee, BigDecimal freight, String currency, String remark) + { + if (fee.getAmount() == null) + { + return null; + } + BigDecimal amount = fee.getAmount(); + if (percent(fee)) + { + if (freight == null) + { + return null; + } + amount = freight.multiply(fee.getAmount()).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP); + } + else + { + amount = amount.setScale(2, RoundingMode.HALF_UP); + String feeCurrency = normalize(fee.getCurrency()); + if (!feeCurrency.isEmpty() && !feeCurrency.equals(currency)) + { + return null; + } + } + long scId = fee.getSurchargeId() == null ? 0L : fee.getSurchargeId(); + return line("SURCHARGE", blank(fee.getFeeName()), scId, amount, currency, remark); + } + + private static boolean percent(PaySurcharge fee) + { + String charge = fee.getChargeText() == null ? "" : fee.getChargeText(); + return charge.contains("%") || "%".equals(fee.getCurrency()); + } + + private static boolean inForce(PaySurcharge fee, Date bizDate) + { + if (bizDate == null || (fee.getEffectiveStart() == null && fee.getEffectiveEnd() == null)) + { + return true; + } + LocalDate day = toLocal(bizDate); + if (fee.getEffectiveStart() != null && day.isBefore(toLocal(fee.getEffectiveStart()))) + { + return false; + } + if (fee.getEffectiveEnd() != null && day.isAfter(toLocal(fee.getEffectiveEnd()))) + { + return false; + } + return true; + } + + private static List usableBands(List bands) + { + List usable = new ArrayList<>(); + if (bands == null) + { + return usable; + } + for (PayWeightBand band : bands) + { + if (band != null && band.getPrice() != null && band.getWeightMin() != null && band.getWeightMax() != null) + { + usable.add(band); + } + } + usable.sort((left, right) -> left.getWeightMin().compareTo(right.getWeightMin())); + return usable; + } + + private static Set zones(List bands) + { + Set zones = new LinkedHashSet<>(); + for (PayWeightBand band : bands) + { + if (band.getZoneName() != null && !band.getZoneName().isBlank()) + { + zones.add(band.getZoneName().trim()); + } + } + return zones; + } + + private static PayWeightBand matchBand(List bands, BigDecimal weight) + { + for (PayWeightBand band : bands) + { + if (weight.compareTo(band.getWeightMin()) >= 0 && weight.compareTo(band.getWeightMax()) <= 0) + { + return band; + } + } + for (int i = 0; i < bands.size() - 1; i++) + { + PayWeightBand current = bands.get(i); + PayWeightBand next = bands.get(i + 1); + BigDecimal gap = next.getWeightMin().subtract(current.getWeightMax()); + if (weight.compareTo(current.getWeightMax()) > 0 && weight.compareTo(next.getWeightMin()) < 0 + && gap.compareTo(new BigDecimal("0.01")) <= 0) + { + return next; + } + } + return null; + } + + private static String outOfRange(PayProduct product, List bands, BigDecimal weight) + { + String name = blank(product.getProductName()); + if (bands.isEmpty()) + { + return "产品「" + name + "」没有可匹配的重量段"; + } + BigDecimal max = bands.get(bands.size() - 1).getWeightMax(); + if (weight.compareTo(max) > 0) + { + return "计费重 " + plain(weight) + "kg 超出「" + name + "」的重量段,最大 " + plain(max) + "kg"; + } + return "计费重 " + plain(weight) + "kg 没有落在「" + name + "」的重量段里"; + } + + private static BigDecimal bandAmount(PayWeightBand band, BigDecimal weight) + { + BigDecimal price = band.getPrice(); + if (perKilogram(band)) + { + return price.multiply(weight).setScale(2, RoundingMode.HALF_UP); + } + return price.setScale(2, RoundingMode.HALF_UP); + } + + private static boolean perKilogram(PayWeightBand band) + { + String blob = (blank(band.getPriceUnit()) + " " + blank(band.getChargeName())).toLowerCase(Locale.ROOT); + return blob.contains("/kg") || blob.contains("每公斤") || blob.contains("每千克") || blob.contains("/公斤"); + } + + private static String currencyOf(PayProduct product, PayWeightBand band) + { + String unit = blank(band.getPriceUnit()).toUpperCase(Locale.ROOT); + if (unit.contains("GBP") || unit.contains("英镑")) + { + return "GBP"; + } + if (unit.contains("EUR") || unit.contains("欧元")) + { + return "EUR"; + } + if (unit.contains("USD") || unit.contains("美元")) + { + return "USD"; + } + if (unit.contains("CNY") || unit.contains("RMB") || unit.contains("人民币")) + { + return "CNY"; + } + String currency = blank(product.getCurrency()); + return currency.isEmpty() ? "CNY" : currency; + } + + private static PayCostLine line(String feeType, String feeName, Long scId, BigDecimal amount, String currency, String remark) + { + PayCostLine line = new PayCostLine(); + line.setFeeType(feeType); + line.setFeeName(feeName); + line.setScId(scId); + line.setAmount(amount.setScale(2, RoundingMode.HALF_UP)); + line.setCurrency(currency); + line.setRemark(remark == null ? "" : remark.trim()); + return line; + } + + private static String summary(PayOrderCostResult result) + { + String text = "应付 " + plain(result.getPayTotal()) + " " + result.getCurrency(); + if (result.getSkipped() != null && !result.getSkipped().isEmpty()) + { + text = text + ",另有 " + result.getSkipped().size() + " 项附加费未自动计算"; + } + return text; + } + + private static LocalDate toLocal(Date date) + { + if (date instanceof java.sql.Date sql) + { + return sql.toLocalDate(); + } + return date.toInstant().atZone(SHANGHAI).toLocalDate(); + } + + private static String plain(BigDecimal value) + { + return value == null ? "" : value.stripTrailingZeros().toPlainString(); + } + + private static String blank(String value) + { + return value == null ? "" : value.trim(); + } + + private static String normalize(String currency) + { + if (currency == null || currency.isBlank() || "%".equals(currency.trim())) + { + return ""; + } + String value = currency.trim().toUpperCase(Locale.ROOT); + if ("英镑".equals(currency)) + { + return "GBP"; + } + if ("欧元".equals(currency)) + { + return "EUR"; + } + return value; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayDetailExporter.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayDetailExporter.java new file mode 100644 index 00000000..f40c4af9 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayDetailExporter.java @@ -0,0 +1,182 @@ +package com.bms.price.pay; + +import java.io.IOException; +import java.io.OutputStream; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import com.bms.price.domain.PayExtraLine; +import com.bms.price.domain.PayProduct; +import com.bms.price.domain.PaySurcharge; +import com.bms.price.domain.PayWeightBand; + +/** + * 把已保存的应付配置导出成产品、重量段、附加费、其他配置四张表。 + */ +public final class PayDetailExporter +{ + private static final int MAX_ROWS = 50000; + + private PayDetailExporter() + { + } + + public static void write(OutputStream output, List products) throws IOException + { + try (XSSFWorkbook workbook = new XSSFWorkbook()) + { + Sheet productSheet = workbook.createSheet("产品"); + header(productSheet, "产品名称", "价卡归属", "国家/口岸", "生效日期", "报价工作表", "关联分区表", "币种", + "识别结果", "重量段数", "附加费数", "其他配置数", "来源文件", "服务说明"); + Sheet weightSheet = workbook.createSheet("重量段"); + header(weightSheet, "产品名称", "报价工作表", "区块", "国家", "国家代码", "分区", "重量段", "下限KG", "上限KG", + "体积", "费用项", "单价", "单位"); + Sheet feeSheet = workbook.createSheet("附加费"); + header(feeSheet, "产品名称", "报价工作表", "区块", "费用名称", "分区", "收费标准", "金额", "币种", "收取时点", + "生效开始", "生效结束", "时效原文", "费用说明"); + Sheet extraSheet = workbook.createSheet("其他配置"); + header(extraSheet, "产品名称", "报价工作表", "类型", "配置内容"); + int weightRows = 1; + int feeRows = 1; + int extraRows = 1; + if (products != null) + { + int rowIndex = 1; + for (PayProduct product : products) + { + Row row = productSheet.createRow(rowIndex++); + cell(row, 0, product.getProductName()); + cell(row, 1, product.getSupplierName()); + cell(row, 2, product.getCountryScope()); + cell(row, 3, day(product.getEffectiveDate())); + cell(row, 4, product.getSheetName()); + cell(row, 5, product.getRelatedSheet()); + cell(row, 6, product.getCurrency()); + cell(row, 7, product.getParseStatus()); + cell(row, 8, String.valueOf(product.getWeightCount())); + cell(row, 9, String.valueOf(product.getSurchargeCount())); + cell(row, 10, String.valueOf(product.getExtraCount())); + cell(row, 11, product.getSourceFile()); + cell(row, 12, product.getServiceNote()); + weightRows = writeWeights(weightSheet, weightRows, product); + feeRows = writeFees(feeSheet, feeRows, product); + extraRows = writeExtras(extraSheet, extraRows, product); + } + } + workbook.write(output); + } + } + + private static int writeWeights(Sheet sheet, int rowIndex, PayProduct product) + { + if (product.getWeightBands() == null) + { + return rowIndex; + } + for (PayWeightBand band : product.getWeightBands()) + { + if (rowIndex >= MAX_ROWS) + { + return rowIndex; + } + Row row = sheet.createRow(rowIndex++); + cell(row, 0, product.getProductName()); + cell(row, 1, product.getSheetName()); + cell(row, 2, band.getSectionName()); + cell(row, 3, band.getCountryName()); + cell(row, 4, band.getCountryCode()); + cell(row, 5, band.getZoneName()); + cell(row, 6, band.getWeightLabel()); + cell(row, 7, plain(band.getWeightMin())); + cell(row, 8, plain(band.getWeightMax())); + cell(row, 9, band.getVolumeText()); + cell(row, 10, band.getChargeName()); + cell(row, 11, plain(band.getPrice())); + cell(row, 12, band.getPriceUnit()); + } + return rowIndex; + } + + private static int writeFees(Sheet sheet, int rowIndex, PayProduct product) + { + if (product.getSurcharges() == null) + { + return rowIndex; + } + for (PaySurcharge fee : product.getSurcharges()) + { + if (rowIndex >= MAX_ROWS) + { + return rowIndex; + } + Row row = sheet.createRow(rowIndex++); + cell(row, 0, product.getProductName()); + cell(row, 1, product.getSheetName()); + cell(row, 2, fee.getSectionName()); + cell(row, 3, fee.getFeeName()); + cell(row, 4, fee.getZoneName()); + cell(row, 5, fee.getChargeText()); + cell(row, 6, plain(fee.getAmount())); + cell(row, 7, fee.getCurrency()); + cell(row, 8, fee.getChargeTiming()); + cell(row, 9, day(fee.getEffectiveStart())); + cell(row, 10, day(fee.getEffectiveEnd())); + cell(row, 11, fee.getPeriodText()); + cell(row, 12, fee.getFeeDesc()); + } + return rowIndex; + } + + private static int writeExtras(Sheet sheet, int rowIndex, PayProduct product) + { + if (product.getExtraLines() == null) + { + return rowIndex; + } + for (PayExtraLine line : product.getExtraLines()) + { + if (rowIndex >= MAX_ROWS) + { + return rowIndex; + } + Row row = sheet.createRow(rowIndex++); + cell(row, 0, product.getProductName()); + cell(row, 1, product.getSheetName()); + cell(row, 2, line.getLineType()); + cell(row, 3, line.getLineText()); + } + return rowIndex; + } + + private static void header(Sheet sheet, String... titles) + { + Row row = sheet.createRow(0); + for (int i = 0; i < titles.length; i++) + { + row.createCell(i).setCellValue(titles[i]); + sheet.setColumnWidth(i, 18 * 256); + } + } + + private static void cell(Row row, int col, String value) + { + row.createCell(col).setCellValue(value == null ? "" : value); + } + + private static String plain(java.math.BigDecimal value) + { + return value == null ? "" : value.stripTrailingZeros().toPlainString(); + } + + private static String day(Date value) + { + if (value == null) + { + return ""; + } + return new SimpleDateFormat("yyyy-MM-dd").format(value); + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayPostcodeImporter.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayPostcodeImporter.java new file mode 100644 index 00000000..02e50cd8 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayPostcodeImporter.java @@ -0,0 +1,1215 @@ +package com.bms.price.pay; + +import java.io.ByteArrayInputStream; +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.springframework.util.DigestUtils; +import com.bms.common.exception.ServiceException; +import com.bms.common.utils.poi.ExcelGrid; +import com.bms.price.domain.PayPostcodeGroup; +import com.bms.price.domain.PayPostcodeLine; + +/** + * 从价卡里抽出邮编分区和按邮编收取的附加费。 + * 表头用正则识别「附加费」和「邮编」。邮编末尾的星号去掉,其余格子按原文保存。 + * 目录里挂在报价表上的分区表,会带上服务类型、国家、销售产品和报价表。 + * 同一个国家、同一种附加费、金额原文相同的,只生成一条附加费,邮编挂在这条下面。 + */ +public class PayPostcodeImporter +{ + private static final int MAX_BYTES = 30 * 1024 * 1024; + + private static final int MAX_ROWS = 100000; + + /** 表名、标题或表头里出现就算附加费 */ + private static final Pattern FEE = Pattern.compile("附加费"); + + /** 邮编列。兼容中英文和西语表头 */ + private static final Pattern POSTCODE = Pattern.compile("(?i)邮编|zip\\s*code|postal\\s*code|post\\s*code|c[oó]digo\\s*postal"); + + /** 用来给表头行打分,避免把标题行当成列名 */ + private static final Pattern HEADER = Pattern.compile("(?i)邮编|附加费|城市|分区|地区|国家|zip|postal|country|status|density|zone|provincia|poblaci"); + + private static final Pattern FEE_COL = Pattern.compile("附加费"); + + private static final Pattern END_COL = Pattern.compile("截止"); + + private static final Pattern CITY_COL = Pattern.compile("(?i)城市|city|poblaci"); + + private static final Pattern CODE_COL = Pattern.compile("代码"); + + /** 国家二字码、二字简码,用来识别国家,不写进地区代码 */ + private static final Pattern ISO_COL = Pattern.compile("二字|简码"); + + private static final Pattern REGION_CN_COL = Pattern.compile("中文"); + + private static final Pattern REGION_COL = Pattern.compile("(?i)地区|州|省|province|provincia|region|area|island"); + + private static final Pattern ZONE_COL = Pattern.compile("(?i)分区|zone|densi"); + + private static final Pattern COUNTRY_COL = Pattern.compile("(?i)国家|country"); + + /** 邮编末尾的星号,GY*、PO30* 去掉星号后保留前面的字符 */ + private static final Pattern TRAILING_STAR = Pattern.compile("\\*+\\s*$"); + + /** 表头里的渠道名,如 Royal Mail、EVRI、IT-iMile、GOFO */ + private static final Pattern CHANNEL_TOKEN = Pattern.compile("[A-Za-z][A-Za-z0-9]*(?:[\\s-][A-Za-z][A-Za-z0-9]*)*"); + + /** 列名里的普通英文,不当成渠道 */ + private static final Set CHANNEL_NOISE = Set.of( + "postal", "postalcode", "postcode", "code", "zip", "zipcode", "area", "zone", "density", "densite", + "country", "status", "city", "province", "provincia", "poblacion", "codigo", "sn", "item", "express", + "island", "islands", "remote", "open", "modify", "modifydate", "date", "time", "update"); + + /** + * 解析工作簿。没有邮编列的报价页会跳过。 + */ + public List parse(byte[] bytes, String filename) + { + if (bytes == null || bytes.length == 0) + { + throw new ServiceException("请选择 Excel 文件"); + } + if (bytes.length > MAX_BYTES) + { + throw new ServiceException("文件不能超过30MB"); + } + String safeName = filename == null ? "邮编.xlsx" : filename; + String md5 = DigestUtils.md5DigestAsHex(bytes); + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) + { + Map links = catalogLinks(workbook); + Map groups = new LinkedHashMap<>(); + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + Sheet sheet = workbook.getSheetAt(i); + readSheet(ExcelGrid.read(sheet, MAX_ROWS), sheet.getSheetName(), safeName, md5, groups); + } + for (PayPostcodeGroup group : groups.values()) + { + CatalogLink link = links.get(group.getSheetName()); + if (link == null) + { + continue; + } + group.setServiceType(cut(link.serviceType, 100)); + group.setCatalogCountry(cut(link.country, 100)); + group.setProductName(cut(link.productName, 200)); + group.setQuoteSheet(cut(link.quoteSheet, 200)); + } + return new ArrayList<>(groups.values()); + } + catch (ServiceException ex) + { + throw ex; + } + catch (Exception ex) + { + throw new ServiceException("邮编表无法读取,请确认是 Excel 文件"); + } + } + + /** + * 目录列是服务类型、国家、销售产品、报价表、分区表。分区表的超链接指向邮编工作表。 + * 合并单元格只在第一行有字,下面的空格沿用上一行。 + */ + private static Map catalogLinks(Workbook workbook) + { + Map links = new LinkedHashMap<>(); + if (workbook.getNumberOfSheets() == 0) + { + return links; + } + ExcelGrid grid = ExcelGrid.read(workbook.getSheetAt(0), 200); + int header = -1; + for (int row = 0; row < grid.rows(); row++) + { + String line = joinRow(grid, row); + if (line.contains("销售产品") && line.contains("报价")) + { + header = row; + break; + } + } + if (header < 0) + { + return links; + } + String[] role = new String[grid.cols()]; + for (int col = 0; col < grid.cols(); col++) + { + String text = flat(grid.text(header, col)); + if (text.contains("服务类型")) + { + role[col] = "service"; + } + else if (text.contains("国家")) + { + role[col] = "country"; + } + else if (text.contains("销售产品") || text.contains("产品名称")) + { + role[col] = "product"; + } + else if (text.contains("报价")) + { + role[col] = "quote"; + } + } + String carryService = ""; + String carryCountry = ""; + String carryProduct = ""; + for (int row = header + 1; row < grid.rows(); row++) + { + if (grid.blankRow(row)) + { + continue; + } + String line = joinRow(grid, row); + if (line.startsWith("特别申明") || line.startsWith("免责") || line.startsWith("备注:") || line.startsWith("备注:")) + { + break; + } + String service = catalogValue(grid, row, role, "service"); + String country = catalogValue(grid, row, role, "country"); + String product = catalogValue(grid, row, role, "product"); + if (!service.isEmpty()) + { + carryService = service; + } + if (!country.isEmpty()) + { + carryCountry = country; + } + if (!product.isEmpty()) + { + carryProduct = product; + } + String quoteSheet = ""; + List related = new ArrayList<>(); + for (int col = 0; col < grid.cols(); col++) + { + String sheet = grid.linkSheet(row, col); + if (sheet.isBlank() || sheet.equals(workbook.getSheetAt(0).getSheetName())) + { + continue; + } + if ("quote".equals(role[col])) + { + quoteSheet = sheet; + } + else + { + related.add(sheet); + } + } + if (quoteSheet.isEmpty() || related.isEmpty() || carryProduct.isEmpty()) + { + continue; + } + for (String sheet : related) + { + links.putIfAbsent(sheet, new CatalogLink(carryService, carryCountry, carryProduct, quoteSheet)); + } + } + return links; + } + + private static String catalogValue(ExcelGrid grid, int row, String[] role, String expect) + { + for (int col = 0; col < role.length; col++) + { + if (expect.equals(role[col])) + { + return flat(grid.text(row, col)); + } + } + return ""; + } + + private static String joinRow(ExcelGrid grid, int row) + { + StringBuilder line = new StringBuilder(); + for (int col = 0; col < grid.cols(); col++) + { + line.append(flat(grid.text(row, col))); + } + return line.toString(); + } + + private void readSheet(ExcelGrid grid, String sheetName, String fileName, String md5, Map groups) + { + int header = headerRow(grid); + if (header < 0) + { + return; + } + List seriesList = seriesOf(grid, header, sheetName); + if (seriesList.isEmpty()) + { + return; + } + String[] carryRegion = new String[seriesList.size()]; + String[] carryCn = new String[seriesList.size()]; + String[] carryCity = new String[seriesList.size()]; + String[] carryCode = new String[seriesList.size()]; + String[] carryZone = new String[seriesList.size()]; + String[] carryCountry = new String[seriesList.size()]; + String[] carryFee = new String[seriesList.size()]; + String[] carryEnd = new String[seriesList.size()]; + for (int row = header + 1; row < grid.rows(); row++) + { + for (int i = 0; i < seriesList.size(); i++) + { + Series series = seriesList.get(i); + String raw = cell(grid, row, series.postcodeCol); + if (!raw.isEmpty()) + { + carryRegion[i] = first(cell(grid, row, series.regionCol), carryRegion[i]); + carryCn[i] = first(cell(grid, row, series.regionCnCol), carryCn[i]); + carryCity[i] = first(cell(grid, row, series.cityCol), carryCity[i]); + carryCode[i] = first(cell(grid, row, series.codeCol), carryCode[i]); + carryZone[i] = first(zoneText(grid, row, series), carryZone[i]); + carryCountry[i] = first(cell(grid, row, series.countryCol), carryCountry[i]); + carryFee[i] = first(cell(grid, row, series.feeCol), carryFee[i]); + carryEnd[i] = first(cell(grid, row, series.endCol), carryEnd[i]); + addLine(groups, series, fileName, md5, sheetName, raw, carryRegion[i], carryCn[i], carryCity[i], + carryCode[i], carryZone[i], carryCountry[i], cell(grid, row, series.isoCol), carryFee[i], carryEnd[i], + extra(grid, row, series)); + } + } + } + } + + /** 选列名最多的那一行。标题行通常只有一两个长名字。 */ + private static int headerRow(ExcelGrid grid) + { + int best = -1; + int bestScore = 0; + int end = Math.min(grid.rows(), 8); + for (int row = 0; row < end; row++) + { + int score = 0; + int filled = 0; + for (int col = 0; col < grid.cols(); col++) + { + String text = flat(grid.text(row, col)); + if (text.isEmpty()) + { + continue; + } + filled++; + if (text.length() <= 40 && HEADER.matcher(text).find()) + { + score++; + } + } + if (filled >= 2 && score > bestScore) + { + best = row; + bestScore = score; + } + } + return bestScore >= 1 ? best : -1; + } + + private static List seriesOf(ExcelGrid grid, int header, String sheetName) + { + List list = new ArrayList<>(); + int col = 0; + while (col < grid.cols()) + { + if (flat(grid.text(header, col)).isEmpty()) + { + col++; + continue; + } + int start = col; + while (col < grid.cols() && !flat(grid.text(header, col)).isEmpty()) + { + col++; + } + list.addAll(seriesIn(grid, header, start, col - 1, sheetName)); + } + return list; + } + + /** + * 一组连续表头里可以并排多列邮编。 + * 邮编右边的第一列附加费只属于这一列邮编,这样空气减排不会并进前面的偏远附加费。 + */ + private static List seriesIn(ExcelGrid grid, int header, int start, int end, String sheetName) + { + List postCols = new ArrayList<>(); + for (int col = start; col <= end; col++) + { + if ("post".equals(role(flat(grid.text(header, col))))) + { + postCols.add(col); + } + } + if (postCols.isEmpty()) + { + return new ArrayList<>(); + } + String title = titleAbove(grid, header, start, end); + boolean namedFee = FEE.matcher(sheetName + title).find(); + int[] owner = new int[end + 1]; + java.util.Arrays.fill(owner, -1); + for (int i = 0; i < postCols.size(); i++) + { + int post = postCols.get(i); + owner[post] = i; + int leftStop = i == 0 ? start : postCols.get(i - 1) + 1; + for (int col = post - 1; col >= leftStop; col--) + { + if (owner[col] >= 0) + { + break; + } + owner[col] = i; + } + int rightStop = i + 1 == postCols.size() ? end : postCols.get(i + 1) - 1; + for (int col = post + 1; col <= rightStop; col++) + { + owner[col] = i; + if ("fee".equals(role(flat(grid.text(header, col))))) + { + break; + } + } + } + List list = new ArrayList<>(); + for (int i = 0; i < postCols.size(); i++) + { + int postCol = postCols.get(i); + int feeCol = owned(grid, header, owner, i, start, end, "fee"); + Series series = new Series(); + series.postcodeCol = postCol; + series.feeCol = feeCol; + series.endCol = owned(grid, header, owner, i, start, end, "end"); + series.cityCol = owned(grid, header, owner, i, start, end, "city"); + series.codeCol = owned(grid, header, owner, i, start, end, "code"); + series.regionCnCol = owned(grid, header, owner, i, start, end, "cn"); + series.regionCol = owned(grid, header, owner, i, start, end, "region"); + series.zoneCol = owned(grid, header, owner, i, start, end, "zone"); + series.countryCol = owned(grid, header, owner, i, start, end, "country"); + series.isoCol = owned(grid, header, owner, i, start, end, "iso"); + series.countryHint = countryHint(grid, header, postCol); + series.fee = feeCol >= 0 || namedFee; + series.sheetName = sheetName; + series.title = title; + series.feeName = feeCol >= 0 ? flat(grid.text(header, feeCol)) : shortTitle(title); + series.zoneAbove = flat(above(grid, header, postCol)); + series.startCol = bound(owner, i, start, end, true); + series.endBound = bound(owner, i, start, end, false); + series.channel = channelOf(channelSources(grid, header, owner, i, start, end, title, sheetName)); + list.add(series); + } + return list; + } + + /** 渠道优先取区块标题,再看本列的表头,最后看工作表名。 */ + private static List channelSources(ExcelGrid grid, int header, int[] owner, int index, int start, int end, String title, String sheetName) + { + List sources = new ArrayList<>(); + sources.add(title); + for (int col = start; col <= end; col++) + { + if (owner[col] == index) + { + sources.add(flat(grid.text(header, col))); + } + } + sources.add(sheetName); + return sources; + } + + /** + * 从标题或表头里取出渠道。Royal Mail偏远地区附加费得到 Royal Mail,EVRI偏远附加费得到 EVRI。 + * 邮编、area、postal code 这类列名会跳过。 + */ + static String channelOf(List sources) + { + if (sources == null) + { + return ""; + } + for (String source : sources) + { + String folded = foldLatin(source); + Matcher matcher = CHANNEL_TOKEN.matcher(folded); + while (matcher.find()) + { + String name = keepChannel(matcher.group()); + if (!name.isEmpty()) + { + return cut(name, 100); + } + } + } + return ""; + } + + private static String foldLatin(String text) + { + if (text == null || text.isEmpty()) + { + return ""; + } + return Normalizer.normalize(flat(text), Normalizer.Form.NFD).replaceAll("\\p{M}+", ""); + } + + private static String keepChannel(String token) + { + StringBuilder kept = new StringBuilder(); + for (String word : token.split("\\s+")) + { + String bare = word.replaceAll("^-+|-+$", ""); + if (bare.isEmpty() || CHANNEL_NOISE.contains(bare.toLowerCase(Locale.ROOT))) + { + continue; + } + if (bare.replaceAll("[^A-Za-z]", "").length() < 2) + { + continue; + } + if (kept.length() > 0) + { + kept.append(' '); + } + kept.append(word); + } + return kept.toString(); + } + + /** 表头角色。附加费优先于地区,避免「空气减排附加费」被当成地区列。 */ + private static String role(String text) + { + if (text.isEmpty()) + { + return ""; + } + if (END_COL.matcher(text).find() && POSTCODE.matcher(text).find()) + { + return "end"; + } + if (POSTCODE.matcher(text).find() && !FEE_COL.matcher(text).find()) + { + return "post"; + } + if (FEE_COL.matcher(text).find()) + { + return "fee"; + } + if (CITY_COL.matcher(text).find()) + { + return "city"; + } + if (REGION_CN_COL.matcher(text).find()) + { + return "cn"; + } + if (ISO_COL.matcher(text).find() && !POSTCODE.matcher(text).find()) + { + return "iso"; + } + if (CODE_COL.matcher(text).find() && !POSTCODE.matcher(text).find()) + { + return "code"; + } + if (REGION_COL.matcher(text).find() && !POSTCODE.matcher(text).find()) + { + return "region"; + } + if (ZONE_COL.matcher(text).find() && !POSTCODE.matcher(text).find()) + { + return "zone"; + } + if (COUNTRY_COL.matcher(text).find()) + { + return "country"; + } + return ""; + } + + private static int owned(ExcelGrid grid, int header, int[] owner, int index, int start, int end, String expect) + { + for (int col = start; col <= end; col++) + { + if (owner[col] == index && expect.equals(role(flat(grid.text(header, col))))) + { + return col; + } + } + return -1; + } + + private static int bound(int[] owner, int index, int start, int end, boolean first) + { + int found = first ? end : start; + for (int col = start; col <= end; col++) + { + if (owner[col] == index) + { + found = col; + if (first) + { + return col; + } + } + } + return found; + } + + private static String titleAbove(ExcelGrid grid, int header, int start, int end) + { + String found = ""; + for (int row = 0; row < header; row++) + { + for (int col = start; col <= end; col++) + { + String text = flat(grid.text(row, col)); + if (text.length() > found.length() && text.length() <= 80 && (FEE.matcher(text).find() || POSTCODE.matcher(text).find() || text.contains("偏远"))) + { + found = text; + } + } + } + return found; + } + + private static String above(ExcelGrid grid, int header, int col) + { + if (header <= 0) + { + return ""; + } + String text = flat(grid.text(header - 1, col)); + if (POSTCODE.matcher(text).find() || FEE_COL.matcher(text).find()) + { + return ""; + } + return text; + } + + private void addLine(Map groups, Series series, String fileName, String md5, String sheetName, + String raw, String region, String regionCn, String city, String code, String zone, String countryCell, String isoCell, + String feeText, String endRaw, String extra) + { + List tokens = endRaw == null || endRaw.isBlank() + ? expandPostcode(raw) + : List.of(new PostcodeToken(stripStar(raw), stripStar(endRaw), raw)); + if (tokens.isEmpty()) + { + return; + } + Country country = countryOf(first(countryCell, series.countryHint), isoCell, series.title + " " + sheetName); + String kind = series.fee ? "FEE" : "ZONE"; + String feeName = series.fee ? cut(series.feeName, 200) : ""; + String feeValue = series.fee ? cut(feeText, 200) : ""; + String channel = cut(series.channel, 100); + String key = kind + "|" + sheetName + "|" + country.code + "|" + channel + "|" + feeName + "|" + feeValue + "|" + (series.fee ? "" : series.startCol); + PayPostcodeGroup group = groups.get(key); + if (group == null) + { + group = new PayPostcodeGroup(); + group.setSourceFile(fileName); + group.setFileMd5(md5); + group.setSheetName(sheetName); + group.setBlockTitle(cut(series.title.isEmpty() ? sheetName : series.title, 200)); + group.setConfigKind(kind); + group.setCoverage(coverageOf(sheetName, series.title, feeName)); + group.setChannelName(channel); + group.setCountryName(country.name); + group.setCountryCode(country.code); + group.setFeeName(feeName); + group.setFeeText(feeValue); + groups.put(key, group); + } + for (PostcodeToken token : tokens) + { + if (token.postcode.isEmpty() || token.postcode.length() > 200) + { + continue; + } + PayPostcodeLine line = new PayPostcodeLine(); + line.setCountryName(country.name); + line.setCountryCode(country.code); + line.setRegionName(cut(region, 200)); + line.setRegionNameCn(cut(regionCn, 200)); + line.setCityName(cut(city, 200)); + line.setRegionCode(cut(code, 50)); + line.setZoneName(cut(zone, 200)); + line.setPostcode(token.postcode); + line.setPostcodeRaw(cut(token.raw, 200)); + line.setPostcodeEnd(cut(token.end, 200)); + line.setExtraText(cut(extra, 500)); + line.setSortNo(group.getLines().size() + 1); + group.getLines().add(line); + } + group.setLineCount(group.getLines().size()); + } + + /** + * 工作表、标题或费用名里出现可达、不可达、偏远时,给这一组邮编打上属性。 + * 不可达要先判断,避免被「可达」两个字截走。 + */ + static String coverageOf(String sheetName, String title, String feeName) + { + if (feeName != null && !feeName.isBlank()) + { + String fromFee = coverageWord(feeName); + if (!fromFee.isEmpty()) + { + return fromFee; + } + return coverageWord(sheetName); + } + return coverageWord((sheetName == null ? "" : sheetName) + (title == null ? "" : title)); + } + + private static String coverageWord(String source) + { + if (source == null || source.isEmpty()) + { + return ""; + } + if (source.contains("不可达")) + { + return "UNREACH"; + } + if (source.contains("可达") || source.contains("可派")) + { + return "REACH"; + } + if (source.contains("偏远") || source.contains("岛屿")) + { + return "REMOTE"; + } + return ""; + } + + /** + * 一个格子里用逗号写了多条邮编时拆开。两端都是完整邮编的区间写成起止,KA27-28 这种短区间保持原文。 + */ + static List expandPostcode(String raw) + { + List tokens = new ArrayList<>(); + if (raw == null || raw.isBlank()) + { + return tokens; + } + String normalized = raw.replace(',', ',').replace(';', ',').replace('、', ',').replace(';', ','); + String[] parts = normalized.indexOf(',') >= 0 ? normalized.split(",") : new String[] { normalized }; + for (String part : parts) + { + String token = afterColon(part); + if (token.isEmpty()) + { + continue; + } + PostcodeToken range = rangeToken(token); + if (range != null) + { + tokens.add(range); + continue; + } + String code = stripStar(token); + if (code.isEmpty() || code.length() > 200) + { + continue; + } + if (parts.length > 1 && !keepToken(code)) + { + continue; + } + tokens.add(new PostcodeToken(code, "", token)); + } + if (tokens.isEmpty()) + { + String code = stripStar(raw.trim()); + if (!code.isEmpty() && code.length() <= 200) + { + tokens.add(new PostcodeToken(code, "", raw.trim())); + } + } + return tokens; + } + + private static String afterColon(String part) + { + String text = part == null ? "" : part.trim(); + int colon = Math.max(text.lastIndexOf(':'), text.lastIndexOf(':')); + if (colon >= 0 && colon < text.length() - 1) + { + text = text.substring(colon + 1).trim(); + } + return text.replaceAll("^[\\-–—\\s]+", "").trim(); + } + + private static final Pattern RANGE = Pattern.compile( + "(?i)^(?:[A-Z]{2}\\s+)?([A-Z]{0,4}\\d{2,6}[A-ZxX]{0,4})\\s*[-–—~~]\\s*(?:[A-Z]{2}\\s+)?([A-Z]{0,4}\\d{2,6}[A-ZxX]{0,4})$"); + + private static final Set TOKEN_NOISE = Set.of("and", "the", "via", "for", "all", "area", "from", "with", "no"); + + private static PostcodeToken rangeToken(String token) + { + Matcher matcher = RANGE.matcher(token.trim()); + if (!matcher.matches() || !fullSide(matcher.group(1)) || !fullSide(matcher.group(2))) + { + return null; + } + return new PostcodeToken(matcher.group(1), matcher.group(2), token.trim()); + } + + private static boolean fullSide(String side) + { + String digits = side.replaceAll("\\D", ""); + boolean wildcard = side.indexOf('x') >= 0 || side.indexOf('X') >= 0; + if (digits.length() >= 4 || (wildcard && side.length() >= 4)) + { + return true; + } + return side.matches("(?i)[A-Z]{2}\\d{3}[A-Z]{0,2}"); + } + + private static boolean keepToken(String code) + { + if (code.length() > 40 || TOKEN_NOISE.contains(code.toLowerCase(Locale.ROOT))) + { + return false; + } + return code.matches("(?i)[A-Z]{2,4}") || code.matches("(?i).*\\d.*"); + } + + /** 邮编列正上方合并格里的国家,例如西班牙 GLS 偏远表的德国、法国、意大利。 */ + private static String countryHint(ExcelGrid grid, int header, int postCol) + { + int top = Math.max(0, header - 3); + for (int row = header - 1; row >= top; row--) + { + for (int col = postCol; col >= 0; col--) + { + String text = flat(grid.text(row, col)); + if (text.isEmpty()) + { + continue; + } + if (text.length() <= 40 && namedCountry(text) != null) + { + return text; + } + break; + } + } + return ""; + } + + private static String zoneText(ExcelGrid grid, int row, Series series) + { + String zone = cell(grid, row, series.zoneCol); + if (!zone.isEmpty()) + { + return zone; + } + return series.zoneAbove; + } + + private static String extra(ExcelGrid grid, int row, Series series) + { + StringBuilder text = new StringBuilder(); + for (int col = series.startCol; col <= series.endBound; col++) + { + if (col == series.postcodeCol || col == series.feeCol || col == series.endCol || col == series.cityCol + || col == series.codeCol || col == series.regionCol || col == series.regionCnCol || col == series.zoneCol + || col == series.countryCol || col == series.isoCol) + { + continue; + } + String value = cell(grid, row, col); + if (value.isEmpty()) + { + continue; + } + if (text.length() > 0) + { + text.append(" | "); + } + text.append(value); + } + return text.toString(); + } + + /** 只去掉邮编末尾的星号。 */ + static String stripStar(String value) + { + if (value == null) + { + return ""; + } + return TRAILING_STAR.matcher(value.trim()).replaceAll("").trim(); + } + + /** 本行国家优先,其次二字码,再看列上方的国家,最后才用工作表名。 */ + private static Country countryOf(String cell, String codeCell, String context) + { + Country found = namedCountry(cell); + if (found == null) + { + found = codedCountry(cell); + } + if (found == null) + { + found = codedCountry(codeCell); + } + if (found != null) + { + return found; + } + if (cell != null && !cell.isBlank()) + { + return new Country(cell.trim(), ""); + } + found = namedCountry(context); + if (found != null) + { + return found; + } + return new Country("", ""); + } + + private static Country namedCountry(String text) + { + if (text == null || text.isBlank()) + { + return null; + } + String source = text.toLowerCase(Locale.ROOT); + if ("uk".equals(source.trim())) + { + return new Country("英国", "GB"); + } + if (source.contains("英国") || source.contains("united kingdom")) + { + return new Country("英国", "GB"); + } + if (source.contains("德国") || source.contains("germany") || source.contains("de-imile")) + { + return new Country("德国", "DE"); + } + if (source.contains("法国") || source.contains("france")) + { + return new Country("法国", "FR"); + } + if (source.contains("西班牙") || source.contains("spain") || source.contains("código postal")) + { + return new Country("西班牙", "ES"); + } + if (source.contains("意大利") || source.contains("italy") || source.contains("it-imile") || source.contains("posteitaliane")) + { + return new Country("意大利", "IT"); + } + if (source.contains("荷兰") || source.contains("netherlands")) + { + return new Country("荷兰", "NL"); + } + if (source.contains("比利时") || source.contains("belgium")) + { + return new Country("比利时", "BE"); + } + if (source.contains("葡萄牙") || source.contains("portugal")) + { + return new Country("葡萄牙", "PT"); + } + if (source.contains("丹麦") || source.contains("denmark")) + { + return new Country("丹麦", "DK"); + } + if (source.contains("爱沙尼亚") || source.contains("estonia")) + { + return new Country("爱沙尼亚", "EE"); + } + if (source.contains("法罗") || source.contains("faroe")) + { + return new Country("法罗群岛", "FO"); + } + if (source.contains("芬兰") || source.contains("finland")) + { + return new Country("芬兰", "FI"); + } + if (source.contains("直布罗陀") || source.contains("gibraltar")) + { + return new Country("直布罗陀", "GI"); + } + if (source.contains("希腊") || source.contains("greece")) + { + return new Country("希腊", "GR"); + } + if (source.contains("格陵兰") || source.contains("greenland")) + { + return new Country("格陵兰", "GL"); + } + if (source.contains("克罗地亚") || source.contains("croatia")) + { + return new Country("克罗地亚", "HR"); + } + if (source.contains("马耳他") || source.contains("malta")) + { + return new Country("马耳他", "MT"); + } + if (source.contains("塞浦路斯") || source.contains("cyprus")) + { + return new Country("塞浦路斯", "CY"); + } + if (source.contains("挪威") || source.contains("norway")) + { + return new Country("挪威", "NO"); + } + if (source.contains("俄罗斯") || source.contains("russia")) + { + return new Country("俄罗斯", "RU"); + } + if (source.contains("瑞典") || source.contains("sweden")) + { + return new Country("瑞典", "SE"); + } + if (source.contains("土耳其") || source.contains("turkey")) + { + return new Country("土耳其", "TR"); + } + if (source.contains("乌克兰") || source.contains("ukraine")) + { + return new Country("乌克兰", "UA"); + } + if (source.contains("ctt") || source.contains("corres")) + { + return new Country("西班牙", "ES"); + } + if (source.contains("colissimo") || source.contains("cirro") || source.contains("gofo")) + { + return new Country("法国", "FR"); + } + if (source.contains("evri") || source.contains("royal mail")) + { + return new Country("英国", "GB"); + } + return null; + } + + private static Country codedCountry(String text) + { + if (text == null || !text.trim().matches("(?i)[A-Z]{2}")) + { + return null; + } + String code = text.trim().toUpperCase(Locale.ROOT); + if ("UK".equals(code) || "GB".equals(code)) + { + return new Country("英国", "GB"); + } + if ("DE".equals(code)) + { + return new Country("德国", "DE"); + } + if ("FR".equals(code)) + { + return new Country("法国", "FR"); + } + if ("ES".equals(code)) + { + return new Country("西班牙", "ES"); + } + if ("IT".equals(code)) + { + return new Country("意大利", "IT"); + } + if ("NL".equals(code)) + { + return new Country("荷兰", "NL"); + } + if ("BE".equals(code)) + { + return new Country("比利时", "BE"); + } + if ("PT".equals(code)) + { + return new Country("葡萄牙", "PT"); + } + if ("DK".equals(code)) + { + return new Country("丹麦", "DK"); + } + if ("EE".equals(code)) + { + return new Country("爱沙尼亚", "EE"); + } + if ("FO".equals(code)) + { + return new Country("法罗群岛", "FO"); + } + if ("FI".equals(code)) + { + return new Country("芬兰", "FI"); + } + if ("GI".equals(code)) + { + return new Country("直布罗陀", "GI"); + } + if ("GR".equals(code)) + { + return new Country("希腊", "GR"); + } + if ("GL".equals(code)) + { + return new Country("格陵兰", "GL"); + } + if ("HR".equals(code)) + { + return new Country("克罗地亚", "HR"); + } + if ("MT".equals(code)) + { + return new Country("马耳他", "MT"); + } + if ("NO".equals(code)) + { + return new Country("挪威", "NO"); + } + if ("RU".equals(code)) + { + return new Country("俄罗斯", "RU"); + } + if ("SE".equals(code)) + { + return new Country("瑞典", "SE"); + } + if ("TR".equals(code)) + { + return new Country("土耳其", "TR"); + } + if ("UA".equals(code)) + { + return new Country("乌克兰", "UA"); + } + return null; + } + + private static String cell(ExcelGrid grid, int row, int col) + { + if (col < 0) + { + return ""; + } + return flat(grid.text(row, col)); + } + + private static String flat(String text) + { + if (text == null) + { + return ""; + } + return text.replace('\n', ' ').replaceAll("\\s+", " ").trim(); + } + + private static String first(String value, String carried) + { + return value == null || value.isEmpty() ? (carried == null ? "" : carried) : value; + } + + private static String shortTitle(String title) + { + if (title == null) + { + return ""; + } + return title.length() > 80 ? title.substring(0, 80) : title; + } + + private static String cut(String value, int max) + { + if (value == null) + { + return ""; + } + return value.length() <= max ? value : value.substring(0, max); + } + + private static final class CatalogLink + { + private final String serviceType; + private final String country; + private final String productName; + private final String quoteSheet; + + private CatalogLink(String serviceType, String country, String productName, String quoteSheet) + { + this.serviceType = serviceType; + this.country = country; + this.productName = productName; + this.quoteSheet = quoteSheet; + } + } + + private static final class Series + { + private int postcodeCol = -1; + private int feeCol = -1; + private int endCol = -1; + private int cityCol = -1; + private int codeCol = -1; + private int regionCol = -1; + private int regionCnCol = -1; + private int zoneCol = -1; + private int countryCol = -1; + private int isoCol = -1; + private int startCol; + private int endBound; + private boolean fee; + private String sheetName = ""; + private String title = ""; + private String feeName = ""; + private String channel = ""; + private String zoneAbove = ""; + private String countryHint = ""; + } + + static final class PostcodeToken + { + final String postcode; + + final String end; + + final String raw; + + private PostcodeToken(String postcode, String end, String raw) + { + this.postcode = postcode == null ? "" : postcode; + this.end = end == null ? "" : end; + this.raw = raw == null ? "" : raw; + } + } + + private static final class Country + { + private final String name; + private final String code; + + private Country(String name, String code) + { + this.name = name; + this.code = code; + } + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PaySurchargeRules.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PaySurchargeRules.java new file mode 100644 index 00000000..d7928195 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PaySurchargeRules.java @@ -0,0 +1,355 @@ +package com.bms.price.pay; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import com.bms.price.domain.PaySurcharge; + +/** + * 把附加费说明收成可计算的条件。认不出的保持人工,避免把签名、重复单号当成每票必收。 + */ +public final class PaySurchargeRules +{ + public static final String ALWAYS = "ALWAYS"; + + public static final String POSTCODE = "POSTCODE"; + + public static final String WEIGHT = "WEIGHT"; + + public static final String SIZE = "SIZE"; + + public static final String RETURN = "RETURN"; + + public static final String MANUAL = "MANUAL"; + + private static final Pattern RANGE = Pattern.compile("([A-Z]{1,2})(\\d{1,2})\\s*-\\s*(\\d{1,2})"); + + private static final Pattern DISTRICT = Pattern.compile("([A-Z]{1,2})(\\d{1,2})"); + + private static final Pattern AREA = Pattern.compile("^[A-Z]{1,2}$"); + + private static final Pattern UK_FULL = Pattern.compile("^([A-Z]{1,2})(\\d{1,2})[A-Z]?\\d[A-Z]{2}$"); + + private static final Pattern UK_OUTWARD = Pattern.compile("^([A-Z]{1,2})(\\d{1,2})$"); + + private static final Pattern BOX = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*\\*\\s*(\\d+(?:\\.\\d+)?)\\s*\\*\\s*(\\d+(?:\\.\\d+)?)"); + + private static final Pattern VOLUME = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)\\s*(?:m3|m³|立方米)"); + + private static final Pattern CM = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*(?:cm|厘米)"); + + private static final Pattern KG = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)\\s*(?:kg|公斤|千克)"); + + private PaySurchargeRules() + { + } + + public static void apply(PaySurcharge fee) + { + if (fee == null) + { + return; + } + String name = fee.getFeeName() == null ? "" : fee.getFeeName(); + String desc = fee.getFeeDesc() == null ? "" : fee.getFeeDesc(); + String charge = fee.getChargeText() == null ? "" : fee.getChargeText(); + String blob = name + " " + desc + " " + charge; + if (name.contains("报价说明")) + { + fee.setCalcRule(MANUAL); + return; + } + if (name.contains("退件")) + { + fee.setCalcRule(RETURN); + return; + } + if (name.contains("签名") || name.contains("重复") || name.contains("未预报") || name.contains("重新贴")) + { + fee.setCalcRule(MANUAL); + return; + } + String postcodes = postcodeParam(desc); + if (name.contains("偏远") && !postcodes.isEmpty()) + { + fee.setCalcRule(POSTCODE); + fee.setCalcParam(postcodes); + return; + } + Matcher kg = KG.matcher(blob); + if ((name.contains("超重") || blob.contains("重量>") || blob.toLowerCase(Locale.ROOT).contains("weight")) && kg.find()) + { + fee.setCalcRule(WEIGHT); + fee.setCalcParam(kg.group(1)); + return; + } + String size = sizeParam(blob); + if (!size.isEmpty() && (name.contains("超大") || name.contains("超尺寸") || name.contains("特大") || blob.contains("厘米") || blob.toLowerCase(Locale.ROOT).contains("cm"))) + { + fee.setCalcRule(SIZE); + fee.setCalcParam(size); + return; + } + if (blob.contains("固定收取") || "固定收取".equals(fee.getChargeTiming())) + { + fee.setCalcRule(ALWAYS); + return; + } + fee.setCalcRule(MANUAL); + } + + public static boolean matchesPostcode(String param, String postcode) + { + UkPostcode parsed = parseUk(postcode); + if (parsed == null || param == null || param.isBlank()) + { + return false; + } + for (String token : param.split(",")) + { + if (tokenMatches(token.trim().toUpperCase(Locale.ROOT), parsed)) + { + return true; + } + } + return false; + } + + public static boolean matchesWeight(String param, BigDecimal weight) + { + if (param == null || param.isBlank() || weight == null) + { + return false; + } + try + { + return weight.compareTo(new BigDecimal(param.trim())) > 0; + } + catch (NumberFormatException ex) + { + return false; + } + } + + /** 长宽高单位厘米。箱规按从大到小比较,体积用立方米,总和是三边相加。 */ + public static boolean matchesSize(String param, BigDecimal length, BigDecimal width, BigDecimal height) + { + if (param == null || param.isBlank() || length == null || width == null || height == null) + { + return false; + } + BigDecimal[] sides = desc(length, width, height); + boolean sideHit = false; + boolean sumHit = false; + boolean sideSeen = false; + boolean sumSeen = false; + String join = "or"; + for (String part : param.split(";")) + { + String[] pair = part.split(":", 2); + if (pair.length < 2) + { + continue; + } + String key = pair[0].trim(); + String value = pair[1].trim(); + if ("join".equals(key)) + { + join = value; + continue; + } + if ("box".equals(key)) + { + String[] dims = value.split("\\*"); + if (dims.length == 3 && exceedsBox(sides, decimal(dims[0]), decimal(dims[1]), decimal(dims[2]))) + { + return true; + } + } + else if ("volume".equals(key)) + { + BigDecimal cubic = length.multiply(width).multiply(height) + .divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP); + if (cubic.compareTo(decimal(value)) >= 0) + { + return true; + } + } + else if ("side".equals(key)) + { + sideSeen = true; + sideHit = sides[0].compareTo(decimal(value)) > 0; + } + else if ("sum".equals(key)) + { + sumSeen = true; + sumHit = length.add(width).add(height).compareTo(decimal(value)) > 0; + } + } + if (sideSeen || sumSeen) + { + if ("and".equals(join)) + { + return (!sideSeen || sideHit) && (!sumSeen || sumHit); + } + return sideHit || sumHit; + } + return false; + } + + private static String postcodeParam(String desc) + { + if (desc == null || desc.isBlank()) + { + return ""; + } + int cut = Math.max(desc.lastIndexOf(';'), desc.lastIndexOf(';')); + String source = cut >= 0 ? desc.substring(cut + 1) : desc; + String upper = source.toUpperCase(Locale.ROOT).replace(',', ',').replace('、', ',').replace('.', ','); + List tokens = new ArrayList<>(); + for (String raw : upper.split("[,\\s]+")) + { + String token = raw.trim(); + if (token.isEmpty()) + { + continue; + } + if (RANGE.matcher(token).matches() || DISTRICT.matcher(token).matches() || AREA.matcher(token).matches()) + { + tokens.add(token); + } + } + return String.join(",", tokens); + } + + private static String sizeParam(String blob) + { + StringBuilder param = new StringBuilder(); + Matcher box = BOX.matcher(blob); + if (box.find()) + { + param.append("box:").append(box.group(1)).append('*').append(box.group(2)).append('*').append(box.group(3)); + } + Matcher volume = VOLUME.matcher(blob); + if (volume.find()) + { + if (param.length() > 0) + { + param.append(';'); + } + param.append("volume:").append(volume.group(1)); + } + List cms = new ArrayList<>(); + Matcher cm = CM.matcher(blob); + while (cm.find()) + { + cms.add(cm.group(1)); + } + if (cms.size() >= 1 && (blob.contains("一边") || blob.toLowerCase(Locale.ROOT).contains("length") || blob.contains("最长"))) + { + if (param.length() > 0) + { + param.append(';'); + } + param.append("side:").append(cms.get(0)); + if (cms.size() >= 2 && (blob.contains("总和") || blob.toLowerCase(Locale.ROOT).contains("sum"))) + { + param.append(";sum:").append(cms.get(1)); + param.append(";join:").append(join(blob)); + } + } + return param.toString(); + } + + private static String join(String blob) + { + if (blob.contains("或") || Pattern.compile("(?i)\\bor\\b").matcher(blob).find()) + { + return "or"; + } + return "and"; + } + + private static boolean tokenMatches(String token, UkPostcode postcode) + { + Matcher range = RANGE.matcher(token); + if (range.matches()) + { + int low = Integer.parseInt(range.group(2)); + int high = Integer.parseInt(range.group(3)); + return postcode.area.equals(range.group(1)) && postcode.district >= low && postcode.district <= high; + } + Matcher district = DISTRICT.matcher(token); + if (district.matches()) + { + return postcode.area.equals(district.group(1)) && postcode.district == Integer.parseInt(district.group(2)); + } + return AREA.matcher(token).matches() && postcode.area.equals(token); + } + + private static UkPostcode parseUk(String postcode) + { + if (postcode == null) + { + return null; + } + String compact = postcode.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", ""); + Matcher full = UK_FULL.matcher(compact); + if (full.matches()) + { + return new UkPostcode(full.group(1), Integer.parseInt(full.group(2))); + } + Matcher outward = UK_OUTWARD.matcher(compact); + if (outward.matches()) + { + return new UkPostcode(outward.group(1), Integer.parseInt(outward.group(2))); + } + return null; + } + + private static boolean exceedsBox(BigDecimal[] parcel, BigDecimal a, BigDecimal b, BigDecimal c) + { + BigDecimal[] limit = desc(a, b, c); + return parcel[0].compareTo(limit[0]) > 0 || parcel[1].compareTo(limit[1]) > 0 || parcel[2].compareTo(limit[2]) > 0; + } + + private static BigDecimal[] desc(BigDecimal a, BigDecimal b, BigDecimal c) + { + BigDecimal[] values = new BigDecimal[] { a, b, c }; + for (int i = 0; i < values.length; i++) + { + for (int j = i + 1; j < values.length; j++) + { + if (values[j].compareTo(values[i]) > 0) + { + BigDecimal swap = values[i]; + values[i] = values[j]; + values[j] = swap; + } + } + } + return values; + } + + private static BigDecimal decimal(String text) + { + return new BigDecimal(text.trim()); + } + + private static final class UkPostcode + { + private final String area; + + private final int district; + + private UkPostcode(String area, int district) + { + this.area = area; + this.district = district; + } + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayTexts.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayTexts.java new file mode 100644 index 00000000..8db15bf4 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayTexts.java @@ -0,0 +1,377 @@ +package com.bms.price.pay; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneId; +import java.util.Date; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 价卡单元格里的重量段、金额和时效原文解析。供导入预览和保存共用。 + */ +public final class PayTexts +{ + private static final Pattern WEIGHT_RANGE = Pattern.compile( + "(?i)(\\d+(?:\\.\\d+)?)\\s*[<<]\\s*[wW]\\s*[≤<=<]\\s*(\\d+(?:\\.\\d+)?)"); + + private static final Pattern WEIGHT_DASH = Pattern.compile( + "(?i)(\\d+(?:\\.\\d+)?)\\s*[-~~]\\s*(\\d+(?:\\.\\d+)?)\\s*(?:kg|公斤)?"); + + private static final Pattern FIRST_WEIGHT = Pattern.compile("(?i)首重\\s*(\\d+(?:\\.\\d+)?)\\s*(?:kg|公斤)?"); + + private static final Pattern STEP_WEIGHT = Pattern.compile("(?i)每增加\\s*(\\d+(?:\\.\\d+)?)"); + + private static final Pattern ONLY_KG = Pattern.compile("(?i)^\\s*(\\d+(?:\\.\\d+)?)\\s*(?:kg|公斤)\\s*$"); + + private static final Pattern CN_PERIOD = Pattern.compile( + "(20\\d{2})\\s*年\\s*(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*[日号]?\\s*[至到\\-~~]+\\s*(?:(20\\d{2})\\s*年\\s*)?(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*[日号]?"); + + private static final Pattern CN_MONTH = Pattern.compile( + "(20\\d{2})\\s*年\\s*(\\d{1,2})\\s*月\\s*[至到\\-~~]+\\s*(\\d{1,2})\\s*月"); + + private static final Pattern ISO_DATE = Pattern.compile("(20\\d{2})[./-](\\d{1,2})[./-](\\d{1,2})"); + + /** 20260928-20270117 这种连在一起的日期窗口 */ + private static final Pattern COMPACT_PERIOD = Pattern.compile("(20\\d{2})(\\d{2})(\\d{2})\\s*-\\s*(20\\d{2})(\\d{2})(\\d{2})"); + + private static final Pattern EN_PERIOD = Pattern.compile( + "(?i)(January|February|March|April|May|June|July|August|September|October|November|December)" + + "\\s+(\\d{1,2})\\w*\\s+to\\s+" + + "(January|February|March|April|May|June|July|August|September|October|November|December)" + + "\\s+(\\d{1,2})\\w*[,\\s]+(20\\d{2})"); + + private static final Pattern MONEY = Pattern.compile( + "(?i)(\\d+(?:\\.\\d+)?)\\s*(EUR|GBP|USD|RMB|CNY|欧元|英镑|人民币)?"); + + private static final Pattern PERCENT = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*%"); + + private PayTexts() + { + } + + /** 从重量段原文取出上下限。每增加一公斤只保留原文,不编造上下限。 */ + public static BigDecimal[] weightBounds(String label) + { + BigDecimal[] bounds = new BigDecimal[] { null, null }; + if (label == null || label.isBlank()) + { + return bounds; + } + Matcher range = WEIGHT_RANGE.matcher(label); + if (range.find()) + { + bounds[0] = decimal(range.group(1)); + bounds[1] = decimal(range.group(2)); + return bounds; + } + Matcher dash = WEIGHT_DASH.matcher(label); + if (dash.find()) + { + bounds[0] = decimal(dash.group(1)); + bounds[1] = decimal(dash.group(2)); + return bounds; + } + Matcher first = FIRST_WEIGHT.matcher(label); + if (first.find()) + { + bounds[0] = BigDecimal.ZERO; + bounds[1] = decimal(first.group(1)); + return bounds; + } + if (STEP_WEIGHT.matcher(label).find()) + { + return bounds; + } + Matcher only = ONLY_KG.matcher(label.trim()); + if (only.find()) + { + bounds[0] = BigDecimal.ZERO; + bounds[1] = decimal(only.group(1)); + } + return bounds; + } + + public static boolean looksLikeWeight(String text) + { + if (text == null) + { + return false; + } + return WEIGHT_RANGE.matcher(text).find() + || WEIGHT_DASH.matcher(text).find() + || FIRST_WEIGHT.matcher(text).find() + || STEP_WEIGHT.matcher(text).find() + || ONLY_KG.matcher(text.trim()).find(); + } + + /** + * 从说明里取第一段时效。defaultYear 用于只有月日、没有年份的句子。 + */ + public static Period period(String text, int defaultYear) + { + if (text == null || text.isBlank()) + { + return Period.NONE; + } + Matcher full = CN_PERIOD.matcher(text); + if (full.find()) + { + int y1 = Integer.parseInt(full.group(1)); + int y2 = full.group(4) == null ? y1 : Integer.parseInt(full.group(4)); + LocalDate start = date(y1, full.group(2), full.group(3)); + LocalDate end = date(y2, full.group(5), full.group(6)); + return new Period(start, end, full.group()); + } + Matcher month = CN_MONTH.matcher(text); + if (month.find()) + { + int year = Integer.parseInt(month.group(1)); + int m1 = Integer.parseInt(month.group(2)); + int m2 = Integer.parseInt(month.group(3)); + LocalDate start = LocalDate.of(year, m1, 1); + LocalDate end = YearMonth.of(year, m2).atEndOfMonth(); + return new Period(start, end, month.group()); + } + Matcher english = EN_PERIOD.matcher(text); + if (english.find()) + { + int year = Integer.parseInt(english.group(5)); + LocalDate start = date(year, monthNumber(english.group(1)), english.group(2)); + LocalDate end = date(year, monthNumber(english.group(3)), english.group(4)); + return new Period(start, end, english.group()); + } + Matcher compact = COMPACT_PERIOD.matcher(text); + if (compact.find()) + { + LocalDate start = date(compact.group(1), compact.group(2), compact.group(3)); + LocalDate end = date(compact.group(4), compact.group(5), compact.group(6)); + return new Period(start, end, compact.group()); + } + Matcher iso = ISO_DATE.matcher(text); + if (iso.find()) + { + LocalDate day = date(iso.group(1), iso.group(2), iso.group(3)); + return new Period(day, day, iso.group()); + } + return Period.NONE; + } + + /** 去掉时效句子后再取金额,避免把 2026 当成费用。 */ + public static Money money(String text, Period period) + { + if (text == null || text.isBlank()) + { + return Money.NONE; + } + String source = text; + if (period != null && period.text != null) + { + source = source.replace(period.text, " "); + } + Matcher percent = PERCENT.matcher(source); + if (percent.find() && !source.matches(".*(?i)(EUR|GBP|USD|RMB|CNY|欧元|英镑).*")) + { + return new Money(decimal(percent.group(1)), "", "%"); + } + Matcher matcher = MONEY.matcher(source); + Money found = Money.NONE; + while (matcher.find()) + { + String raw = matcher.group(1); + if (raw.startsWith("20") && raw.length() == 4) + { + continue; + } + String currency = currency(matcher.group(2)); + String unit = unit(source); + found = new Money(decimal(raw), currency, unit); + if (!currency.isEmpty()) + { + return found; + } + } + return found; + } + + public static String timing(String text, Period period) + { + String value = text == null ? "" : text; + if (value.contains("产生时") || value.contains("实报") || value.contains("实付")) + { + return "产生时收取"; + } + if (value.contains("旺季") || (period != null && period.start != null && value.contains("期间"))) + { + return "旺季期间"; + } + if (value.contains("固定收取") || value.contains("每票固定")) + { + return "固定收取"; + } + if (period != null && period.start != null) + { + return "旺季期间"; + } + return ""; + } + + public static String currencies(String text) + { + String source = text == null ? "" : text.toUpperCase(Locale.ROOT); + StringBuilder joined = new StringBuilder(); + appendCurrency(joined, source, "GBP", "英镑"); + appendCurrency(joined, source, "EUR", "欧元"); + appendCurrency(joined, source, "USD", "美元"); + if (source.contains("RMB") || source.contains("CNY") || source.contains("人民币")) + { + appendCurrency(joined, "CNY", "CNY", ""); + } + return joined.toString(); + } + + public static Date toDate(LocalDate day) + { + if (day == null) + { + return null; + } + // 用普通日期,避免 java.sql.Date 在接口序列化时抛出不支持的时间类型 + return Date.from(day.atStartOfDay(ZoneId.of("GMT+8")).toInstant()); + } + + private static void appendCurrency(StringBuilder joined, String source, String code, String alias) + { + if (source.contains(code) || (!alias.isEmpty() && source.contains(alias))) + { + if (joined.length() > 0) + { + joined.append(','); + } + joined.append(code); + } + } + + private static String currency(String token) + { + if (token == null) + { + return ""; + } + String value = token.toUpperCase(Locale.ROOT); + if (value.contains("GBP") || token.contains("英镑")) + { + return "GBP"; + } + if (value.contains("EUR") || token.contains("欧元")) + { + return "EUR"; + } + if (value.contains("USD") || token.contains("美元")) + { + return "USD"; + } + if (value.contains("RMB") || value.contains("CNY") || token.contains("人民币")) + { + return "CNY"; + } + return ""; + } + + private static String unit(String text) + { + String value = text.toUpperCase(Locale.ROOT); + if (value.contains("/ITEM") || value.contains("每票") || text.contains("/票") || value.contains("/PARCEL")) + { + return "ITEM"; + } + if (value.contains("/KG") || text.contains("/公斤") || text.contains("每公斤")) + { + return "KG"; + } + if (text.contains("/次")) + { + return "次"; + } + if (text.contains("%")) + { + return "%"; + } + return ""; + } + + private static int monthNumber(String name) + { + String[] months = { "JANUARY", "FEBRUARY", "MARCH", "APRIL", "MAY", "JUNE", "JULY", "AUGUST", + "SEPTEMBER", "OCTOBER", "NOVEMBER", "DECEMBER" }; + String key = name.toUpperCase(Locale.ROOT); + for (int i = 0; i < months.length; i++) + { + if (months[i].equals(key)) + { + return i + 1; + } + } + return 1; + } + + private static LocalDate date(String year, String month, String day) + { + return date(Integer.parseInt(year), month, day); + } + + private static LocalDate date(int year, String month, String day) + { + return date(year, Integer.parseInt(month), day); + } + + private static LocalDate date(int year, int month, String day) + { + return LocalDate.of(year, month, Integer.parseInt(day)); + } + + private static BigDecimal decimal(String raw) + { + return new BigDecimal(raw); + } + + /** 一段可以落库的生效窗口。text 是原文片段。 */ + public static final class Period + { + public static final Period NONE = new Period(null, null, null); + + public final LocalDate start; + + public final LocalDate end; + + public final String text; + + public Period(LocalDate start, LocalDate end, String text) + { + this.start = start; + this.end = end; + this.text = text; + } + } + + /** 收费标准里的金额。unit 为 % 时 amount 是百分数本身。 */ + public static final class Money + { + public static final Money NONE = new Money(null, "", ""); + + public final BigDecimal amount; + + public final String currency; + + public final String unit; + + public Money(BigDecimal amount, String currency, String unit) + { + this.amount = amount; + this.currency = currency == null ? "" : currency; + this.unit = unit == null ? "" : unit; + } + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayWorkbookParser.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayWorkbookParser.java new file mode 100644 index 00000000..a5ebaf77 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayWorkbookParser.java @@ -0,0 +1,4426 @@ +package com.bms.price.pay; + +import java.io.ByteArrayInputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.springframework.util.DigestUtils; +import com.bms.common.exception.ServiceException; +import com.bms.common.utils.poi.ExcelGrid; +import com.bms.price.domain.PayExtraLine; +import com.bms.price.domain.PayPreview; +import com.bms.price.domain.PayProduct; +import com.bms.price.domain.PaySurcharge; +import com.bms.price.domain.PayWeightBand; +import com.bms.price.pay.PayTexts.Money; +import com.bms.price.pay.PayTexts.Period; + +/** + * 读取应付价卡。第一张表是目录,只解析目录指向的产品报价表。 + * 分区表留在产品的关联字段里,不另建一条产品。高风险国家和燃油费率表写入对应产品的附加费。 + */ +public class PayWorkbookParser +{ + private static final int MAX_BYTES = 30 * 1024 * 1024; + + private static final int MAX_EXTRA = 20000; + + /** iMile 邮编分区一行一个邮编,行数超过普通价卡 */ + private static final int POSTCODE_ROWS = 12000; + + private static final Pattern FILE_DATE = Pattern.compile("(20\\d{2})[.\\-/年](\\d{1,2})[.\\-/月](\\d{1,2})"); + + private static final Pattern FILE_COMPACT = Pattern.compile("(20\\d{2})(\\d{2})(\\d{2})"); + + private static final Pattern FILE_MONTH_DAY = Pattern.compile("生效日期\\s*[::]?\\s*(\\d{1,2})[.\\-/](\\d{1,2})"); + + private static final Pattern COUNTRY_CODE = Pattern.compile("\\(([A-Z]{2})\\)"); + + private static final Pattern POSTCODE = Pattern.compile("(?i)([A-Z]{1,2}\\d|\\d{4,6}|[A-Z]{2}\\*)"); + + /** 偏远地区附加费单元格里的 Area A / Area B */ + private static final Pattern AREA_LABEL = Pattern.compile("(?i)\\b(area\\s*[A-Z])\\b"); + + /** 1.80 GBP/parcel 这种写在地区表左侧的收费标准 */ + private static final Pattern FEE_PRICE = Pattern.compile( + "(?i)(\\d+(?:\\.\\d+)?)\\s*(GBP|EUR|USD|CNY|RMB|欧元|英镑)?\\s*(/\\s*[A-Za-z]+)?"); + + /** + * 解析整本价卡,返回可直接预览、确认后再保存的产品列表。 + */ + public PayPreview parse(byte[] bytes, String filename) + { + if (bytes == null || bytes.length == 0) + { + throw new ServiceException("请选择价卡文件"); + } + if (bytes.length > MAX_BYTES) + { + throw new ServiceException("价卡文件不能超过30MB"); + } + String safeName = filename == null ? "价卡.xlsx" : filename; + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) + { + if (workbook.getNumberOfSheets() == 0) + { + throw new ServiceException("文件里没有工作表"); + } + ExcelGrid catalog = ExcelGrid.read(workbook.getSheetAt(0)); + FileMeta meta = fileMeta(safeName); + List items = readCatalog(catalog, workbook, meta); + if (items.isEmpty()) + { + throw new ServiceException("目录里没有找到产品报价表"); + } + PayPreview preview = new PayPreview(); + preview.setSourceFile(safeName); + preview.setFileMd5(DigestUtils.md5DigestAsHex(bytes)); + preview.setSupplierName(meta.supplier); + preview.setEffectiveDate(PayTexts.toDate(meta.effective)); + for (CatalogItem item : items) + { + preview.getProducts().add(buildProduct(workbook, item, meta, preview)); + } + attachImilePostcodes(workbook, preview.getProducts()); + attachCatalogRates(workbook, preview.getProducts()); + return preview; + } + catch (ServiceException ex) + { + throw ex; + } + catch (Exception ex) + { + throw new ServiceException("价卡无法读取,请确认是 Excel 报价文件"); + } + } + + /** + * 按字典值匹配工作表。选了单个产品时只读对应表;不选时读所有能对上的表。 + * 单元格里用分号隔开的国家、分区或费用名称,拆成多条。 + */ + public PayPreview parseMatched(byte[] bytes, String filename, String selected, Collection dictValues) + { + if (bytes == null || bytes.length == 0) + { + throw new ServiceException("请选择价卡文件"); + } + if (bytes.length > MAX_BYTES) + { + throw new ServiceException("价卡文件不能超过30MB"); + } + Map dict = dictIndex(dictValues); + if (dict.isEmpty()) + { + throw new ServiceException("应付产品字典还没有数据"); + } + String safeName = filename == null ? "价卡.xlsx" : filename; + String wanted = norm(selected); + try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) + { + if (workbook.getNumberOfSheets() == 0) + { + throw new ServiceException("文件里没有工作表"); + } + FileMeta meta = fileMeta(safeName); + PayPreview preview = new PayPreview(); + preview.setSourceFile(safeName); + preview.setFileMd5(DigestUtils.md5DigestAsHex(bytes)); + preview.setSupplierName(meta.supplier); + preview.setEffectiveDate(PayTexts.toDate(meta.effective)); + List skipped = new ArrayList<>(); + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + Sheet sheet = workbook.getSheetAt(i); + String sheetName = sheet.getSheetName() == null ? "" : sheet.getSheetName().trim(); + String dictValue = dict.get(norm(sheetName)); + if (dictValue == null) + { + skipped.add(sheetName); + continue; + } + if (!wanted.isEmpty() && !wanted.equals(norm(dictValue)) && !wanted.equals(norm(sheetName))) + { + skipped.add(sheetName); + continue; + } + preview.getProducts().add(readMatchedSheet(sheet, sheetName, dictValue, meta, preview)); + } + if (preview.getProducts().isEmpty() && !wanted.isEmpty() && workbook.getNumberOfSheets() == 1) + { + Sheet sheet = workbook.getSheetAt(0); + String sheetName = sheet.getSheetName() == null ? "" : sheet.getSheetName().trim(); + String dictValue = dict.containsValue(selected.trim()) ? selected.trim() : labelOf(dict, wanted); + preview.getProducts().add(readMatchedSheet(sheet, sheetName, dictValue, meta, preview)); + skipped.clear(); + } + if (preview.getProducts().isEmpty()) + { + throw new ServiceException(wanted.isEmpty() + ? "没有工作表能对上应付产品字典" + : "文件里没有「" + selected.trim() + "」这张工作表"); + } + attachWorkbookRates(workbook, preview.getProducts()); + preview.setSkippedSheets(skipped); + return preview; + } + catch (ServiceException ex) + { + throw ex; + } + catch (Exception ex) + { + throw new ServiceException("价卡无法读取,请确认是 Excel 报价文件"); + } + } + + private PayProduct readMatchedSheet(Sheet sheet, String sheetName, String dictValue, FileMeta meta, PayPreview preview) + { + PayProduct product = new PayProduct(); + product.setProductName(dictValue); + product.setDictValue(dictValue); + product.setSheetName(sheetName); + product.setSourceFile(preview.getSourceFile()); + product.setFileMd5(preview.getFileMd5()); + product.setEffectiveDate(preview.getEffectiveDate()); + product.setSupplierName(supplierOf(meta, sheetName)); + product.setWeightBands(new ArrayList<>()); + product.setSurcharges(new ArrayList<>()); + product.setExtraLines(new ArrayList<>()); + fillSheet(ExcelGrid.read(sheet), product); + expandSeparated(product); + return product; + } + + /** 分号拆开的是多条数据,不是一句话。句子太长时保持原样。 */ + private void expandSeparated(PayProduct product) + { + List bands = new ArrayList<>(); + for (PayWeightBand band : product.getWeightBands()) + { + List codes = splitList(band.getCountryCode()); + List countries = splitList(band.getCountryName()); + List zones = splitList(band.getZoneName()); + if (codes.size() > 1) + { + for (String code : codes) + { + PayWeightBand copy = copyBand(band); + copy.setCountryCode(code); + bands.add(copy); + } + } + else if (countries.size() > 1) + { + for (String country : countries) + { + PayWeightBand copy = copyBand(band); + copy.setCountryName(country); + bands.add(copy); + } + } + else if (zones.size() > 1) + { + for (String zone : zones) + { + PayWeightBand copy = copyBand(band); + copy.setZoneName(zone); + bands.add(copy); + } + } + else + { + bands.add(band); + } + } + int sort = 1; + for (PayWeightBand band : bands) + { + band.setSortNo(sort++); + } + product.setWeightBands(bands); + + List fees = new ArrayList<>(); + for (PaySurcharge fee : product.getSurcharges()) + { + List names = splitList(fee.getFeeName()); + List zones = splitList(fee.getZoneName()); + if (names.size() > 1) + { + for (String name : names) + { + PaySurcharge copy = copyFee(fee); + copy.setFeeName(name); + fees.add(copy); + } + } + else if (zones.size() > 1) + { + for (String zone : zones) + { + PaySurcharge copy = copyFee(fee); + copy.setZoneName(zone); + fees.add(copy); + } + } + else + { + fees.add(fee); + } + } + sort = 1; + for (PaySurcharge fee : fees) + { + fee.setSortNo(sort++); + } + product.setSurcharges(fees); + product.setWeightCount(bands.size()); + product.setSurchargeCount(fees.size()); + product.setExtraCount(product.getExtraLines() == null ? 0 : product.getExtraLines().size()); + if (product.getWeightCount() + product.getSurchargeCount() + product.getExtraCount() == 0) + { + product.setParseStatus("EMPTY"); + } + else + { + product.setParseStatus("OK"); + } + } + + private static Map dictIndex(Collection dictValues) + { + Map dict = new LinkedHashMap<>(); + if (dictValues == null) + { + return dict; + } + for (String value : dictValues) + { + if (value == null || value.isBlank()) + { + continue; + } + dict.put(norm(value), value.trim()); + } + return dict; + } + + private static String labelOf(Map dict, String wanted) + { + String label = dict.get(wanted); + return label == null ? wanted : label; + } + + private static String norm(String value) + { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT).replace(" ", ""); + } + + private static List splitList(String value) + { + if (value == null || !value.matches(".*[;;].*")) + { + return List.of(value == null ? "" : value); + } + String[] parts = value.split("[;;]"); + List items = new ArrayList<>(); + for (String part : parts) + { + String text = part.trim(); + if (!text.isEmpty()) + { + items.add(text); + } + } + if (items.size() < 2) + { + return List.of(value); + } + for (String item : items) + { + if (item.length() > 80) + { + return List.of(value); + } + } + return items; + } + + private static PayWeightBand copyBand(PayWeightBand band) + { + PayWeightBand copy = new PayWeightBand(); + copy.setSectionName(band.getSectionName()); + copy.setCountryName(band.getCountryName()); + copy.setCountryCode(band.getCountryCode()); + copy.setZoneName(band.getZoneName()); + copy.setWeightLabel(band.getWeightLabel()); + copy.setWeightMin(band.getWeightMin()); + copy.setWeightMax(band.getWeightMax()); + copy.setVolumeText(band.getVolumeText()); + copy.setChargeName(band.getChargeName()); + copy.setPrice(band.getPrice()); + copy.setPriceUnit(band.getPriceUnit()); + return copy; + } + + private static PaySurcharge copyFee(PaySurcharge fee) + { + PaySurcharge copy = new PaySurcharge(); + copy.setSectionName(fee.getSectionName()); + copy.setFeeName(fee.getFeeName()); + copy.setZoneName(fee.getZoneName()); + copy.setChargeText(fee.getChargeText()); + copy.setAmount(fee.getAmount()); + copy.setCurrency(fee.getCurrency()); + copy.setFeeDesc(fee.getFeeDesc()); + copy.setPeriodText(fee.getPeriodText()); + copy.setEffectiveStart(fee.getEffectiveStart()); + copy.setEffectiveEnd(fee.getEffectiveEnd()); + copy.setChargeTiming(fee.getChargeTiming()); + copy.setCalcRule(fee.getCalcRule()); + copy.setCalcParam(fee.getCalcParam()); + return copy; + } + + private PayProduct buildProduct(Workbook workbook, CatalogItem item, FileMeta meta, PayPreview preview) + { + PayProduct product = new PayProduct(); + product.setProductName(item.name); + product.setCountryScope(item.country); + product.setServiceType(item.serviceType); + product.setClassification(item.classification); + product.setCatalogRemark(item.remark); + product.setUpdateContent(item.updateContent); + product.setWeightLimit(item.weightLimit); + product.setSheetName(item.sheetName); + product.setRelatedSheet(item.relatedSheet); + product.setSourceFile(preview.getSourceFile()); + product.setFileMd5(preview.getFileMd5()); + product.setEffectiveDate(PayTexts.toDate(item.effective != null ? item.effective : meta.effective)); + product.setSupplierName(supplierOf(meta, item.sheetName)); + product.setWeightBands(new ArrayList<>()); + product.setSurcharges(new ArrayList<>()); + product.setExtraLines(new ArrayList<>()); + Sheet sheet = sheetNamed(workbook, item.sheetName); + if (sheet == null) + { + product.setParseStatus("MISSING"); + product.setRemark("目录指向的工作表不存在"); + product.setWeightCount(0); + product.setSurchargeCount(0); + product.setExtraCount(0); + return product; + } + fillSheet(ExcelGrid.read(sheet), product); + return product; + } + + /** 工作表名末尾多一个空格时,目录链接里的名字对不上,按去掉空格后再找一次。 */ + private static Sheet sheetNamed(Workbook workbook, String name) + { + if (name == null || name.isBlank()) + { + return null; + } + Sheet sheet = workbook.getSheet(name); + if (sheet != null) + { + return sheet; + } + String wanted = name.trim(); + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + String current = workbook.getSheetName(i); + if (current != null && current.trim().equals(wanted)) + { + return workbook.getSheetAt(i); + } + } + return null; + } + + /** + * iMile邮编分区左右两块分别挂到 IT-iMile、DE-iMile,不另建产品。 + * 意大利用邮编找到州、城市,并把分区换成价卡上的 City、Province、Rural。 + * 德国这块没有州和城市,只保留 Active 的邮编覆盖。 + */ + private void attachImilePostcodes(Workbook workbook, List products) + { + Sheet sheet = null; + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + String name = workbook.getSheetName(i); + if (name != null && name.contains("iMile") && name.contains("邮编")) + { + sheet = workbook.getSheetAt(i); + break; + } + } + if (sheet == null) + { + return; + } + PayProduct italy = productOf(products, "IT-iMile"); + PayProduct germany = productOf(products, "DE-iMile"); + if (italy == null && germany == null) + { + return; + } + ExcelGrid grid = ExcelGrid.read(sheet, POSTCODE_ROWS); + int header = -1; + for (int row = 0; row < Math.min(grid.rows(), 6); row++) + { + String line = joinRow(grid, row); + if (line.contains("邮编") && (line.contains("城市") || line.toLowerCase(Locale.ROOT).contains("zip"))) + { + header = row; + break; + } + } + if (header < 0) + { + return; + } + int regionCol = headerColumn(grid, header, text -> text.contains("地区") && !text.contains("代码")); + int cityCol = headerColumn(grid, header, text -> text.contains("城市")); + int codeCol = headerColumn(grid, header, text -> text.contains("地区代码")); + int postcodeCol = headerColumn(grid, header, text -> text.contains("邮编")); + int zoneCol = headerColumn(grid, header, text -> "分区".equals(text) || text.startsWith("分区")); + int countryCol = headerColumn(grid, header, text -> text.toLowerCase(Locale.ROOT).contains("country")); + int productCol = headerColumn(grid, header, text -> "product".equalsIgnoreCase(text) || text.toLowerCase(Locale.ROOT).startsWith("product")); + int zipCol = headerColumn(grid, header, text -> text.toLowerCase(Locale.ROOT).contains("zip")); + int statusCol = headerColumn(grid, header, text -> text.toLowerCase(Locale.ROOT).contains("status")); + for (int row = header + 1; row < grid.rows(); row++) + { + if (italy != null && postcodeCol >= 0) + { + String postcode = postcodeOf(grid, row, postcodeCol); + String zone = imileZone(grid.text(row, zoneCol)); + if (!postcode.isEmpty() && !zone.isEmpty()) + { + addPostcode(italy, cell(grid, row, regionCol), cell(grid, row, cityCol), cell(grid, row, codeCol), postcode, zone); + } + } + if (germany != null && zipCol >= 0) + { + String postcode = postcodeOf(grid, row, zipCol); + String status = cell(grid, row, statusCol); + if (!postcode.isEmpty() && (statusCol < 0 || status.equalsIgnoreCase("Active"))) + { + addPostcode(germany, cell(grid, row, countryCol), cell(grid, row, productCol), postcode, status.isEmpty() ? "Active" : status); + } + } + } + String sheetName = sheet.getSheetName(); + if (italy != null && !italy.getExtraLines().isEmpty()) + { + linkSheet(italy, sheetName); + italy.setExtraCount(italy.getExtraLines().size()); + } + if (germany != null && !germany.getExtraLines().isEmpty()) + { + linkSheet(germany, sheetName); + germany.setExtraCount(germany.getExtraLines().size()); + } + if (sheet.getLastRowNum() >= POSTCODE_ROWS) + { + String remark = "邮编分区超过" + POSTCODE_ROWS + "行,已保留前" + POSTCODE_ROWS + "行"; + if (italy != null) + { + italy.setRemark(remark); + } + if (germany != null) + { + germany.setRemark(remark); + } + } + } + + private static PayProduct productOf(List products, String name) + { + for (PayProduct product : products) + { + if (name.equals(product.getSheetName()) || name.equals(product.getProductName())) + { + return product; + } + } + return null; + } + + private static int headerColumn(ExcelGrid grid, int row, java.util.function.Predicate test) + { + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (!text.isEmpty() && test.test(text)) + { + return col; + } + } + return -1; + } + + /** city(dense)、province(no dense)、rural(remote) 对应价卡从左到右的三列。 */ + private static String imileZone(String text) + { + String value = text == null ? "" : text.trim().toLowerCase(Locale.ROOT); + if (value.startsWith("city")) + { + return "City"; + } + if (value.startsWith("province")) + { + return "Province"; + } + if (value.startsWith("rural")) + { + return "Rural"; + } + return ""; + } + + private static String postcodeOf(ExcelGrid grid, int row, int col) + { + if (col < 0) + { + return ""; + } + Double number = grid.number(row, col); + if (number != null) + { + long value = Math.round(number); + if (value >= 0 && value <= 99999) + { + return String.format("%05d", value); + } + } + String text = grid.text(row, col).trim(); + if (text.matches("\\d{1,5}")) + { + return String.format("%05d", Integer.parseInt(text)); + } + return text; + } + + private static String cell(ExcelGrid grid, int row, int col) + { + if (col < 0) + { + return ""; + } + return grid.text(row, col).replace('\n', ' ').trim(); + } + + private void addPostcode(PayProduct product, String... parts) + { + if (product.getExtraLines().size() >= MAX_EXTRA) + { + product.setRemark("邮编超过" + MAX_EXTRA + "行,已保留前" + MAX_EXTRA + "行"); + return; + } + StringBuilder text = new StringBuilder(); + for (String part : parts) + { + if (text.length() > 0) + { + text.append(" | "); + } + text.append(part == null ? "" : part); + } + PayExtraLine line = new PayExtraLine(); + line.setLineType("邮编"); + line.setLineText(text.toString()); + line.setSortNo(product.getExtraLines().size() + 1); + product.getExtraLines().add(line); + } + + private static void linkSheet(PayProduct product, String sheetName) + { + String current = product.getRelatedSheet(); + if (current == null || current.isBlank()) + { + product.setRelatedSheet(sheetName); + return; + } + if (!current.contains(sheetName)) + { + product.setRelatedSheet(current + "、" + sheetName); + } + } + + /** + * 目录分区表里的高风险国家,以及报价里写了危机燃油、但费率在单独表上的阶梯,挂回原产品。 + * 不另建产品,服务类型、国家和销售产品仍以目录那一行为准。 + */ + private void attachCatalogRates(Workbook workbook, List products) + { + for (PayProduct product : products) + { + String related = product.getRelatedSheet(); + if (related == null || related.isBlank()) + { + continue; + } + for (String name : related.split("、")) + { + Sheet sheet = workbook.getSheet(name.trim()); + if (sheet == null) + { + continue; + } + addHighRisk(product, ExcelGrid.read(sheet, 500), sheet.getSheetName()); + } + } + Sheet fuel = null; + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + String name = workbook.getSheetName(i); + if (name != null && name.contains("危机燃油")) + { + fuel = workbook.getSheetAt(i); + break; + } + } + if (fuel == null) + { + return; + } + ExcelGrid grid = ExcelGrid.read(fuel, 80); + for (PayProduct product : products) + { + String relatedName = product.getRelatedSheet() == null ? "" : product.getRelatedSheet(); + String sheetName = product.getSheetName() == null ? "" : product.getSheetName(); + // 费率表只挂到目录关联了它的产品,以及德国本地 DPD。国际件自己的说明行不能把整张阶梯再复制一份 + boolean linked = relatedName.contains("危机燃油"); + boolean domesticDpd = sheetName.contains("DPD挂号") && !sheetName.contains("国际"); + if (!linked && !domesticDpd) + { + continue; + } + int before = product.getSurcharges().size(); + addFuelLadder(product, grid, fuel.getSheetName()); + if (product.getSurcharges().size() > before) + { + linkSheet(product, fuel.getSheetName()); + product.setSurchargeCount(product.getSurcharges().size()); + } + } + } + + /** + * 只导入其中一张产品表时,同一本里的高风险国家表仍挂回提到它的产品。 + * 整本目录解析走关联字段,不从这里再挂一次。 + */ + private void attachWorkbookRates(Workbook workbook, List products) + { + Sheet risk = null; + for (int i = 0; i < workbook.getNumberOfSheets(); i++) + { + String name = workbook.getSheetName(i); + if (name != null && name.contains("高风险")) + { + risk = workbook.getSheetAt(i); + break; + } + } + if (risk == null) + { + return; + } + String riskName = risk.getSheetName(); + ExcelGrid grid = ExcelGrid.read(risk, 500); + for (PayProduct product : products) + { + if (riskName.equals(product.getSheetName()) || !mentionsHighRisk(product)) + { + continue; + } + int before = product.getSurcharges().size(); + addHighRisk(product, grid, riskName); + if (product.getSurcharges().size() > before) + { + linkSheet(product, riskName); + product.setSurchargeCount(product.getSurcharges().size()); + } + } + } + + private static boolean mentionsHighRisk(PayProduct product) + { + if (product.getSurcharges() == null) + { + return false; + } + for (PaySurcharge fee : product.getSurcharges()) + { + String text = (fee.getFeeName() == null ? "" : fee.getFeeName()) + + (fee.getChargeText() == null ? "" : fee.getChargeText()); + if (text.contains("高风险")) + { + return true; + } + } + return false; + } + + /** 国家、国家简码、每公斤金额。说明行没有二字码,写进费用解释。 */ + private void addHighRisk(PayProduct product, ExcelGrid grid, String sheetName) + { + int header = -1; + int countryCol = -1; + int isoCol = -1; + int amountCol = -1; + for (int row = 0; row < Math.min(grid.rows(), 8); row++) + { + int country = -1; + int iso = -1; + int amount = -1; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (text.contains("简码") || text.contains("二字")) + { + iso = col; + } + else if (text.contains("国家")) + { + country = col; + } + else if (text.toUpperCase(Locale.ROOT).contains("RMB") || text.contains("/kg") || text.toLowerCase(Locale.ROOT).contains("surcharge")) + { + amount = col; + } + } + if (iso >= 0 && amount >= 0) + { + header = row; + countryCol = country; + isoCol = iso; + amountCol = amount; + break; + } + } + if (header < 0) + { + return; + } + String unit = grid.text(header, amountCol).replace('\n', ' ').trim(); + String currency = unit.toUpperCase(Locale.ROOT).contains("GBP") ? "GBP" : unit.toUpperCase(Locale.ROOT).contains("EUR") ? "EUR" : "RMB"; + StringBuilder notes = new StringBuilder(); + for (int row = 0; row < grid.rows(); row++) + { + if (row == header) + { + continue; + } + for (int col = 0; col < grid.cols(); col++) + { + if (col == countryCol || col == isoCol || col == amountCol) + { + continue; + } + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (text.length() > 12) + { + if (notes.length() > 0) + { + notes.append(' '); + } + notes.append(text); + } + } + } + String desc = cut(notes, 2000); + for (int row = header + 1; row < grid.rows(); row++) + { + String code = grid.text(row, isoCol).trim().toUpperCase(Locale.ROOT); + BigDecimal amount = plainDecimal(grid.text(row, amountCol)); + if (!code.matches("[A-Z]{2}") || amount == null) + { + continue; + } + String country = countryCol < 0 ? "" : grid.text(row, countryCol).replace('\n', ' ').trim(); + PaySurcharge fee = new PaySurcharge(); + fee.setSectionName(cut(new StringBuilder(sheetName), 200)); + fee.setFeeName("高风险地区附加费"); + fee.setZoneName(cut(new StringBuilder(country.isEmpty() ? code : country), 200)); + fee.setChargeText(cut(new StringBuilder(amount.toPlainString() + " " + currency + (unit.contains("/kg") ? "/kg" : "")), 500)); + fee.setAmount(amount); + fee.setCurrency(currency); + fee.setFeeDesc(desc); + fee.setCalcRule(PaySurchargeRules.MANUAL); + fee.setCalcParam(code); + fee.setSortNo(product.getSurcharges().size() + 1); + product.getSurcharges().add(fee); + } + product.setSurchargeCount(product.getSurcharges().size()); + } + + /** 燃油公开价区间对应的危机燃油费率。低于下限的那一行费率是 0。 */ + private void addFuelLadder(PayProduct product, ExcelGrid grid, String sheetName) + { + int header = -1; + int fromCol = -1; + int toCol = -1; + int rateCol = -1; + for (int row = 0; row < Math.min(grid.rows(), 6); row++) + { + int from = -1; + int to = -1; + int rate = -1; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).toLowerCase(Locale.ROOT); + if (text.contains("from")) + { + from = col; + } + else if (text.contains("less")) + { + to = col; + } + else if (text.contains("surcharge") || text.contains("费率")) + { + rate = col; + } + } + if (from >= 0 && to >= 0 && rate >= 0) + { + header = row; + fromCol = from; + toCol = to; + rateCol = rate; + break; + } + } + if (header < 0) + { + return; + } + for (int row = header + 1; row < grid.rows(); row++) + { + BigDecimal rate = plainDecimal(grid.text(row, rateCol)); + if (rate == null) + { + continue; + } + String from = boundText(grid.text(row, fromCol)); + String to = boundText(grid.text(row, toCol)); + if (from.isEmpty() && to.isEmpty()) + { + continue; + } + String charge; + if (from.isEmpty()) + { + charge = "油价 < " + to; + } + else if (to.isEmpty()) + { + charge = "油价 ≥ " + from; + } + else + { + charge = from + " ≤ 油价 < " + to; + } + PaySurcharge fee = new PaySurcharge(); + fee.setSectionName(cut(new StringBuilder(sheetName), 200)); + fee.setFeeName("危机燃油附加费"); + fee.setChargeText(charge); + fee.setAmount(rate); + fee.setFeeDesc("燃油公开价落在该区间时的危机燃油费率"); + fee.setCalcRule(PaySurchargeRules.MANUAL); + fee.setCalcParam(from + "|" + to); + fee.setSortNo(product.getSurcharges().size() + 1); + product.getSurcharges().add(fee); + } + } + + private static String boundText(String text) + { + String value = text == null ? "" : text.trim(); + if (value.isEmpty() || "...".equals(value) || "-".equals(value) || "—".equals(value)) + { + return ""; + } + return value; + } + + private static BigDecimal plainDecimal(String text) + { + String value = text == null ? "" : text.trim().replace(",", ""); + if (!value.matches("-?\\d+(\\.\\d+)?")) + { + return null; + } + return new BigDecimal(value); + } + + /** 文件名里的易德、威狮;8.1 这种只把 DHL 泛欧换成 GWE。 */ + private String supplierOf(FileMeta meta, String sheetName) + { + if (meta.gweFile && sheetName != null && sheetName.contains("DHL泛欧")) + { + return "GWE"; + } + return meta.supplier; + } + + private void fillSheet(ExcelGrid grid, PayProduct product) + { + StringBuilder note = new StringBuilder(); + String pending = ""; + int row = 0; + while (row < grid.rows()) + { + if (grid.blankRow(row)) + { + row++; + continue; + } + int zoneCodeRow = countryZoneCodeRow(grid, row); + if (zoneCodeRow >= 0) + { + row = readCountryZoneTable(grid, zoneCodeRow, product); + pending = ""; + continue; + } + int serviceHeader = servicePriceHeader(grid, row); + if (serviceHeader >= 0) + { + row = readServicePrices(grid, serviceHeader, product); + pending = ""; + continue; + } + if (isBillableConceptHeader(grid, row)) + { + row = readBillableConcepts(grid, row, product); + pending = ""; + continue; + } + if (isInsularServiceHeader(grid, row)) + { + row = readInsularRates(grid, row, product); + pending = ""; + continue; + } + if (isKgStepHeader(grid, row)) + { + row = readKgStepTable(grid, row, product); + pending = ""; + continue; + } + int remoteHeader = remoteFeeHeader(grid, row); + if (remoteHeader >= 0) + { + row = readRemoteFeeTable(grid, remoteHeader, product); + pending = ""; + continue; + } + if (isWeightColumnHeader(grid, row)) + { + row = readWeightColumns(grid, row, pending, product); + pending = ""; + continue; + } + if (isRegionWeightHeader(grid, row)) + { + row = readRegionWeightTable(grid, row, product); + pending = ""; + continue; + } + if (isSplitWeightHeader(grid, row)) + { + row = readSplitWeightTable(grid, row, product); + pending = ""; + continue; + } + if (isFeeSectionTitle(grid, row)) + { + row = readFeeSection(grid, row, product); + pending = ""; + continue; + } + if (isSurchargeHeader(grid, row)) + { + row = readSurchargeTable(grid, row, pending, product, note); + pending = ""; + continue; + } + if (isSurchargeMatrix(grid, row)) + { + row = readSurchargeMatrix(grid, row, pending, product); + pending = ""; + continue; + } + if (isFeeRateHeader(grid, row)) + { + row = readFeeRateTable(grid, row, product); + pending = ""; + continue; + } + if (isPriceHeader(grid, row)) + { + row = readPriceTable(grid, row, pending, product, note); + pending = ""; + continue; + } + if (isZonePriceHeader(grid, row)) + { + row = readZonePrices(grid, row, product); + pending = ""; + continue; + } + if (isPostcodeHeader(grid, row)) + { + row = readLines(grid, row, "邮编", product); + pending = ""; + continue; + } + if (isLooseSurcharge(grid, row)) + { + addLooseSurcharge(grid, row, pending, product); + row++; + continue; + } + String line = joinRow(grid, row); + // 备注、说明段落不是表格,不写入附加费,也不收进服务说明 + if (countText(grid, row) <= 2 && line.length() <= 40 && !line.contains("特别申明") && !line.contains("。")) + { + pending = line; + } + row++; + } + readFuelMark(grid, product); + if (product.getWeightBands().isEmpty() && product.getSurcharges().isEmpty() && product.getExtraLines().isEmpty() + && postcodeHits(grid) >= 5) + { + readLines(grid, 0, "邮编", product); + } + product.setServiceNote(cut(note, 4000)); + String detectedCurrency = currencyOf(grid); + if ((detectedCurrency == null || detectedCurrency.isBlank()) && product.getCurrency() != null && !product.getCurrency().isBlank()) + { + detectedCurrency = product.getCurrency(); + } + product.setCurrency(detectedCurrency); + product.setWeightCount(product.getWeightBands().size()); + product.setSurchargeCount(product.getSurcharges().size()); + product.setExtraCount(product.getExtraLines().size()); + applyFileCountry(product); + product.setChargeType(chargeTypeOf(product)); + product.setExtraPerKg("续重计费".equals(product.getChargeType()) ? extraPerKgOf(product) : null); + if (product.getWeightCount() + product.getSurchargeCount() + product.getExtraCount() == 0) + { + product.setParseStatus("EMPTY"); + product.setRemark("报价表里没有识别到重量段、附加费或邮编配置"); + } + else + { + product.setParseStatus("OK"); + } + } + + /** + * 和派送费同一版式,但表头是偏远附加费、额外线路运输费这类费用。按地区和重量记附加费,不进重量段。 + */ + private int readFeeRateTable(ExcelGrid grid, int header, PayProduct product) + { + int data = header + 1; + int subHeader = -1; + while (data < grid.rows() && !grid.blankRow(data) && !isHardHeader(grid, data)) + { + if (countNumbers(grid, data) > 0 || rowHasWeight(grid, data)) + { + break; + } + if (countText(grid, data) >= 2) + { + subHeader = data; + } + data++; + } + int[] kind = new int[grid.cols()]; + String[] parent = new String[grid.cols()]; + String[] sub = new String[grid.cols()]; + String carry = ""; + for (int col = 0; col < grid.cols(); col++) + { + if (strayColumn(grid, header, col, subHeader)) + { + parent[col] = ""; + sub[col] = ""; + kind[col] = -1; + continue; + } + String head = grid.text(header, col); + if (isPriceHead(head)) + { + carry = head; + } + if (head.isBlank()) + { + head = carry; + } + parent[col] = head; + sub[col] = subHeader < 0 ? "" : grid.text(subHeader, col); + kind[col] = columnKind(head, numericBelow(grid, col, data)); + } + int added = product.getSurcharges().size(); + while (data < grid.rows()) + { + if (grid.blankRow(data) || isHardHeader(grid, data)) + { + break; + } + String weight = ""; + String country = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(data, col); + if (text.isBlank()) + { + continue; + } + if (kind[col] == 1 || (weight.isEmpty() && PayTexts.looksLikeWeight(text))) + { + if (weight.isEmpty() || kind[col] == 1) + { + weight = text.replace('\n', ' ').trim(); + } + } + else if (kind[col] == 2 && country.isEmpty()) + { + country = text.replace('\n', ' ').trim(); + } + } + boolean priced = false; + for (int col = 0; col < grid.cols(); col++) + { + if (kind[col] != 4) + { + continue; + } + BigDecimal price = priceOf(grid, data, col); + if (price == null) + { + continue; + } + priced = true; + String feeName = shortCharge(parent[col]); + String zone = zoneOf(sub[col], parent[col]).replace('\n', ' ').replaceAll("\\s+", " ").trim(); + String standard = price.stripTrailingZeros().toPlainString() + " " + feeUnit(parent[col], weight); + String desc = country.isBlank() ? weight : country + ";" + weight; + addSurcharge(product, feeName, feeName, zone, standard.trim(), desc, ""); + PaySurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1); + fee.setCalcRule(PaySurchargeRules.MANUAL); + fee.setCalcParam(weight); + } + if (!priced && joinRow(grid, data).length() > 40) + { + break; + } + data++; + } + if (data < grid.rows() && !grid.blankRow(data) && !isHardHeader(grid, data)) + { + String note = joinRow(grid, data).replace('\n', ' ').trim(); + if (note.length() > 20 && countNumbers(grid, data) == 0) + { + for (int i = added; i < product.getSurcharges().size(); i++) + { + PaySurcharge fee = product.getSurcharges().get(i); + String desc = fee.getFeeDesc() == null ? "" : fee.getFeeDesc(); + fee.setFeeDesc(cut(new StringBuilder(desc.isBlank() ? note : desc + "。" + note), 2000)); + } + data++; + } + } + return data; + } + + private int readPriceTable(ExcelGrid grid, int header, String pending, PayProduct product, StringBuilder note) + { + int data = header + 1; + int subHeader = -1; + String section = cleanTitle(pending); + while (data < grid.rows() && !grid.blankRow(data) && !isHardHeader(grid, data)) + { + if (countNumbers(grid, data) > 0 || rowHasWeight(grid, data)) + { + break; + } + if (countText(grid, data) >= 2) + { + subHeader = data; + } + else if (section.isEmpty()) + { + section = joinRow(grid, data); + } + data++; + } + int[] kind = new int[grid.cols()]; + String[] parent = new String[grid.cols()]; + String[] sub = new String[grid.cols()]; + String carry = ""; + for (int col = 0; col < grid.cols(); col++) + { + if (strayColumn(grid, header, col, subHeader)) + { + parent[col] = ""; + sub[col] = ""; + kind[col] = -1; + continue; + } + String head = grid.text(header, col); + if (isPriceHead(head)) + { + carry = head; + } + if (head.isBlank()) + { + head = carry; + } + parent[col] = head; + sub[col] = subHeader < 0 ? "" : grid.text(subHeader, col); + kind[col] = columnKind(head, numericBelow(grid, col, data)); + } + if (section.isEmpty()) + { + section = firstFeeHead(parent); + } + while (data < grid.rows()) + { + if (grid.blankRow(data) || isHardHeader(grid, data)) + { + break; + } + if (isLooseSurcharge(grid, data) && countNumbers(grid, data) <= 1 && !rowHasWeight(grid, data)) + { + break; + } + String weight = ""; + String rowCountry = ""; + String rowCode = ""; + String rowLabel = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(data, col); + if (text.isBlank()) + { + continue; + } + if (kind[col] == 1 || (weight.isEmpty() && PayTexts.looksLikeWeight(text))) + { + if (weight.isEmpty() || kind[col] == 1) + { + weight = text; + } + } + else if (kind[col] == 2) + { + rowCountry = text; + } + else if (kind[col] == 3) + { + rowCode = text; + } + else if (kind[col] == 0 && rowLabel.isEmpty() && text.length() <= 40) + { + rowLabel = text; + } + if (kind[col] == 0 && (text.contains("已含") || text.contains("燃油"))) + { + appendNote(note, parent[col] + ":" + text); + } + } + // 国家、产品只取本行单元格。纵向合并不再抄到后面的重量段和超重行 + String country = rowCountry; + String countryCode = rowCode.isBlank() ? codesIn(rowCountry) : rowCode.trim(); + boolean priced = false; + String volume = volumeOnRow(grid, data, kind, parent); + for (int col = 0; col < grid.cols(); col++) + { + if (kind[col] != 4) + { + continue; + } + BigDecimal price = priceOf(grid, data, col); + if (price == null) + { + continue; + } + if (col > 0 && parent[col].equals(parent[col - 1])) + { + BigDecimal previous = priceOf(grid, data, col - 1); + if (previous != null && previous.compareTo(price) == 0) + { + continue; + } + } + priced = true; + boolean feeHead = isPriceHead(parent[col]); + PayWeightBand band = new PayWeightBand(); + band.setSectionName(blankTo(section, "价格")); + band.setCountryName(country); + band.setCountryCode(countryCode); + band.setWeightLabel(weight); + BigDecimal[] bounds = PayTexts.weightBounds(weight); + band.setWeightMin(bounds[0]); + band.setWeightMax(bounds[1]); + if (feeHead) + { + band.setChargeName(shortCharge(parent[col])); + band.setZoneName(zoneOf(sub[col], parent[col])); + } + else + { + band.setChargeName(rowLabel.isBlank() ? "" : shortCharge(rowLabel)); + band.setZoneName(parent[col].isBlank() ? sub[col] : parent[col]); + } + band.setPrice(price); + band.setPriceUnit(unitOf(parent[col] + " " + rowLabel)); + band.setVolumeText(volume.isBlank() ? null : volume); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + if (!priced && joinRow(grid, data).length() > 40) + { + break; + } + data++; + } + return data; + } + + private int readWeightColumns(ExcelGrid grid, int header, String pending, PayProduct product) + { + List weightCols = new ArrayList<>(); + for (int col = 0; col < grid.cols(); col++) + { + if (PayTexts.looksLikeWeight(grid.text(header, col))) + { + weightCols.add(col); + } + } + int labelCol = 0; + for (int col = 0; col < grid.cols(); col++) + { + if (!weightCols.contains(col) && !grid.text(header, col).isBlank()) + { + labelCol = col; + break; + } + } + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row) && !isSurchargeHeader(grid, row) && !isPriceHeader(grid, row)) + { + int numbers = 0; + for (int col : weightCols) + { + if (priceOf(grid, row, col) != null) + { + numbers++; + } + } + String label = grid.text(row, labelCol); + if (numbers <= 1 && label.contains("费")) + { + break; + } + if (numbers == 0) + { + break; + } + for (int col : weightCols) + { + BigDecimal price = priceOf(grid, row, col); + if (price == null) + { + continue; + } + PayWeightBand band = new PayWeightBand(); + band.setSectionName(blankTo(cleanTitle(pending), "价格")); + band.setChargeName(label.isBlank() ? "" : shortCharge(label)); + band.setWeightLabel(grid.text(header, col)); + BigDecimal[] bounds = PayTexts.weightBounds(band.getWeightLabel()); + band.setWeightMin(bounds[0]); + band.setWeightMax(bounds[1]); + band.setPrice(price); + band.setPriceUnit(unitOf(grid.text(header, labelCol) + label)); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + row++; + } + return row; + } + + /** + * 表头「尾程(公斤)」下面两列是重量段起止,右边一列是每票价格。 + * 英国 RM 没有「0-5kg」原文,两列数字要拼成一个重量段。 + */ + private int readSplitWeightTable(ExcelGrid grid, int header, PayProduct product) + { + int weightCol = -1; + int priceCol = -1; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col); + String lower = text.toLowerCase(Locale.ROOT); + if (weightCol < 0 && (text.contains("公斤") || text.contains("重量") || text.contains("尾程") || lower.contains("kg"))) + { + weightCol = col; + } + if (text.contains("每件") || text.contains("每票") || text.contains("单价")) + { + priceCol = col; + } + } + if (weightCol < 0 || priceCol < 0) + { + return header + 1; + } + int maxCol = -1; + if (grid.colSpan(header, weightCol) >= 2 && weightCol + 1 < priceCol) + { + maxCol = weightCol + 1; + } + else + { + for (int col = weightCol + 1; col < priceCol; col++) + { + if (grid.text(header, col).isBlank()) + { + maxCol = col; + break; + } + } + } + String section = shortCharge(grid.text(header, weightCol)); + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row) && !isHardHeader(grid, row)) + { + BigDecimal price = priceOf(grid, row, priceCol); + String min = grid.text(row, weightCol).trim(); + String max = maxCol < 0 ? "" : grid.text(row, maxCol).trim(); + if (price == null || min.isBlank() || min.contains("费")) + { + break; + } + PayWeightBand band = new PayWeightBand(); + band.setSectionName(blankTo(section, "价格")); + band.setWeightLabel(max.isBlank() ? min : min + "-" + max); + BigDecimal[] bounds = PayTexts.weightBounds(band.getWeightLabel()); + band.setWeightMin(bounds[0]); + band.setWeightMax(bounds[1]); + band.setPrice(price); + band.setChargeName("每票"); + band.setPriceUnit(perTicketUnit(grid)); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + row++; + } + return row; + } + + /** + * 上一行是地区名称,本行每个地区一列「每件」。西邮西班牙主岛就是这种表。 + */ + private int readRegionWeightTable(ExcelGrid grid, int header, PayProduct product) + { + int areaRow = header - 1; + while (areaRow >= 0 && grid.blankRow(areaRow)) + { + areaRow--; + } + int weightCol = 0; + List priceCols = new ArrayList<>(); + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col); + String lower = text.toLowerCase(Locale.ROOT); + if (text.contains("重量") || text.contains("公斤") || lower.contains("weight") || lower.contains("kg")) + { + weightCol = col; + } + if (text.contains("每件") || text.contains("每票") || lower.contains("per item")) + { + priceCols.add(col); + } + } + String unit = pieceUnit(grid); + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row) && !isHardHeader(grid, row)) + { + String weight = grid.text(row, weightCol).replace('\n', ' ').trim(); + if (!PayTexts.looksLikeWeight(weight)) + { + break; + } + for (int col : priceCols) + { + BigDecimal price = priceOf(grid, row, col); + if (price == null) + { + continue; + } + PayWeightBand band = new PayWeightBand(); + band.setSectionName("重量段"); + band.setZoneName(zoneLabel(areaRow < 0 ? "" : grid.text(areaRow, col))); + band.setWeightLabel(weight); + BigDecimal[] bounds = PayTexts.weightBounds(weight); + band.setWeightMin(bounds[0]); + band.setWeightMax(bounds[1]); + band.setPrice(price); + band.setChargeName("每件"); + band.setPriceUnit(unit); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + row++; + } + return row; + } + + /** + * 西班牙 GLS:上一行是分区字母,本行用短横线把国家代码串在一起,说明里再写「德国(DE)」。 + * 每个国家各记一条价格,分区字母留在分区列。 + */ + private int readCountryZoneTable(ExcelGrid grid, int codeRow, PayProduct product) + { + int zoneRow = codeRow - 1; + while (zoneRow >= 0 && grid.blankRow(zoneRow)) + { + zoneRow--; + } + int header = codeRow + 1; + while (header < grid.rows() && !joinRow(grid, header).contains("重量")) + { + header++; + if (header > codeRow + 3) + { + return codeRow + 1; + } + } + Map names = countryNames(grid); + String unit = pieceUnit(grid); + List priceCols = new ArrayList<>(); + for (int col = 0; col < grid.cols(); col++) + { + if (!countryCodes(grid.text(codeRow, col)).isEmpty()) + { + priceCols.add(col); + } + } + BigDecimal previousMax = null; + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row)) + { + String line = joinRow(grid, row); + if (line.contains("说明") || line.contains("附加费") || line.contains("备注") || line.contains("禁运")) + { + break; + } + String label = ""; + for (int col = 0; col < grid.cols(); col++) + { + if (!priceCols.contains(col) && !grid.text(row, col).isBlank()) + { + label = grid.text(row, col).replace('\n', ' ').trim(); + break; + } + } + boolean extraKg = label.toLowerCase(Locale.ROOT).contains("ad") && label.toLowerCase(Locale.ROOT).contains("kg"); + BigDecimal maxKg = extraKg ? null : leadingKg(label); + if (!extraKg && maxKg == null) + { + break; + } + BigDecimal minKg = extraKg || previousMax == null ? BigDecimal.ZERO : previousMax; + for (int col : priceCols) + { + BigDecimal price = cellPrice(grid, row, col); + if (price == null) + { + continue; + } + String zone = zoneRow < 0 ? "" : grid.text(zoneRow, col).trim(); + for (String code : namedCodes(countryCodes(grid.text(codeRow, col)), names)) + { + PayWeightBand band = new PayWeightBand(); + band.setSectionName("重量段"); + band.setZoneName(zone); + band.setCountryCode(code); + band.setCountryName(names.getOrDefault(code, code)); + band.setWeightLabel(label); + if (!extraKg) + { + band.setWeightMin(minKg); + band.setWeightMax(maxKg); + band.setChargeName("每件"); + band.setPriceUnit(unit); + } + else + { + band.setChargeName("每公斤"); + band.setPriceUnit(unit.startsWith("EUR") ? "EUR/公斤" : unit.startsWith("GBP") ? "GBP/公斤" : "每公斤"); + } + band.setPrice(price); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + } + if (!extraKg) + { + previousMax = maxKg; + } + row++; + } + return row; + } + + /** + * Additional Billable Concepts:表头右侧是地区。每个地区有价格时按地区各记一条; + * 右侧只有一句说明时只记一条,例如 Pudo。 + */ + private int readBillableConcepts(ExcelGrid grid, int header, PayProduct product) + { + String section = ""; + List zoneCols = new ArrayList<>(); + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col).replace('\n', ' ').trim(); + if (text.isBlank()) + { + continue; + } + if (text.toLowerCase(Locale.ROOT).contains("additional billable")) + { + section = text; + } + else + { + zoneCols.add(col); + } + } + if (section.isBlank()) + { + section = "Additional Billable Concepts"; + } + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row)) + { + String line = joinRow(grid, row); + if (line.contains("备注") || line.contains("禁运") || line.contains("条款")) + { + break; + } + String name = ""; + for (int col = 0; col < grid.cols(); col++) + { + if (zoneCols.contains(col)) + { + continue; + } + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (!text.isBlank()) + { + name = text; + break; + } + } + if (name.isBlank() || name.contains("备注")) + { + break; + } + List zones = new ArrayList<>(); + List prices = new ArrayList<>(); + String sentence = ""; + for (int col : zoneCols) + { + BigDecimal price = cellPrice(grid, row, col); + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (price != null && text.matches("\\d+(?:\\.\\d+)?")) + { + zones.add(grid.text(header, col).replace('\n', ' ').trim()); + prices.add(price); + } + else if (sentence.isBlank() && !text.isBlank()) + { + sentence = text; + } + } + if (!prices.isEmpty()) + { + for (int i = 0; i < prices.size(); i++) + { + addSurcharge(product, section, name, zones.get(i), prices.get(i).toPlainString() + " EUR", "", ""); + markManual(product); + } + } + else if (!sentence.isBlank()) + { + addSurcharge(product, section, name, "", sentence, sentence, ""); + markManual(product); + keepSingleEuro(product.getSurcharges().get(product.getSurcharges().size() - 1), sentence); + } + row++; + } + return row; + } + + /** 说明句里只有一个欧元金额时保留这个价格。百分号、体积规则不当成收费金额。 */ + private static void keepSingleEuro(PaySurcharge fee, String sentence) + { + fee.setChargeText(sentence); + fee.setFeeDesc(sentence); + if (sentence.contains("%")) + { + fee.setAmount(null); + fee.setCurrency(""); + return; + } + Matcher matcher = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*欧元").matcher(sentence); + String amount = ""; + int hits = 0; + while (matcher.find()) + { + hits++; + amount = matcher.group(1); + } + if (hits == 1) + { + fee.setAmount(new BigDecimal(amount)); + fee.setCurrency("EUR"); + return; + } + fee.setAmount(null); + fee.setCurrency(""); + } + + private static void markManual(PayProduct product) + { + PaySurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1); + fee.setCalcRule(PaySurchargeRules.MANUAL); + fee.setCalcParam(""); + } + + private static boolean isBillableConceptHeader(ExcelGrid grid, int row) + { + return joinRow(grid, row).toLowerCase(Locale.ROOT).contains("additional billable concepts"); + } + + /** + * 岛屿价卡一张表有两级表头:上面是群岛,下面是具体岛屿。 + * Up to 1 kg 是首重,Additional Kg 是续重,Cubic Capacity 是体积重系数,不是运费。 + */ + private int readInsularRates(ExcelGrid grid, int header, PayProduct product) + { + int groupRow = header - 1; + while (groupRow >= 0 && grid.blankRow(groupRow)) + { + groupRow--; + } + String service = grid.text(header, 0).replace('\n', ' ').trim(); + String title = insularTitle(grid, header); + String section = title.isBlank() ? service : title + " / " + service; + product.setCurrency("EUR"); + List zoneCols = new ArrayList<>(); + for (int col = 1; col < grid.cols(); col++) + { + if (!grid.text(header, col).isBlank() && cellPrice(grid, header, col) == null) + { + zoneCols.add(col); + } + } + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row)) + { + String label = grid.text(row, 0).replace('\n', ' ').trim(); + String lower = label.toLowerCase(Locale.ROOT); + boolean firstKg = lower.contains("up to"); + boolean extraKg = lower.contains("additional"); + boolean cubic = lower.contains("cubic"); + if (!firstKg && !extraKg && !cubic) + { + break; + } + if (cubic) + { + addCubicCapacity(grid, row, header, groupRow, title, section, zoneCols, product); + row++; + continue; + } + for (int col : zoneCols) + { + String child = grid.text(header, col).replace('\n', ' ').trim(); + String parent = groupLabel(grid, groupRow, col, title); + String zone = parent.isBlank() ? child : parent + " / " + child; + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (text.isBlank()) + { + continue; + } + BigDecimal price = cellPrice(grid, row, col); + if (price == null) + { + continue; + } + PayWeightBand band = new PayWeightBand(); + band.setSectionName(section); + band.setZoneName(zone); + band.setWeightLabel(label); + band.setPrice(price); + if (extraKg) + { + band.setChargeName("每公斤"); + band.setPriceUnit("EUR/公斤"); + } + else + { + band.setWeightMin(BigDecimal.ZERO); + band.setWeightMax(firstKgLimit(label)); + band.setChargeName("每件"); + band.setPriceUnit("EUR/件"); + } + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + row++; + } + return row; + } + + /** + * 西班牙 CTT:上面一行是大陆或群岛,下一行是密度和岛屿。1kg 到 5kg 是该重量的票价,Kg.ad. 是超出后的续重。 + */ + private int readKgStepTable(ExcelGrid grid, int header, PayProduct product) + { + int groupRow = header - 1; + while (groupRow >= 0 && grid.blankRow(groupRow)) + { + groupRow--; + } + String service = grid.text(header, 0).replace('\n', ' ').trim(); + product.setCurrency("EUR"); + List zoneCols = new ArrayList<>(); + for (int col = 1; col < grid.cols(); col++) + { + if (!grid.text(header, col).isBlank() && cellPrice(grid, header, col) == null) + { + zoneCols.add(col); + } + } + BigDecimal previousMax = null; + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row)) + { + String label = grid.text(row, 0).replace('\n', ' ').trim(); + String compact = label.toLowerCase(Locale.ROOT).replace(" ", ""); + boolean extra = compact.contains("kg.ad") || compact.contains("ad.kg") || compact.contains("additionalkg"); + BigDecimal maxKg = extra ? null : leadingKg(label); + if (!extra && maxKg == null) + { + break; + } + BigDecimal minKg = extra || previousMax == null ? BigDecimal.ZERO : previousMax; + for (int col : zoneCols) + { + BigDecimal price = cellPrice(grid, row, col); + if (price == null) + { + continue; + } + String child = grid.text(header, col).replace('\n', ' ').trim(); + String parent = groupLabel(grid, groupRow, col, service); + PayWeightBand band = new PayWeightBand(); + band.setSectionName(service); + band.setZoneName(parent.isBlank() ? child : parent + " / " + child); + band.setWeightLabel(label); + band.setPrice(price); + if (extra) + { + band.setChargeName("每公斤"); + band.setPriceUnit("EUR/公斤"); + } + else + { + band.setWeightMin(minKg); + band.setWeightMax(maxKg); + band.setChargeName("每件"); + band.setPriceUnit("EUR/件"); + } + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + if (!extra) + { + previousMax = maxKg; + } + row++; + } + return row; + } + + private static boolean isKgStepHeader(ExcelGrid grid, int row) + { + int names = 0; + for (int col = 1; col < grid.cols(); col++) + { + String text = grid.text(row, col).trim(); + if (!text.isBlank() && cellPrice(grid, row, col) == null && text.length() <= 40) + { + names++; + } + } + if (names < 3) + { + return false; + } + int next = row + 1; + while (next < grid.rows() && grid.blankRow(next)) + { + next++; + } + if (next >= grid.rows()) + { + return false; + } + return leadingKg(grid.text(next, 0)) != null; + } + + /** 体积重系数是这一行的属性,同一张表只记一条,地区写在说明里。 */ + private void addCubicCapacity(ExcelGrid grid, int row, int header, int groupRow, String title, String section, + List zoneCols, PayProduct product) + { + Map> factors = new LinkedHashMap<>(); + for (int col : zoneCols) + { + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (text.isBlank()) + { + continue; + } + String child = grid.text(header, col).replace('\n', ' ').trim(); + String parent = groupLabel(grid, groupRow, col, title); + String zone = parent.isBlank() ? child : parent + " / " + child; + factors.computeIfAbsent(text, key -> new ArrayList<>()).add(zone); + } + if (factors.isEmpty()) + { + return; + } + StringBuilder desc = new StringBuilder(); + for (Map.Entry> entry : factors.entrySet()) + { + if (desc.length() > 0) + { + desc.append(";"); + } + desc.append(String.join("、", entry.getValue())).append(" ").append(entry.getKey()); + } + addSurcharge(product, section, "Cubic Capacity", "", String.join("、", factors.keySet()), desc.toString(), ""); + PaySurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1); + fee.setChargeText(String.join("、", factors.keySet())); + fee.setAmount(null); + fee.setCurrency(""); + markManual(product); + } + + private static boolean isInsularServiceHeader(ExcelGrid grid, int row) + { + String first = grid.text(row, 0).trim().toUpperCase(Locale.ROOT); + boolean service = "STANDARD".equals(first) || "EXPRESS".equals(first) || first.contains("WITHOUT INYECTION") + || first.contains("WITHOUT INJECTION"); + if (!service) + { + return false; + } + int names = 0; + for (int col = 1; col < grid.cols(); col++) + { + String text = grid.text(row, col).trim(); + if (!text.isBlank() && cellPrice(grid, row, col) == null && text.length() <= 40) + { + names++; + } + } + if (names < 2) + { + return false; + } + int next = row + 1; + while (next < grid.rows() && grid.blankRow(next)) + { + next++; + } + return next < grid.rows() && grid.text(next, 0).toLowerCase(Locale.ROOT).contains("up to"); + } + + private static String insularTitle(ExcelGrid grid, int header) + { + for (int row = header - 1; row >= 0; row--) + { + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).replace('\n', ' ').trim(); + String upper = text.toUpperCase(Locale.ROOT); + if (upper.contains("INSULAR RATES") || upper.contains("CUSTOMS CLEARANCE") || upper.contains("CEUTA")) + { + return text; + } + } + } + return ""; + } + + /** 合并单元格里的群岛名称覆盖到右侧岛屿列。标题行本身不当成群岛。 */ + private static String groupLabel(ExcelGrid grid, int groupRow, int col, String title) + { + if (groupRow < 0) + { + return ""; + } + String text = ""; + if (!grid.text(groupRow, col).isBlank()) + { + text = grid.text(groupRow, col).replace('\n', ' ').trim(); + } + else + { + for (int left = col - 1; left >= 0; left--) + { + if (grid.colSpan(groupRow, left) > 1 && left + grid.colSpan(groupRow, left) - 1 >= col) + { + text = grid.text(groupRow, left).replace('\n', ' ').trim(); + break; + } + } + } + if (text.isBlank() || text.equals(title)) + { + return ""; + } + String upper = text.toUpperCase(Locale.ROOT); + if (upper.contains("INSULAR RATES") || upper.contains("CUSTOMS CLEARANCE") || upper.contains("CEUTA")) + { + return ""; + } + return text; + } + + private static BigDecimal firstKgLimit(String label) + { + Matcher matcher = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)").matcher(label == null ? "" : label); + if (!matcher.find()) + { + return BigDecimal.ONE; + } + return new BigDecimal(matcher.group(1)); + } + + /** 文件名或产品名里带国家时,只补空白的国家,不覆盖表里已经写明的目的国。 */ + private static void applyFileCountry(PayProduct product) + { + String[] country = countryInName(product.getProductName(), product.getSheetName(), product.getSourceFile()); + if (country == null) + { + return; + } + if (product.getCountryScope() == null || product.getCountryScope().isBlank()) + { + product.setCountryScope(country[0]); + } + if (product.getWeightBands() == null) + { + return; + } + for (PayWeightBand band : product.getWeightBands()) + { + if (band.getCountryName() == null || band.getCountryName().isBlank()) + { + band.setCountryName(country[0]); + if (band.getCountryCode() == null || band.getCountryCode().isBlank()) + { + band.setCountryCode(country[1]); + } + } + } + } + + private static String[] countryInName(String... texts) + { + String[][] countries = { + { "西班牙", "ES" }, { "英国", "GB" }, { "德国", "DE" }, { "法国", "FR" }, { "意大利", "IT" }, + { "荷兰", "NL" }, { "比利时", "BE" }, { "葡萄牙", "PT" }, { "波兰", "PL" }, { "奥地利", "AT" }, + { "瑞士", "CH" }, { "瑞典", "SE" }, { "丹麦", "DK" }, { "芬兰", "FI" }, { "爱尔兰", "IE" }, + { "希腊", "GR" }, { "匈牙利", "HU" }, { "捷克", "CZ" }, { "罗马尼亚", "RO" }, { "卢森堡", "LU" } + }; + String[][] codes = { + { "DE-", "德国", "DE" }, { "IT-", "意大利", "IT" }, { "NL-", "荷兰", "NL" }, { "FR-", "法国", "FR" }, + { "ES-", "西班牙", "ES" }, { "UK-", "英国", "GB" }, { "GB-", "英国", "GB" } + }; + String foundName = ""; + String foundCode = ""; + int foundAt = Integer.MAX_VALUE; + for (String text : texts) + { + if (text == null || text.isBlank()) + { + continue; + } + String name = text.replaceAll("(?i)\\.xlsx$", "").trim(); + String upper = name.toUpperCase(Locale.ROOT); + for (String[] code : codes) + { + if (upper.startsWith(code[0]) && 0 < foundAt) + { + foundAt = 0; + foundName = code[1]; + foundCode = code[2]; + } + } + for (String[] country : countries) + { + int at = name.indexOf(country[0]); + if (at >= 0 && (at < foundAt || (at == foundAt && country[0].length() > foundName.length()))) + { + foundAt = at; + foundName = country[0]; + foundCode = country[1]; + } + } + } + return foundName.isBlank() ? null : new String[] { foundName, foundCode }; + } + + /** + * 续重:有超出后的每公斤价格。常规计重:同一产品既有每公斤运费,又有处理费或挂号费。其余按重量段收一票的价格。 + */ + private static String chargeTypeOf(PayProduct product) + { + if (product.getWeightBands() == null || product.getWeightBands().isEmpty()) + { + return ""; + } + boolean extraKg = false; + boolean perKg = false; + boolean itemFee = false; + for (PayWeightBand band : product.getWeightBands()) + { + String unit = band.getPriceUnit() == null ? "" : band.getPriceUnit().toUpperCase(Locale.ROOT); + if (continuedKg(band)) + { + extraKg = true; + } + if (unit.contains("KG") || unit.contains("公斤")) + { + perKg = true; + } + String charge = band.getChargeName() == null ? "" : band.getChargeName(); + if (charge.contains("处理费") || charge.contains("挂号")) + { + itemFee = true; + } + } + if (extraKg) + { + return "续重计费"; + } + if (perKg && itemFee) + { + return "常规计重价"; + } + return "单票价"; + } + + /** + * 续重产品的每公斤加价。各分区金额相同则返回该金额,不同则返回最小~最大。 + */ + public static String extraPerKgOf(PayProduct product) + { + if (product == null || product.getWeightBands() == null) + { + return null; + } + BigDecimal low = null; + BigDecimal high = null; + for (PayWeightBand band : product.getWeightBands()) + { + if (band == null || band.getPrice() == null || !continuedKg(band)) + { + continue; + } + if (low == null || band.getPrice().compareTo(low) < 0) + { + low = band.getPrice(); + } + if (high == null || band.getPrice().compareTo(high) > 0) + { + high = band.getPrice(); + } + } + if (low == null) + { + return null; + } + String lowText = low.stripTrailingZeros().toPlainString(); + if (low.compareTo(high) == 0) + { + return lowText; + } + return lowText + "~" + high.stripTrailingZeros().toPlainString(); + } + + private static boolean continuedKg(PayWeightBand band) + { + String label = ((band.getWeightLabel() == null ? "" : band.getWeightLabel()) + " " + + (band.getChargeName() == null ? "" : band.getChargeName())).toLowerCase(Locale.ROOT).replace(" ", ""); + return label.contains("kg.ad") || label.contains("ad.kg") || label.contains("additionalkg") || label.contains("续重") + || "每公斤".equals(band.getChargeName()); + } + + /** + * 「其他附加费」下面是类型、服务说明和价格。备注段落不是表格,不收成附加费。 + */ + private int readServicePrices(ExcelGrid grid, int header, PayProduct product) + { + int nameCol = -1; + int priceCol = -1; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col).trim(); + if (nameCol < 0 && (text.contains("类型") || text.contains("服务") || text.contains("项目"))) + { + nameCol = col; + } + if (text.contains("价格") || text.contains("收费")) + { + priceCol = col; + } + } + if (nameCol < 0) + { + nameCol = 0; + } + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row)) + { + String line = joinRow(grid, row); + if (line.contains("备注") || line.contains("说明") || line.contains("禁运") || line.contains("条款") || line.contains("免责")) + { + return skipNotes(grid, row); + } + String name = grid.text(row, nameCol).replace('\n', ' ').trim(); + String priceText = ""; + if (priceCol >= 0) + { + priceText = grid.text(row, priceCol).replace('\n', ' ').trim(); + } + if (priceText.isBlank()) + { + for (int col = grid.cols() - 1; col > nameCol; col--) + { + String text = grid.text(row, col).trim(); + if (!text.isBlank()) + { + priceText = text; + break; + } + } + } + String desc = ""; + for (int col = 0; col < grid.cols(); col++) + { + if (col == nameCol || col == priceCol) + { + continue; + } + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (text.length() > desc.length()) + { + desc = text; + } + } + String charge = serviceCharge(priceText); + if (name.isBlank() || charge.isEmpty() || name.contains("类型") || name.contains("价格")) + { + break; + } + addSurcharge(product, "其他附加费", name, "", charge, desc, ""); + if ("free".equalsIgnoreCase(priceText.trim()) || priceText.contains("免费")) + { + PaySurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1); + fee.setChargeText(priceText.trim()); + fee.setAmount(BigDecimal.ZERO); + fee.setCurrency(fee.getCurrency() == null || fee.getCurrency().isBlank() ? "EUR" : fee.getCurrency()); + } + row++; + } + return row; + } + + /** 备注、说明后面的句子不再当成费用,直到下一张真正的表。 */ + private static int skipNotes(ExcelGrid grid, int row) + { + while (row < grid.rows()) + { + if (!grid.blankRow(row) && (isHardHeader(grid, row) || isTypePriceRow(grid, row) || countryZoneCodeRow(grid, row) >= 0)) + { + return row; + } + row++; + } + return row; + } + + /** 分区字母行的下一行是 DE-BE-NL 这种国家代码,并且随后写着重量段。 */ + private static int countryZoneCodeRow(ExcelGrid grid, int row) + { + int codeRow = -1; + if (isZoneLetterRow(grid, row)) + { + int next = row + 1; + if (next < grid.rows() && codeGroupCount(grid, next) >= 2) + { + codeRow = next; + } + } + else if (row > 0 && isZoneLetterRow(grid, row - 1) && codeGroupCount(grid, row) >= 2) + { + codeRow = row; + } + if (codeRow < 0) + { + return -1; + } + int end = Math.min(grid.rows(), codeRow + 4); + for (int next = codeRow + 1; next < end; next++) + { + if (joinRow(grid, next).contains("重量")) + { + return codeRow; + } + } + return -1; + } + + private static int servicePriceHeader(ExcelGrid grid, int row) + { + if (isTypePriceRow(grid, row)) + { + return isSurchargeHeader(grid, row) ? -1 : row; + } + String line = joinRow(grid, row); + if (line.length() > 40 || line.contains("。") || (!line.contains("附加费") && !line.contains("其他费用"))) + { + return -1; + } + for (int next = row + 1; next < grid.rows() && next <= row + 3; next++) + { + if (grid.blankRow(next)) + { + continue; + } + // 收费项、收费标准、费用说明是附加费表,不是「类型 + 价格」那种服务价 + return isTypePriceRow(grid, next) && !isSurchargeHeader(grid, next) ? next : -1; + } + return -1; + } + + private static boolean isZoneLetterRow(ExcelGrid grid, int row) + { + boolean zone = false; + int letters = 0; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).trim(); + if (text.equalsIgnoreCase("zone")) + { + zone = true; + } + else if (text.matches("(?i)[A-H]")) + { + letters++; + } + } + return zone && letters >= 2; + } + + private static boolean isTypePriceRow(ExcelGrid grid, int row) + { + boolean kind = false; + boolean price = false; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).trim(); + if (text.contains("类型") || text.equals("服务") || text.contains("收费项")) + { + kind = true; + } + if (text.contains("价格") || text.contains("收费标准")) + { + price = true; + } + } + return kind && price; + } + + private static int codeGroupCount(ExcelGrid grid, int row) + { + int count = 0; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + if ((text.contains("-") || text.contains("⁻") || text.contains("-")) && countryCodes(text).size() >= 2) + { + count++; + } + } + return count; + } + + /** 表头上的脚注数字去掉,但说明里单独写明的 FR2 这类代码保留。 */ + private static List namedCodes(List rawCodes, Map names) + { + List codes = new ArrayList<>(); + for (String raw : rawCodes) + { + String code = names.containsKey(raw) ? raw : raw.replaceAll("\\d+$", ""); + if (!names.containsKey(code)) + { + code = raw; + } + if (!codes.contains(code)) + { + codes.add(code); + } + } + return codes; + } + + private static List countryCodes(String text) + { + List codes = new ArrayList<>(); + if (text == null || text.isBlank()) + { + return codes; + } + String folded = foldMarks(text).replace('\n', ' '); + for (String part : folded.split("[-\\s]+")) + { + String code = part.replaceAll("[^A-Za-z0-9]", "").toUpperCase(Locale.ROOT); + if (code.matches("[A-Z]{2}\\d*")) + { + codes.add(code); + } + } + return codes; + } + + /** 说明里的「德国(DE)」「英国-GB(威尔士…)」对应表头拆出来的代码。 */ + private static Map countryNames(ExcelGrid grid) + { + Map names = new LinkedHashMap<>(); + Pattern wrapped = Pattern.compile("([\\u4e00-\\u9fff][\\u4e00-\\u9fff0-9\\-]{0,24}?)\\s*\\d*(?:\\s*-\\s*\\d+)*\\s*[((]\\s*([A-Z]{2}\\d?)\\s*[))]"); + Pattern ahead = Pattern.compile("([\\u4e00-\\u9fff]{2,12})\\s*\\d*(?:\\s*-\\s*\\d+)*\\s*-\\s*([A-Z]{2})\\s*[((]"); + for (int row = 0; row < grid.rows(); row++) + { + for (int col = 0; col < grid.cols(); col++) + { + String text = foldMarks(grid.text(row, col)); + if (!text.contains("(") && !text.contains("(")) + { + continue; + } + Matcher matcher = wrapped.matcher(text); + while (matcher.find()) + { + names.putIfAbsent(matcher.group(2), matcher.group(1).replaceAll("[-\\s]+$", "")); + } + Matcher codeAhead = ahead.matcher(text); + while (codeAhead.find()) + { + names.putIfAbsent(codeAhead.group(2), codeAhead.group(1)); + } + } + } + return names; + } + + private static String foldMarks(String text) + { + if (text == null) + { + return ""; + } + return text.replace('²', '2').replace('³', '3').replace('⁴', '4').replace('⁵', '5') + .replace('⁶', '6').replace('⁷', '7').replace('⁸', '8').replace('⁹', '9') + .replace('⁻', '-').replace('-', '-'); + } + + private static BigDecimal leadingKg(String label) + { + Matcher matcher = Pattern.compile("(?i)^\\s*(\\d+(?:\\.\\d+)?)\\s*(?:kg|公斤)\\s*$").matcher(label == null ? "" : label); + if (!matcher.find()) + { + return null; + } + return new BigDecimal(matcher.group(1)); + } + + private static BigDecimal cellPrice(ExcelGrid grid, int row, int col) + { + BigDecimal price = priceOf(grid, row, col); + if (price != null) + { + return price; + } + String compact = grid.text(row, col).replace(" ", ""); + if (compact.matches("\\d+\\.\\d+")) + { + return new BigDecimal(compact); + } + return null; + } + + private static String serviceCharge(String text) + { + if (text == null || text.isBlank()) + { + return ""; + } + String value = text.trim(); + if (value.equalsIgnoreCase("free") || value.contains("免费")) + { + return "0 EUR"; + } + Matcher euro = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*€").matcher(value); + if (euro.find()) + { + return euro.group(1) + " EUR"; + } + Matcher amount = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)\\s*(欧元|英镑|EUR|GBP)").matcher(value); + if (amount.find()) + { + return amount.group(1) + " " + feeCurrency(amount.group(2)); + } + return ""; + } + + /** + * 附加费标题下面不一定是标准表。一句写明金额的说明,或「条件 + 费率」两列,都要收成附加费。 + */ + private int readFeeSection(ExcelGrid grid, int start, PayProduct product) + { + String feeName = ""; + int row = start + 1; + while (row < grid.rows() && !grid.blankRow(row)) + { + String line = joinRow(grid, row); + String lower = line.toLowerCase(Locale.ROOT); + if (line.contains("禁运") || line.contains("条款") || line.contains("免责") || lower.contains("terms and conditions") + || lower.contains("disclaimer") || isHardHeader(grid, row) || isRegionWeightHeader(grid, row) + || remoteFeeHeader(grid, row) >= 0) + { + break; + } + if (isFeeSectionTitle(grid, row)) + { + feeName = chineseTitle(line); + row++; + continue; + } + String rate = ""; + String desc = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).trim(); + if (text.isEmpty()) + { + continue; + } + if (rate.isEmpty() && looksLikeRate(text) && text.length() <= 80) + { + rate = text; + } + else if (text.length() > desc.length()) + { + desc = text; + } + } + if (!rate.isEmpty() && !desc.isEmpty()) + { + addSurcharge(product, "附加费", blankTo(feeName, "附加费"), "", rateLine(rate), desc, ""); + row++; + continue; + } + String amount = feeAmount(line); + if (amount != null && (line.contains("费") || lower.contains("surcharge") || line.contains("标签"))) + { + addSurcharge(product, "附加费", sentenceFeeName(line), "", amount, line, ""); + row++; + continue; + } + break; + } + return row; + } + + /** + * 偏远地区附加费:下一行表头是地区、邮编,价格写在左侧,合并单元格要沿用到下一行。 + */ + private int readRemoteFeeTable(ExcelGrid grid, int header, PayProduct product) + { + int regionCol = -1; + int postcodeCol = -1; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col); + if (regionCol < 0 && text.contains("地区")) + { + regionCol = col; + } + else if (postcodeCol < 0 && text.contains("邮编")) + { + postcodeCol = col; + } + } + String carriedPrice = ""; + String carriedArea = ""; + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row) && !isSurchargeHeader(grid, row) && !isPriceHeader(grid, row)) + { + String region = regionCol < 0 ? "" : grid.text(row, regionCol).replace('\n', ' ').trim(); + String postcode = postcodeCol < 0 ? "" : grid.text(row, postcodeCol).replace('\n', ' ').trim(); + if (region.isBlank() && postcode.isBlank()) + { + break; + } + String priceCell = ""; + int end = regionCol < 0 ? grid.cols() : regionCol; + for (int col = 0; col < end; col++) + { + String text = grid.text(row, col).trim(); + if (!text.isBlank()) + { + priceCell = text; + } + } + if (!priceCell.isBlank()) + { + carriedPrice = priceCell; + String area = areaLabel(priceCell); + if (!area.isBlank()) + { + carriedArea = area; + } + } + String desc = region; + if (!postcode.isBlank()) + { + desc = desc.isBlank() ? postcode : desc + ";" + postcode; + } + if (!carriedArea.isBlank()) + { + desc = carriedArea + ";" + desc; + } + addSurcharge(product, "偏远地区附加费", "偏远地区附加费", "", feePriceText(carriedPrice), desc, ""); + row++; + } + return row; + } + + /** 德国 UPS 这种分区表:表头从左到右是 Zone,下一行国家,再下一行每个分区一个价格。 */ + private int readZonePrices(ExcelGrid grid, int header, PayProduct product) + { + int row = header + 1; + while (row < grid.rows() && grid.blankRow(row)) + { + row++; + } + int countryRow = -1; + if (row < grid.rows() && countNumbers(grid, row) == 0 && countText(grid, row) >= 2) + { + countryRow = row; + row++; + } + while (row < grid.rows() && grid.blankRow(row)) + { + row++; + } + if (row >= grid.rows() || countNumbers(grid, row) == 0) + { + return row; + } + String charge = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + if (!text.isBlank() && grid.number(row, col) == null && !isMoneyText(text)) + { + charge = shortCharge(text); + break; + } + } + for (int col = 0; col < grid.cols(); col++) + { + BigDecimal price = priceOf(grid, row, col); + if (price == null) + { + continue; + } + PayWeightBand band = new PayWeightBand(); + band.setSectionName("分区报价"); + String country = countryRow < 0 ? "" : grid.text(countryRow, col); + band.setCountryName(country); + band.setCountryCode(zoneCodes(country)); + band.setZoneName(grid.text(header, col)); + band.setChargeName(charge); + band.setPrice(price); + band.setPriceUnit(unitOf(charge + " EUR")); + band.setSortNo(product.getWeightBands().size() + 1); + product.getWeightBands().add(band); + } + return row + 1; + } + + private int readSurchargeTable(ExcelGrid grid, int header, String pending, PayProduct product, StringBuilder note) + { + int nameCol = -1; + int standardCol = -1; + int descCol = -1; + int remarkCol = -1; + int peakCol = -1; + int fuelCol = -1; + String peakHeader = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col); + if (text.contains("收费标准")) + { + standardCol = col; + } + else if (standardCol < 0 && text.contains("收费") && !text.contains("收费项") && !text.contains("收费项目") + && !text.contains("说明") && !text.contains("解释")) + { + standardCol = col; + } + else if (text.contains("高峰")) + { + peakCol = col; + peakHeader = text; + } + else if (text.contains("燃油")) + { + fuelCol = col; + } + else if (text.contains("费用说明") || text.contains("费用解释") || text.contains("收费说明") || text.contains("说明")) + { + descCol = col; + } + else if (text.contains("备注")) + { + remarkCol = col; + } + else if (nameCol < 0 && isFeeNameHead(text)) + { + nameCol = col; + } + } + List nameCols = new ArrayList<>(); + List zoneCols = new ArrayList<>(); + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(header, col).trim(); + if (text.isEmpty()) + { + continue; + } + if (isFeeNameHead(text)) + { + nameCols.add(col); + } + else if (col != standardCol && col != descCol && col != remarkCol && col != peakCol && col != fuelCol) + { + zoneCols.add(col); + } + } + // 其他费用这种表:收费项后面每一列是一个地区,不是单一的收费标准 + if (standardCol < 0 && peakCol < 0 && zoneCols.size() >= 2) + { + return readRegionSurcharges(grid, header, nameCols, zoneCols, descCol, remarkCol, pending, product); + } + int row = header + 1; + // 费用说明里也会出现“处理费、重量”,不能把正文当成新的价格表头而整表跳过 + while (row < grid.rows() && !grid.blankRow(row) && !isWeightColumnHeader(grid, row)) + { + String name = nameCol >= 0 ? firstLine(grid.text(row, nameCol)) : firstLine(firstText(grid, row)); + if (name.startsWith("服务说明") || name.startsWith("报价已含燃油")) + { + appendNote(note, joinRow(grid, row)); + row++; + break; + } + String standard = standardCol >= 0 ? grid.text(row, standardCol) : ""; + String desc = descText(grid, header, row, descCol); + String remark = remarkCol >= 0 ? grid.text(row, remarkCol) : ""; + String fuel = fuelCol >= 0 ? grid.text(row, fuelCol).trim() : ""; + if (!fuel.isEmpty()) + { + remark = (remark + " 是否加收燃油:" + fuel).trim(); + } + String peak = peakCol >= 0 ? grid.text(row, peakCol).trim() : ""; + if (standard.isBlank() && peak.isBlank()) + { + break; + } + if (!name.isBlank()) + { + addSurcharge(product, cleanTitle(pending), name, "", standard, desc, remark); + } + if (!peak.isEmpty()) + { + addSurcharge(product, "高峰附加费", name, "", peak, peakHeader, remark); + } + row++; + } + return row; + } + + /** 表头从左到右是地区,每个地区一列价格。横向合并的价格抄到覆盖的每个地区,纵向合并的费用名沿用到下一行。 */ + private int readRegionSurcharges(ExcelGrid grid, int header, List nameCols, List zoneCols, + int descCol, int remarkCol, String pending, PayProduct product) + { + int firstZone = zoneCols.get(0); + String carried = ""; + String section = cleanTitle(pending); + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row) && !isWeightColumnHeader(grid, row) && !isPriceHeader(grid, row)) + { + String original = ""; + String named = ""; + for (int col : nameCols) + { + String text = firstLine(grid.text(row, col)); + if (text.isBlank()) + { + continue; + } + if (original.isBlank()) + { + original = text; + } + named = text; + } + if (named.isBlank()) + { + named = carried; + } + else + { + carried = named; + } + if (named.startsWith("服务说明") || named.contains("报价已含") || named.startsWith("以上报价")) + { + break; + } + boolean priced = false; + for (int col : zoneCols) + { + if (!zonePrice(grid, row, col, firstZone).isBlank()) + { + priced = true; + break; + } + } + if (!priced) + { + break; + } + String desc = descCol < 0 ? "" : grid.text(row, descCol).trim(); + String remark = remarkCol < 0 ? "" : grid.text(row, remarkCol).trim(); + if (!original.isBlank() && !named.isBlank() && !original.equals(named)) + { + desc = desc.isBlank() ? original : original + ";" + desc; + } + String feeName = shortCharge(named); + for (int col : zoneCols) + { + String standard = zonePrice(grid, row, col, firstZone); + if (standard.isBlank()) + { + continue; + } + addSurcharge(product, blankTo(section, "其他费用"), feeName, zoneLabel(grid.text(header, col)), standard, desc, remark); + } + row++; + } + return row; + } + + private static boolean isFeeNameHead(String text) + { + return text.contains("费用类型") || text.contains("收费项") || text.contains("附加费") || text.contains("收费项目") + || text.contains("类目") || text.contains("费用名称"); + } + + /** 地区列被横向合并时,价格只写在左上角,后面的地区沿用这块价格。 */ + private static String zonePrice(ExcelGrid grid, int row, int col, int firstZone) + { + String text = grid.text(row, col).trim(); + if (!text.isBlank()) + { + return text; + } + for (int left = col - 1; left >= firstZone; left--) + { + int span = grid.colSpan(row, left); + if (span > 1 && left + span - 1 >= col) + { + return grid.text(row, left).trim(); + } + } + return ""; + } + + private static String zoneLabel(String text) + { + if (text == null) + { + return ""; + } + return text.replace('\n', ' ').replaceAll("\\s+", " ").trim(); + } + + private int readSurchargeMatrix(ExcelGrid grid, int header, String pending, PayProduct product) + { + int row = header + 1; + while (row < grid.rows() && !grid.blankRow(row) && !isHardHeader(grid, row)) + { + String name = ""; + for (int col = 0; col < Math.min(3, grid.cols()); col++) + { + String text = grid.text(row, col); + if (!text.isBlank() && !isMoneyText(text)) + { + name = text; + break; + } + } + boolean any = false; + for (int col = 0; col < grid.cols(); col++) + { + if (col < 2 && grid.text(header, col).contains("收费")) + { + continue; + } + BigDecimal price = priceOf(grid, row, col); + if (price == null) + { + continue; + } + any = true; + // 金额横向合并时只记一次,不按每个分区再复制一条 + String zone = grid.colSpan(row, col) > 1 ? "" : grid.text(header, col); + addSurcharge(product, blankTo(cleanTitle(pending), "附加费"), name.isBlank() ? "" : shortCharge(name), + zone, grid.text(row, col), "", ""); + } + if (!any && joinRow(grid, row).length() > 40) + { + break; + } + row++; + } + return row; + } + + private int readLines(ExcelGrid grid, int start, String lineType, PayProduct product) + { + int row = start; + if (!grid.blankRow(row) && (isPostcodeHeader(grid, row) || countNumbers(grid, row) == 0)) + { + row++; + } + int kept = 0; + while (row < grid.rows()) + { + if (grid.blankRow(row)) + { + row++; + if (kept > 0) + { + break; + } + continue; + } + if (kept >= MAX_EXTRA) + { + product.setRemark("邮编或配置超过" + MAX_EXTRA + "行,已保留前" + MAX_EXTRA + "行"); + break; + } + PayExtraLine line = new PayExtraLine(); + line.setLineType(lineType); + line.setLineText(cut(new StringBuilder(joinRow(grid, row)), 1000)); + line.setSortNo(product.getExtraLines().size() + 1); + product.getExtraLines().add(line); + kept++; + row++; + } + return row; + } + + private void addLooseSurcharge(ExcelGrid grid, int row, String pending, PayProduct product) + { + String name = ""; + String standard = ""; + String desc = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + if (text.isBlank()) + { + continue; + } + if (name.isEmpty() && text.contains("费") && text.length() <= 40) + { + name = text; + } + else if (standard.isEmpty() && (isMoneyText(text) || grid.number(row, col) != null)) + { + standard = text; + } + else if (text.length() > desc.length()) + { + desc = text; + } + } + addSurcharge(product, cleanTitle(pending), blankTo(name, "附加费"), "", standard, desc, ""); + } + + private void addSurcharge(PayProduct product, String section, String name, String zone, String standard, String desc, String remark) + { + String combined = (standard + " " + desc + " " + remark).trim(); + Period period = PayTexts.period(combined, 2026); + Money money = PayTexts.money(blankTo(standard, combined), period); + PaySurcharge fee = new PaySurcharge(); + fee.setSectionName(section); + fee.setFeeName(cut(new StringBuilder(name), 200)); + fee.setZoneName(cut(new StringBuilder(zone), 200)); + fee.setChargeText(cut(new StringBuilder(blankTo(standard, money.amount == null ? "" : money.amount.toPlainString())), 500)); + fee.setAmount(money.amount); + fee.setCurrency(money.currency); + fee.setFeeDesc(cut(new StringBuilder(joinDesc(desc, remark)), 2000)); + fee.setPeriodText(period.text == null ? "" : cut(new StringBuilder(period.text), 500)); + fee.setEffectiveStart(PayTexts.toDate(period.start)); + fee.setEffectiveEnd(PayTexts.toDate(period.end)); + fee.setChargeTiming(PayTexts.timing(combined, period)); + if (currencyHits(standard) > 1) + { + fee.setAmount(null); + fee.setCurrency(""); + } + fee.setSortNo(product.getSurcharges().size() + 1); + PaySurchargeRules.apply(fee); + product.getSurcharges().add(fee); + } + + /** 燃油、去碳列是整张表的包含说明,只记一条,不跟每个重量段重复。 */ + private void readFuelMark(ExcelGrid grid, PayProduct product) + { + if (grid.rows() > 0) + { + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(0, col).trim(); + if (text.isEmpty() || text.length() > 80) + { + continue; + } + String mark = fuelMark(text); + if (!mark.isEmpty()) + { + addSurcharge(product, "报价包含情况", "报价说明", "", mark, text, ""); + break; + } + } + } + int end = Math.min(grid.rows(), 8); + for (int row = 0; row < end; row++) + { + for (int col = 0; col < grid.cols(); col++) + { + String head = grid.text(row, col).replace('\n', ' ').trim(); + if (head.isEmpty() || head.length() > 30 || !head.contains("燃油")) + { + continue; + } + for (int body = row + 1; body < grid.rows(); body++) + { + String text = grid.text(body, col).trim(); + if (text.isEmpty()) + { + continue; + } + String mark = fuelMark(text); + if (mark.isEmpty()) + { + break; + } + addSurcharge(product, "报价包含情况", head, "", mark, text, ""); + return; + } + } + } + } + + private static String fuelMark(String text) + { + StringBuilder mark = new StringBuilder(); + if (text.contains("不含管理费")) + { + mark.append("不含管理费"); + } + if (text.contains("已含燃油")) + { + if (mark.length() > 0) + { + mark.append(';'); + } + mark.append("已含燃油"); + } + else if (text.contains("不含燃油") || text.contains("不包含燃油")) + { + if (mark.length() > 0) + { + mark.append(';'); + } + mark.append("不含燃油"); + } + if (text.contains("去碳") && (text.contains("不包含") || text.contains("不含"))) + { + if (mark.length() > 0) + { + mark.append(';'); + } + mark.append("不含去碳"); + } + return mark.toString(); + } + + private static String firstLine(String text) + { + if (text == null) + { + return ""; + } + int cut = text.indexOf('\n'); + String line = (cut >= 0 ? text.substring(0, cut) : text).trim(); + return line.length() > 80 ? line.substring(0, 80) : line; + } + + private static String zoneCodes(String text) + { + Matcher matcher = Pattern.compile("\\b([A-Z]{2})\\b").matcher(text == null ? "" : text); + StringBuilder codes = new StringBuilder(); + while (matcher.find()) + { + if (codes.length() > 0) + { + codes.append(','); + } + codes.append(matcher.group(1)); + } + return codes.toString(); + } + + private static int currencyHits(String text) + { + if (text == null || text.isBlank()) + { + return 0; + } + Matcher matcher = Pattern.compile("欧元|英镑|EUR|GBP|USD|CNY").matcher(text.toUpperCase(Locale.ROOT)); + int hits = 0; + while (matcher.find()) + { + hits++; + } + return hits; + } + + private List readCatalog(ExcelGrid grid, Workbook workbook, FileMeta meta) + { + int header = -1; + for (int row = 0; row < grid.rows(); row++) + { + String line = joinRow(grid, row); + if (line.contains("产品名称") || line.contains("销售产品") || line.contains("产品代码") || line.contains("产品 Product")) + { + header = row; + break; + } + } + List items = new ArrayList<>(); + if (header < 0) + { + return items; + } + int cols = grid.cols(); + String[] role = new String[cols]; + for (int col = 0; col < cols; col++) + { + role[col] = headerRole(grid.text(header, col)); + } + String carryName = ""; + String carryCountry = ""; + String carryService = ""; + String carryClass = ""; + for (int row = header + 1; row < grid.rows(); row++) + { + if (grid.blankRow(row)) + { + continue; + } + String line = joinRow(grid, row); + if (line.startsWith("特别申明") || line.startsWith("免责") || line.startsWith("备注:") || line.startsWith("备注:")) + { + break; + } + String quoteSheet = ""; + Set related = new LinkedHashSet<>(); + List linked = new ArrayList<>(); + for (int col = 0; col < cols; col++) + { + String sheet = grid.linkSheet(row, col); + if (sheet.isBlank() || sheet.equals(workbook.getSheetAt(0).getSheetName())) + { + continue; + } + linked.add(sheet); + String text = grid.text(row, col).replace('\n', ' '); + boolean zone = "ZONE".equals(role[col]); + boolean quote = !zone && ("QUOTE".equals(role[col]) || "NAME".equals(role[col]) || "进入报价表".equals(text) || "价格表".equals(text)); + if (quote) + { + quoteSheet = sheet; + } + else + { + related.add(sheet); + } + } + if (quoteSheet.isBlank() && linked.size() == 1) + { + quoteSheet = linked.get(0); + related.clear(); + } + String name = valueOf(grid, row, role, "NAME"); + if (name.isBlank()) + { + name = carryName; + } + else + { + carryName = name; + } + String country = valueOf(grid, row, role, "COUNTRY"); + if (country.isBlank()) + { + country = carryCountry; + } + else + { + carryCountry = country; + } + String service = valueOf(grid, row, role, "SERVICE"); + if (service.isBlank()) + { + service = carryService; + } + else + { + carryService = service; + } + String classification = valueOf(grid, row, role, "CLASS"); + if (classification.isBlank()) + { + classification = carryClass; + } + else + { + carryClass = classification; + } + if (quoteSheet.isBlank()) + { + continue; + } + if (name.isBlank()) + { + name = valueOf(grid, row, role, "VERSION"); + } + if (name.isBlank()) + { + name = quoteSheet; + } + CatalogItem item = new CatalogItem(); + item.name = name; + item.country = country; + item.serviceType = service; + item.classification = classification; + item.remark = valueOf(grid, row, role, "REMARK"); + item.updateContent = valueOf(grid, row, role, "UPDATE"); + item.weightLimit = valueOf(grid, row, role, "WEIGHT"); + item.sheetName = quoteSheet; + item.relatedSheet = String.join("、", related); + item.effective = dateOf(valueOf(grid, row, role, "EFFECTIVE")); + if (item.effective == null) + { + item.effective = meta.effective; + } + items.add(item); + } + return items; + } + + private FileMeta fileMeta(String filename) + { + FileMeta meta = new FileMeta(); + meta.supplier = filename.contains("易德") ? "易德" : "威狮"; + meta.gweFile = filename.toUpperCase(Locale.ROOT).contains("GWE"); + Matcher full = FILE_DATE.matcher(filename); + if (full.find()) + { + meta.effective = LocalDate.of(Integer.parseInt(full.group(1)), Integer.parseInt(full.group(2)), Integer.parseInt(full.group(3))); + return meta; + } + Matcher compact = FILE_COMPACT.matcher(filename); + if (compact.find()) + { + meta.effective = LocalDate.of(Integer.parseInt(compact.group(1)), Integer.parseInt(compact.group(2)), Integer.parseInt(compact.group(3))); + return meta; + } + Matcher monthDay = FILE_MONTH_DAY.matcher(filename); + if (monthDay.find()) + { + // 这批价卡文件名只写了月日,年份用价卡所属的 2026 + meta.effective = LocalDate.of(2026, Integer.parseInt(monthDay.group(1)), Integer.parseInt(monthDay.group(2))); + } + return meta; + } + + private static LocalDate dateOf(String text) + { + Period period = PayTexts.period(text, 2026); + return period.start; + } + + private static String headerRole(String text) + { + String flat = text.replace('\n', ' ').replaceAll("\\s+", " ").trim(); + if (flat.contains("产品名称") || flat.contains("销售产品") || flat.equals("产品") || flat.startsWith("产品 ")) + { + return "NAME"; + } + if (text.contains("版本")) + { + return "VERSION"; + } + if (text.contains("国家") || text.contains("地区") || text.contains("口岸")) + { + return "COUNTRY"; + } + if (text.contains("服务类型")) + { + return "SERVICE"; + } + if (text.contains("分类")) + { + return "CLASS"; + } + if (text.contains("更新")) + { + return "UPDATE"; + } + if (text.contains("重量限制")) + { + return "WEIGHT"; + } + if (text.contains("生效")) + { + return "EFFECTIVE"; + } + if (text.contains("报价") || text.contains("价卡")) + { + return "QUOTE"; + } + if (text.contains("分区")) + { + return "ZONE"; + } + if (text.contains("备注")) + { + return "REMARK"; + } + return ""; + } + + private static boolean isWeightColumnHeader(ExcelGrid grid, int row) + { + int hits = 0; + String previous = ""; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + if (!PayTexts.looksLikeWeight(text)) + { + previous = ""; + continue; + } + // 横向合并会把同一个重量上限复制到相邻格,不能当成两个重量段 + if (text.equals(previous)) + { + continue; + } + previous = text; + hits++; + } + return hits >= 2; + } + + private static boolean isSurchargeHeader(ExcelGrid grid, int row) + { + String line = joinRow(grid, row); + // 说明句子里提到「收费标准」不是表头 + if (countText(grid, row) <= 1 && (line.length() > 40 || line.contains("。"))) + { + return false; + } + if (line.contains("收费标准") || line.contains("费用说明") || line.contains("费用解释")) + { + return true; + } + boolean name = line.contains("类目") || line.contains("费用名称") || line.contains("收费项目"); + boolean money = line.contains("收费") || line.contains("金额"); + boolean desc = line.contains("说明") || line.contains("解释"); + return name && money && desc; + } + + /** 标题行写着偏远附加费,紧挨着的表头是地区和邮编。 */ + private static int remoteFeeHeader(ExcelGrid grid, int row) + { + String line = joinRow(grid, row); + if (!line.contains("偏远") || !line.contains("费")) + { + return -1; + } + if (line.contains("地区") && line.contains("邮编")) + { + return row; + } + int next = row + 1; + while (next < grid.rows() && grid.blankRow(next)) + { + next++; + } + if (next >= grid.rows() || next > row + 3) + { + return -1; + } + String header = joinRow(grid, next); + if (header.contains("地区") && header.contains("邮编")) + { + return next; + } + return -1; + } + + /** 地区名称在上一行,本行每个地区下面都写着每件。 */ + private static boolean isRegionWeightHeader(ExcelGrid grid, int row) + { + int perItem = 0; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + if (text.contains("每件") || text.toLowerCase(Locale.ROOT).contains("per item")) + { + perItem++; + } + } + if (perItem < 2) + { + return false; + } + int prev = row - 1; + while (prev >= 0 && grid.blankRow(prev)) + { + prev--; + } + if (prev < 0) + { + return false; + } + String prevLine = joinRow(grid, prev).toLowerCase(Locale.ROOT); + if (!prevLine.contains("地区") && !prevLine.contains("area")) + { + return false; + } + int next = row + 1; + while (next < grid.rows() && grid.blankRow(next)) + { + next++; + } + return next < grid.rows() && rowHasWeight(grid, next) && countNumbers(grid, next) >= 2; + } + + /** 单独的「附加费」标题。后面跟着金额说明或条件费率,而不是收费标准表。 */ + private static boolean isFeeSectionTitle(ExcelGrid grid, int row) + { + if (isSurchargeHeader(grid, row)) + { + return false; + } + String line = joinRow(grid, row); + if (line.length() > 80 || countText(grid, row) > 2 || line.contains("。") || feeAmount(line) != null) + { + return false; + } + String lower = line.toLowerCase(Locale.ROOT); + return line.contains("附加费") || lower.contains("surcharge"); + } + + private static String pieceUnit(ExcelGrid grid) + { + for (int row = 0; row < grid.rows(); row++) + { + String line = joinRow(grid, row); + String upper = line.toUpperCase(Locale.ROOT); + if (upper.contains("EUR") || line.contains("欧元")) + { + return "EUR/件"; + } + if (upper.contains("GBP") || line.contains("英镑")) + { + return "GBP/件"; + } + if (upper.contains("USD") || line.contains("美元")) + { + return "USD/件"; + } + } + return "每件"; + } + + private static String sentenceFeeName(String line) + { + if (line.contains("重新贴尾程标签")) + { + return "重新贴尾程标签附加费"; + } + if (line.contains("重新贴")) + { + return "重新贴标签附加费"; + } + String title = chineseTitle(line); + return title.isBlank() ? "附加费" : title; + } + + private static String chineseTitle(String line) + { + Matcher matcher = Pattern.compile("[\\u4e00-\\u9fff]{2,20}").matcher(line == null ? "" : line); + String title = ""; + while (matcher.find()) + { + title = matcher.group(); + } + return title; + } + + private static boolean looksLikeRate(String text) + { + String flat = text.replace('\n', ' '); + return flat.matches("(?i).*(\\d+(?:\\.\\d+)?\\s*%|欧元|英镑|\\beur\\b|\\bgbp\\b|euro).*"); + } + + private static String rateLine(String text) + { + String[] lines = text.split("\\n"); + for (int i = lines.length - 1; i >= 0; i--) + { + String line = lines[i].trim(); + if (line.contains("%") || line.contains("欧元") || line.toUpperCase(Locale.ROOT).contains("EUR")) + { + return line; + } + } + return text.replace('\n', ' ').trim(); + } + + private static String feeAmount(String line) + { + if (line == null || line.isBlank()) + { + return null; + } + Matcher leading = Pattern.compile("(?i)(\\d+(?:\\.\\d+)?)\\s*(欧元|英镑|EUR|GBP|Euro)").matcher(line); + if (leading.find()) + { + return leading.group(1) + " " + feeCurrency(leading.group(2)); + } + Matcher trailing = Pattern.compile("(?i)(欧元|英镑|EUR|GBP|Euro)\\s*(\\d+(?:\\.\\d+)?)").matcher(line); + if (trailing.find()) + { + return trailing.group(2) + " " + feeCurrency(trailing.group(1)); + } + return null; + } + + private static String feeCurrency(String token) + { + String upper = token.toUpperCase(Locale.ROOT); + if (upper.contains("EUR") || token.contains("欧元") || upper.contains("EURO")) + { + return "EUR"; + } + if (upper.contains("GBP") || token.contains("英镑")) + { + return "GBP"; + } + return token; + } + + /** 重量上下限拆成两列数字,右侧是每票价格。 */ + private static boolean isSplitWeightHeader(ExcelGrid grid, int row) + { + if (isWeightColumnHeader(grid, row) || isSurchargeHeader(grid, row)) + { + return false; + } + boolean weight = false; + boolean price = false; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + String lower = text.toLowerCase(Locale.ROOT); + if (text.contains("公斤") || text.contains("重量") || text.contains("尾程") || lower.contains("kg")) + { + weight = true; + } + if (text.contains("每件") || text.contains("每票") || text.contains("单价")) + { + price = true; + } + } + if (!weight || !price) + { + return false; + } + int perItem = 0; + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col); + if (text.contains("每件") || text.contains("每票") || text.contains("单价")) + { + perItem++; + } + } + // 每个地区一列「每件」是地区价格表,不是英国 RM 那种两列重量范围 + if (perItem >= 2) + { + return false; + } + int next = row + 1; + while (next < grid.rows() && grid.blankRow(next)) + { + next++; + } + return next < grid.rows() && countNumbers(grid, next) >= 2; + } + + /** 费用说明表头横向合并时,说明写在合并范围内的每一列,空的那一列不能把下一条说明丢掉。 */ + private static String descText(ExcelGrid grid, int header, int row, int descCol) + { + if (descCol < 0) + { + return ""; + } + int end = descCol + grid.colSpan(header, descCol); + StringBuilder desc = new StringBuilder(); + for (int col = descCol; col < end && col < grid.cols(); col++) + { + String text = grid.text(row, col).trim(); + if (text.isEmpty()) + { + continue; + } + if (desc.length() > 0) + { + desc.append('\n'); + } + desc.append(text); + } + return desc.toString(); + } + + /** + * 表头是空的,左侧表头也没有合并过来,副表头同样是空的。 + * 这种列是价卡右边多贴的一份价格,不能再记成重量段。 + */ + private static boolean strayColumn(ExcelGrid grid, int header, int col, int subHeader) + { + if (!grid.text(header, col).isBlank() || headerCovers(grid, header, col)) + { + return false; + } + return subHeader < 0 || grid.text(subHeader, col).isBlank(); + } + + private static boolean headerCovers(ExcelGrid grid, int row, int col) + { + for (int left = col - 1; left >= 0; left--) + { + int span = grid.colSpan(row, left); + if (span > 1 && left + span - 1 >= col) + { + return true; + } + } + return false; + } + + /** 表头是重量段加费用列,但费用本身是附加费,不是派送费。 */ + private static boolean isFeeRateHeader(ExcelGrid grid, int row) + { + if (!isPriceHeader(grid, row)) + { + return false; + } + boolean surcharge = false; + boolean freight = false; + for (int col = 0; col < grid.cols(); col++) + { + String head = grid.text(row, col); + if (isSurchargePriceHead(head)) + { + surcharge = true; + } + if (isBaseFreightHead(head)) + { + freight = true; + } + } + return surcharge && !freight; + } + + private static boolean isBaseFreightHead(String head) + { + if (head == null || head.isBlank()) + { + return false; + } + if (head.contains("附加") || head.contains("额外") || head.toLowerCase(Locale.ROOT).contains("surcharge")) + { + return false; + } + return head.contains("派送费") || head.contains("运费") || head.contains("处理费") || head.contains("挂号"); + } + + private static boolean isSurchargePriceHead(String head) + { + if (head == null || head.isBlank() || isBaseFreightHead(head)) + { + return false; + } + String lower = head.toLowerCase(Locale.ROOT); + return head.contains("附加费") || head.contains("额外") || lower.contains("surcharge") || lower.contains("line-haul") + || lower.contains("line haul"); + } + + private static String feeUnit(String head, String weight) + { + String unit = unitOf(head + " " + weight); + String compact = weight == null ? "" : weight.replace(" ", "").toLowerCase(Locale.ROOT); + boolean perKg = compact.contains("每增加") || compact.contains("续重") || compact.contains("additional"); + if (!perKg) + { + return unit; + } + if (unit.contains("ITEM")) + { + return unit.replace("ITEM", "KG"); + } + if (unit.contains("KG") || unit.contains("公斤")) + { + return unit; + } + return unit.isEmpty() ? "KG" : unit + "/KG"; + } + + private static boolean isPriceHeader(ExcelGrid grid, int row) + { + if (isSurchargeHeader(grid, row) || isWeightColumnHeader(grid, row)) + { + return false; + } + String line = joinRow(grid, row); + // 说明句子里提到重量段、运费,不是一张新的价格表 + if (countText(grid, row) <= 1 && (line.length() > 40 || line.contains("。"))) + { + return false; + } + String lower = line.toLowerCase(Locale.ROOT); + // 正文里提到“派送费”不能当成新表头,否则附加费行会被截断 + if (lower.contains("prices in") || line.contains("速递运费") || line.contains("国家简码") || line.contains("重量段")) + { + return true; + } + boolean fee = line.contains("处理费") || line.contains("派送费") || line.contains("运费"); + boolean structure = line.contains("国家") || line.contains("产品") || line.contains("重量") || lower.contains("country"); + return fee && structure; + } + + /** 表头从左到右至少两个 Zone,例如德国 UPS 的 Zone 1 到 Zone 5。 */ + private static boolean isZonePriceHeader(ExcelGrid grid, int row) + { + int zones = 0; + for (int col = 0; col < grid.cols(); col++) + { + if (grid.text(row, col).toLowerCase(Locale.ROOT).matches(".*zone\\s*\\d.*")) + { + zones++; + } + } + return zones >= 2; + } + + private static boolean isHardHeader(ExcelGrid grid, int row) + { + return isWeightColumnHeader(grid, row) || isSurchargeHeader(grid, row) || isPriceHeader(grid, row); + } + + private static boolean isSurchargeMatrix(ExcelGrid grid, int row) + { + if (isHardHeader(grid, row) || countText(grid, row) < 2) + { + return false; + } + int next = row + 1; + while (next < grid.rows() && grid.blankRow(next)) + { + next++; + } + if (next >= grid.rows()) + { + return false; + } + String line = joinRow(grid, next); + return line.contains("费") && countNumbers(grid, next) >= 1; + } + + private static boolean isPostcodeHeader(ExcelGrid grid, int row) + { + String line = joinRow(grid, row); + return line.contains("起始邮编") || (line.contains("邮编") && (line.contains("分区") || line.contains("截止"))); + } + + private static boolean isLooseSurcharge(ExcelGrid grid, int row) + { + String line = joinRow(grid, row); + if (!line.contains("费") || line.length() > 500) + { + return false; + } + // 一整段说明里顺便提到费用,不是表格行 + if (countText(grid, row) <= 1 && (line.length() > 40 || line.contains("。"))) + { + return false; + } + boolean money = false; + for (int col = 0; col < grid.cols(); col++) + { + if (grid.number(row, col) != null || isMoneyText(grid.text(row, col))) + { + money = true; + } + } + return money && countNumbers(grid, row) <= 2; + } + + private static int columnKind(String head, boolean numericBelow) + { + if (head.contains("产品") && !head.contains("费")) + { + return 0; + } + if (head.contains("备注") || head.contains("尺寸") || head.contains("燃油") || head.contains("说明")) + { + return 0; + } + if (head.contains("简码") || head.toLowerCase(Locale.ROOT).contains("code")) + { + return 3; + } + if (head.contains("英文")) + { + return 0; + } + if (head.contains("重量") || head.toLowerCase(Locale.ROOT).contains("weight")) + { + return 1; + } + if (head.contains("国家") || head.toLowerCase(Locale.ROOT).contains("country")) + { + return 2; + } + if (isVolumeHead(head)) + { + return 5; + } + if (isPriceHead(head) || numericBelow) + { + return 4; + } + return 0; + } + + /** 长x宽x高是这一档的体积上限,不是单价。表头里的 CM 是单边单位,乘积用 cm³。 */ + private static boolean isVolumeHead(String head) + { + if (head == null || head.isBlank()) + { + return false; + } + String value = head.toLowerCase(Locale.ROOT).replace(" ", "").replace("×", "x").replace("*", "x"); + return value.contains("长x宽x高"); + } + + /** 同一行的体积写到这一档的价格上,不另开一条费用。 */ + private static String volumeOnRow(ExcelGrid grid, int row, int[] kind, String[] parent) + { + String volume = ""; + for (int col = 0; col < kind.length; col++) + { + if (kind[col] != 5) + { + continue; + } + String text = volumeText(grid, row, col, parent[col]); + if (!text.isBlank()) + { + volume = text; + } + } + return volume; + } + + private static String volumeText(ExcelGrid grid, int row, int col, String header) + { + Double number = grid.number(row, col); + if (number == null) + { + String text = grid.text(row, col).trim(); + if (!text.matches("\\d+(?:\\.\\d+)?")) + { + return ""; + } + number = Double.valueOf(text); + } + String digits = BigDecimal.valueOf(number).stripTrailingZeros().toPlainString(); + return digits + volumeUnit(header); + } + + private static String volumeUnit(String header) + { + String value = header == null ? "" : header.toLowerCase(Locale.ROOT).replace(" ", ""); + if (value.contains("m³") || value.contains("m3") || value.contains("立方米")) + { + return "m³"; + } + return "cm³"; + } + + private static boolean isPriceHead(String head) + { + if (head == null) + { + return false; + } + String value = head.toLowerCase(Locale.ROOT); + return head.contains("费") || head.contains("价") || head.contains("运费") || value.contains("eur") || value.contains("gbp") + || value.contains("price"); + } + + private static boolean numericBelow(ExcelGrid grid, int col, int from) + { + int end = Math.min(grid.rows(), from + 8); + for (int row = from; row < end; row++) + { + if (priceOf(grid, row, col) != null) + { + return true; + } + } + return false; + } + + private static BigDecimal priceOf(ExcelGrid grid, int row, int col) + { + Double number = grid.number(row, col); + if (number != null && Math.abs(number) < 100000) + { + return BigDecimal.valueOf(number).setScale(4, java.math.RoundingMode.HALF_UP).stripTrailingZeros(); + } + String text = grid.text(row, col); + Matcher matcher = Pattern.compile("^\\s*(\\d+(?:\\.\\d+)?)").matcher(text); + if (matcher.find() && (text.contains("/") || text.toUpperCase(Locale.ROOT).matches(".*([A-Z]{3}|%).*"))) + { + return new BigDecimal(matcher.group(1)); + } + return null; + } + + private static boolean isMoneyText(String text) + { + return text.matches(".*\\d.*") && (text.contains("/") || text.toUpperCase(Locale.ROOT).matches(".*\\d\\s*(EUR|GBP|USD|RMB|CNY|%|欧元|英镑).*")); + } + + /** 这种价卡右边是每票价格。表头写着每件时也按每票记,并带上表里的币种。 */ + private static String perTicketUnit(ExcelGrid grid) + { + String currency = ""; + for (int row = 0; row < grid.rows() && currency.isEmpty(); row++) + { + String line = joinRow(grid, row); + if (line.toUpperCase(Locale.ROOT).contains("GBP") || line.contains("英镑")) + { + currency = "GBP"; + } + else if (line.toUpperCase(Locale.ROOT).contains("EUR") || line.contains("欧元")) + { + currency = "EUR"; + } + else if (line.toUpperCase(Locale.ROOT).contains("USD") || line.contains("美元")) + { + currency = "USD"; + } + } + return currency.isEmpty() ? "每票" : currency + "/票"; + } + + private static String unitOf(String header) + { + String value = header.toUpperCase(Locale.ROOT); + String currency = value.contains("GBP") ? "GBP" : value.contains("EUR") ? "EUR" : value.contains("USD") ? "USD" : value.contains("RMB") || value.contains("CNY") ? "CNY" : ""; + String per = value.contains("/ITEM") || header.contains("/票") || header.contains("每件") || header.contains("每票") + || value.contains("/PARCEL") ? "ITEM" : value.contains("/KG") ? "KG" : ""; + if (currency.isEmpty()) + { + return per; + } + return per.isEmpty() ? currency : currency + "/" + per; + } + + private static String shortCharge(String text) + { + if (text == null) + { + return ""; + } + String value = text.replace('\n', ' ').trim(); + int cut = value.indexOf('('); + if (cut < 0) + { + cut = value.indexOf('('); + } + if (cut > 0) + { + value = value.substring(0, cut).trim(); + } + return value.length() > 80 ? value.substring(0, 80) : value; + } + + private static String zoneOf(String sub, String parent) + { + if (sub == null || sub.isBlank() || sub.equals(parent)) + { + return ""; + } + return sub; + } + + private static String firstFeeHead(String[] parent) + { + for (String head : parent) + { + if (isPriceHead(head)) + { + return shortCharge(head); + } + } + return "价格"; + } + + private static String codesIn(String text) + { + Matcher matcher = COUNTRY_CODE.matcher(text == null ? "" : text); + StringBuilder codes = new StringBuilder(); + while (matcher.find()) + { + if (codes.length() > 0) + { + codes.append(','); + } + codes.append(matcher.group(1)); + } + return codes.toString(); + } + + private static String areaLabel(String text) + { + if (text == null) + { + return ""; + } + Matcher matcher = AREA_LABEL.matcher(text); + if (!matcher.find()) + { + return ""; + } + return matcher.group(1).replaceAll("(?i)area\\s*", "Area "); + } + + private static String feePriceText(String text) + { + if (text == null || text.isBlank()) + { + return ""; + } + String flat = text.replace('\n', ' ').replaceAll("\\s+", " ").trim(); + Matcher matcher = FEE_PRICE.matcher(flat); + if (!matcher.find()) + { + return flat.length() > 80 ? flat.substring(0, 80) : flat; + } + StringBuilder price = new StringBuilder(matcher.group(1)); + if (matcher.group(2) != null) + { + price.append(' ').append(matcher.group(2).toUpperCase(Locale.ROOT)); + } + if (matcher.group(3) != null) + { + price.append(matcher.group(3).replace(" ", "")); + } + return price.toString(); + } + + private static String valueOf(ExcelGrid grid, int row, String[] role, String expect) + { + for (int col = 0; col < role.length; col++) + { + if (expect.equals(role[col]) && !grid.text(row, col).isBlank()) + { + return grid.text(row, col); + } + } + return ""; + } + + private static int countText(ExcelGrid grid, int row) + { + int count = 0; + for (int col = 0; col < grid.cols(); col++) + { + if (!grid.text(row, col).isBlank()) + { + count++; + } + } + return count; + } + + private static int countNumbers(ExcelGrid grid, int row) + { + int count = 0; + for (int col = 0; col < grid.cols(); col++) + { + if (grid.number(row, col) != null) + { + count++; + } + } + return count; + } + + private static boolean rowHasWeight(ExcelGrid grid, int row) + { + for (int col = 0; col < grid.cols(); col++) + { + if (PayTexts.looksLikeWeight(grid.text(row, col))) + { + return true; + } + } + return false; + } + + private static String firstText(ExcelGrid grid, int row) + { + for (int col = 0; col < grid.cols(); col++) + { + if (!grid.text(row, col).isBlank()) + { + return grid.text(row, col); + } + } + return ""; + } + + private static String joinRow(ExcelGrid grid, int row) + { + StringBuilder line = new StringBuilder(); + for (int col = 0; col < grid.cols(); col++) + { + String text = grid.text(row, col).replace('\n', ' ').trim(); + if (text.isEmpty()) + { + continue; + } + if (line.length() > 0) + { + line.append(" | "); + } + line.append(text); + } + return line.toString(); + } + + private static String joinDesc(String desc, String remark) + { + if (desc == null || desc.isBlank()) + { + return remark == null ? "" : remark; + } + if (remark == null || remark.isBlank() || desc.contains(remark)) + { + return desc; + } + return desc + " " + remark; + } + + private static int postcodeHits(ExcelGrid grid) + { + int hits = 0; + int end = Math.min(grid.rows(), 40); + for (int row = 0; row < end; row++) + { + for (int col = 0; col < grid.cols(); col++) + { + if (POSTCODE.matcher(grid.text(row, col)).find()) + { + hits++; + } + } + } + return hits; + } + + private static String currencyOf(ExcelGrid grid) + { + StringBuilder sample = new StringBuilder(); + int end = Math.min(grid.rows(), 25); + for (int row = 0; row < end; row++) + { + sample.append(joinRow(grid, row)).append(' '); + } + return PayTexts.currencies(sample.toString()); + } + + private static void appendNote(StringBuilder note, String line) + { + if (line == null || line.isBlank()) + { + return; + } + if (note.indexOf(line) >= 0) + { + return; + } + if (note.length() > 0) + { + note.append('\n'); + } + note.append(line.replace('\n', ' ')); + } + + private static String cleanTitle(String title) + { + if (title == null) + { + return ""; + } + String value = title.trim(); + return value.length() > 80 ? "" : value; + } + + private static String blankTo(String value, String fallback) + { + return value == null || value.isBlank() ? fallback : value; + } + + private static String cut(StringBuilder value, int max) + { + if (value == null) + { + return ""; + } + String text = value.toString().trim(); + return text.length() <= max ? text : text.substring(0, max); + } + + private static final class FileMeta + { + private String supplier; + + private boolean gweFile; + + private LocalDate effective; + } + + private static final class CatalogItem + { + private String name; + + private String country; + + private String serviceType; + + private String classification; + + private String remark; + + private String updateContent; + + private String weightLimit; + + private String sheetName; + + private String relatedSheet; + + private LocalDate effective; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayZoneMatcher.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayZoneMatcher.java new file mode 100644 index 00000000..4419a041 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayZoneMatcher.java @@ -0,0 +1,315 @@ +package com.bms.price.pay; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import com.bms.price.domain.PayPostcodeLine; + +/** + * 价卡有多个地区时,用邮编在分区表里确定价格列。 + * 邮编不在分区表里、价卡又有「大陆」列时,按大陆计费。 + */ +public final class PayZoneMatcher +{ + private static final Pattern TOKEN = Pattern.compile("[a-z0-9]+|[\\u4e00-\\u9fa5]+"); + + private static final Set GENERIC_TOKENS = Set.of("英国", "美国", "德国", "法国", "西班牙", "意大利", "挂号", "普货", "标快", "快递", "专线", "末端", "包裹"); + + private PayZoneMatcher() + { + } + + /** 中英文连在一起时拆开,例如「英国evri」取出 evri,用来对邮编分区标题。 */ + public static List searchTokens(String... texts) + { + Set tokens = new LinkedHashSet<>(); + if (texts == null) + { + return new ArrayList<>(); + } + for (String text : texts) + { + if (text == null || text.isBlank()) + { + continue; + } + Matcher matcher = TOKEN.matcher(text.toLowerCase(Locale.ROOT)); + while (matcher.find()) + { + String token = matcher.group(); + if (token.length() >= 3 && !GENERIC_TOKENS.contains(token)) + { + tokens.add(token); + } + } + } + return new ArrayList<>(tokens); + } + + public static final class Decision + { + private final String zoneName; + + private final String message; + + private Decision(String zoneName, String message) + { + this.zoneName = zoneName; + this.message = message; + } + + public static Decision zone(String zoneName) + { + return new Decision(zoneName, null); + } + + public static Decision fail(String message) + { + return new Decision(null, message); + } + + public static Decision unknown() + { + return new Decision(null, null); + } + + public String getZoneName() + { + return zoneName; + } + + public String getMessage() + { + return message; + } + } + + public static Decision resolve(List zoneNames, List lines, String postcode) + { + if (zoneNames == null || zoneNames.size() <= 1) + { + return Decision.unknown(); + } + if (postcode == null || postcode.isBlank()) + { + return Decision.fail("订单没有邮编,无法从多个地区里确定运费"); + } + String bfpo = bfpoZone(zoneNames, postcode); + if (bfpo != null) + { + return Decision.zone(bfpo); + } + if (lines == null || lines.isEmpty()) + { + return Decision.unknown(); + } + String bestZone = null; + int bestLength = -1; + boolean tie = false; + boolean unmapped = false; + String unmappedName = ""; + for (PayPostcodeLine line : lines) + { + int length = matchedLength(line, postcode); + if (length < 0) + { + continue; + } + String region = firstText(line.getZoneName(), line.getRegionName()); + String zone = mapRegion(zoneNames, region); + if (zone == null) + { + unmapped = true; + unmappedName = region; + continue; + } + if (length > bestLength) + { + bestLength = length; + bestZone = zone; + tie = false; + } + else if (length == bestLength && !zone.equals(bestZone)) + { + tie = true; + } + } + if (tie) + { + return Decision.fail("邮编 " + postcode.trim() + " 对上了多个地区,无法确定运费"); + } + if (bestZone != null) + { + return Decision.zone(bestZone); + } + if (unmapped) + { + return Decision.fail("邮编 " + postcode.trim() + " 落在「" + unmappedName + "」,价卡没有同名地区"); + } + String mainland = mainlandZone(zoneNames); + if (mainland != null) + { + return Decision.zone(mainland); + } + return Decision.fail("邮编 " + postcode.trim() + " 没有对上价卡地区"); + } + + public static String mapRegion(List zoneNames, String region) + { + if (region == null || region.isBlank() || zoneNames == null) + { + return null; + } + String normRegion = norm(region); + List hits = new ArrayList<>(); + for (String zone : zoneNames) + { + String normZone = norm(zone); + if (!normRegion.isEmpty() && (normZone.contains(normRegion) || normRegion.contains(normZone))) + { + hits.add(zone); + } + } + if (hits.size() == 1) + { + return hits.get(0); + } + hits.clear(); + for (String word : region.toLowerCase(Locale.ROOT).split("[^a-z0-9\\u4e00-\\u9fa5]+")) + { + if (word.length() < 5) + { + continue; + } + for (String zone : zoneNames) + { + if (norm(zone).contains(word) && !hits.contains(zone)) + { + hits.add(zone); + } + } + } + if (hits.size() == 1) + { + return hits.get(0); + } + if (containsAny(region, "scottish", "shetland", "highland", "高地")) + { + return uniqueKeyword(zoneNames, "highlands", "高地"); + } + if (containsAny(region, "scilly", "锡利")) + { + return uniqueKeyword(zoneNames, "scilly", "锡利", "海峡"); + } + return null; + } + + public static boolean sameZone(String left, String right) + { + String a = norm(left); + String b = norm(right); + return !a.isEmpty() && (a.equals(b) || a.contains(b) || b.contains(a)); + } + + private static String bfpoZone(List zoneNames, String postcode) + { + String compact = postcode.toUpperCase(Locale.ROOT).replaceAll("[^A-Z0-9]", ""); + if (!compact.startsWith("BFPO") && !compact.startsWith("BF")) + { + return null; + } + return uniqueKeyword(zoneNames, "bfpo"); + } + + private static String mainlandZone(List zoneNames) + { + List hits = new ArrayList<>(); + for (String zone : zoneNames) + { + String text = norm(zone); + if (text.contains("mainland") || text.contains("大陆")) + { + hits.add(zone); + } + } + return hits.size() == 1 ? hits.get(0) : null; + } + + private static int matchedLength(PayPostcodeLine line, String postcode) + { + int best = -1; + best = longer(best, line.getPostcode(), postcode); + best = longer(best, line.getPostcodeEnd(), postcode); + return best; + } + + private static int longer(int best, String raw, String postcode) + { + if (raw == null || raw.isBlank()) + { + return best; + } + for (String token : raw.split("[,,、|;;/]+")) + { + String text = token.trim(); + if (text.isEmpty()) + { + continue; + } + if (PaySurchargeRules.matchesPostcode(text, postcode) && text.length() > best) + { + best = text.length(); + } + } + return best; + } + + private static String uniqueKeyword(List zoneNames, String... keywords) + { + List hits = new ArrayList<>(); + for (String zone : zoneNames) + { + String text = norm(zone); + if (containsAny(text, keywords) && !hits.contains(zone)) + { + hits.add(zone); + } + } + return hits.size() == 1 ? hits.get(0) : null; + } + + private static boolean containsAny(String text, String... keywords) + { + String value = text == null ? "" : text.toLowerCase(Locale.ROOT); + for (String keyword : keywords) + { + if (value.contains(keyword.toLowerCase(Locale.ROOT))) + { + return true; + } + } + return false; + } + + private static String firstText(String first, String second) + { + if (first != null && !first.isBlank()) + { + return first.trim(); + } + return second == null ? "" : second.trim(); + } + + static String norm(String value) + { + if (value == null) + { + return ""; + } + return value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9\\u4e00-\\u9fa5]", ""); + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/IPayBillService.java b/bms-back/bms-system/src/main/java/com/bms/price/service/IPayBillService.java new file mode 100644 index 00000000..991f6202 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/IPayBillService.java @@ -0,0 +1,23 @@ +package com.bms.price.service; + +import java.util.List; +import com.bms.price.domain.PayBill; +import com.bms.price.domain.PayBillCommand; +import com.bms.price.domain.PayBillPreview; + +public interface IPayBillService +{ + List selectPayBillList(PayBill query); + + PayBill selectPayBillDetail(Long billId); + + PayBillPreview preview(PayBillCommand command); + + PayBill generate(PayBillCommand command); + + int confirm(Long billId); + + int markPaid(Long billId); + + int voidBill(PayBillCommand command); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/IPayOrderCostService.java b/bms-back/bms-system/src/main/java/com/bms/price/service/IPayOrderCostService.java new file mode 100644 index 00000000..37478ef7 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/IPayOrderCostService.java @@ -0,0 +1,9 @@ +package com.bms.price.service; + +import com.bms.price.domain.PayOrderCostResult; + +/** 按订单生成应付:渠道服务商上的应付产品,用订单基础数据计算。 */ +public interface IPayOrderCostService +{ + PayOrderCostResult generate(String billId); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/IPayPostcodeService.java b/bms-back/bms-system/src/main/java/com/bms/price/service/IPayPostcodeService.java new file mode 100644 index 00000000..9b11e9ed --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/IPayPostcodeService.java @@ -0,0 +1,22 @@ +package com.bms.price.service; + +import java.util.List; +import com.bms.price.domain.PayPostcodeGroup; +import com.bms.price.domain.PayPostcodeImportResult; +import com.bms.price.domain.PayPostcodeLine; + +/** 数据管理下的应付邮编配置 */ +public interface IPayPostcodeService +{ + List selectGroupList(PayPostcodeGroup query); + + PayPostcodeGroup selectGroupById(Long groupId); + + int updateGroup(PayPostcodeGroup group); + + List selectLineList(PayPostcodeLine query); + + PayPostcodeImportResult importExcel(byte[] bytes, String filename, String username); + + int deleteByIds(Long[] groupIds); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/IPayProductService.java b/bms-back/bms-system/src/main/java/com/bms/price/service/IPayProductService.java new file mode 100644 index 00000000..b6c879e6 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/IPayProductService.java @@ -0,0 +1,70 @@ +package com.bms.price.service; + +import java.util.List; +import java.util.Date; +import com.bms.price.domain.PayPreview; +import com.bms.price.domain.PayPriceCompare; +import com.bms.price.domain.PayProduct; +import com.bms.price.domain.PayProductTerm; +import com.bms.price.domain.PaySaveCommand; + +/** + * 应付价卡配置:预览后保存,列表查询和导出。 + */ +public interface IPayProductService +{ + /** + * 按字典匹配工作表并识别,不写数据库。 + * dictValue 有值时只读这一张,为空时读取文件里所有能对上的工作表。 + */ + PayPreview preview(byte[] bytes, String filename, String dictValue); + + /** 把预览里勾选的产品写入数据库。同名字典产品再次导入时另存一版,旧价格留在有效期表里 */ + String save(PaySaveCommand command); + + /** 一个产品的全部价格版本 */ + List listTerms(Long productId); + + /** 修改某一版的生效起止 */ + int updateTerm(PayProductTerm term); + + /** 0 生效,1 失效 */ + int changeTermStatus(PayProductTerm term); + + /** 修改已保存的产品,重量段和附加费按提交内容整表替换 */ + int updateDetail(PayProduct product); + + /** 公共模板下拉。payType 为空时按应付 AP */ + List selectTemplateOptions(String payType); + + /** 渠道绑定的产品。返回的 originId 是不变的产品编号,价格是当前有效版 */ + PayProduct selectChannelPrice(String channelId); + + /** 只绑定产品编号,不产生新版本。payType 为空时按应付 AP */ + void bindChannel(String channelId, Long productId, Long priceSetId, String payType); + + /** 取消渠道上指定收付类型的产品。payType 为空时按应付 AP */ + void unbindChannel(String channelId, String payType); + + /** 按订单时间取这一产品当时有效的价格版本 */ + PayProduct selectPriceForDate(String channelId, Date bizDate); + + /** 对照两个版本的重量段和附加费 */ + PayPriceCompare compare(Long leftId, Long rightId); + + /** 按编辑结果给同一产品编号追加一版,渠道绑定保持原产品编号 */ + PayProduct saveChannelVersion(String channelId, PayProduct edited); + + /** 重新导入价格,仍记在原来的产品编号下 */ + PayProduct reimportChannel(String channelId, byte[] bytes, String filename); + + List selectPayProductList(PayProduct query); + + /** 列表条件对应的产品,并带上重量段、附加费和其他配置 */ + List selectPayProductDetails(PayProduct query); + + /** 产品及其重量段、附加费、邮编配置 */ + PayProduct selectPayProductDetail(Long productId); + + int deletePayProducts(Long[] productIds); +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayBillServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayBillServiceImpl.java new file mode 100644 index 00000000..bb2b80d2 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayBillServiceImpl.java @@ -0,0 +1,399 @@ +package com.bms.price.service.impl; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.SimpleDateFormat; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.bms.common.exception.ServiceException; +import com.bms.common.utils.SecurityUtils; +import com.bms.common.utils.uuid.IdUtils; +import com.bms.customer.supplierinfo.domain.BmsSupplier; +import com.bms.customer.supplierinfo.mapper.BmsSupplierMapper; +import com.bms.price.domain.PayBill; +import com.bms.price.domain.PayBillCommand; +import com.bms.price.domain.PayBillLine; +import com.bms.price.domain.PayBillLog; +import com.bms.price.domain.PayBillOrder; +import com.bms.price.domain.PayBillPreview; +import com.bms.price.domain.PayOrderCostResult; +import com.bms.price.domain.PrcPayBillFee; +import com.bms.price.mapper.PayBillMapper; +import com.bms.price.service.IPayBillService; +import com.bms.price.service.IPayOrderCostService; + +/** + * 应付账单。先按订单号、计费日或提单查出订单,再把当时的费用抄进账单。作废只改状态。 + */ +@Service +public class PayBillServiceImpl implements IPayBillService +{ + private static final int MAX_ORDERS = 2000; + + @Autowired + private PayBillMapper payBillMapper; + + @Autowired + private IPayOrderCostService payOrderCostService; + + @Autowired + private BmsSupplierMapper supplierMapper; + + @Override + public List selectPayBillList(PayBill query) + { + return payBillMapper.selectPayBillList(query); + } + + @Override + public PayBill selectPayBillDetail(Long billId) + { + PayBill bill = payBillMapper.selectPayBillById(billId); + if (bill == null) + { + throw new ServiceException("应付账单不存在"); + } + bill.setLines(payBillMapper.selectLines(billId)); + bill.setLogs(payBillMapper.selectLogs(billId)); + return bill; + } + + @Override + public PayBillPreview preview(PayBillCommand command) + { + return collect(command, false); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public PayBill generate(PayBillCommand command) + { + if (command.getSupplierId() == null) + { + throw new ServiceException("请选择供应商"); + } + BmsSupplier supplier = supplierMapper.selectBmsSupplierBySupplierId(command.getSupplierId()); + if (supplier == null) + { + throw new ServiceException("供应商不存在"); + } + PayBillPreview preview = collect(command, true); + List orders = freeOrders(preview); + if (orders.isEmpty()) + { + throw new ServiceException(preview.getMissingNos().isEmpty() ? "没有可生成的订单" : "订单号没有对上:" + String.join("、", preview.getMissingNos())); + } + String currency = currencyOf(orders); + Map versions = new HashMap<>(); + for (PayBillOrder order : orders) + { + PayOrderCostResult cost = payOrderCostService.generate(order.getBillId()); + if (cost != null) + { + versions.put(order.getBillId(), cost.getVersionNo()); + } + } + List billIds = new ArrayList<>(); + for (PayBillOrder order : orders) + { + billIds.add(order.getBillId()); + } + List fees = billIds.isEmpty() ? new ArrayList<>() : payBillMapper.selectOrderFees(billIds); + if (!fees.isEmpty() && fees.get(0).getCurrency() != null && !fees.get(0).getCurrency().isBlank()) + { + currency = fees.get(0).getCurrency(); + } + List lines = new ArrayList<>(); + for (PrcPayBillFee fee : fees) + { + PayBillOrder order = findOrder(orders, fee.getBillId()); + PayBillLine line = new PayBillLine(); + line.setOrderBillId(fee.getBillId()); + line.setOrderNo(order == null ? "" : order.getOrderNo()); + line.setLadingNo(order == null ? "" : order.getLadingNo()); + line.setFeeName(fee.getFeeName() == null || fee.getFeeName().isBlank() ? fee.getFeeType() : fee.getFeeName()); + line.setAmount(fee.getAmount()); + line.setCurrency(fee.getCurrency()); + line.setPriceDate(order == null || order.getBizDate() == null ? null : order.getBizDate()); + line.setVersionNo(versions.get(fee.getBillId())); + line.setLineSource("ORDER"); + lines.add(line); + } + if (lines.isEmpty()) + { + throw new ServiceException("订单没有应付费用"); + } + BigDecimal total = BigDecimal.ZERO; + for (PayBillLine line : lines) + { + String lineCurrency = line.getCurrency() == null ? "" : line.getCurrency(); + if (!currency.equals(lineCurrency)) + { + throw new ServiceException("所选订单币种不一致,请按币种分开生成"); + } + total = total.add(line.getAmount() == null ? BigDecimal.ZERO : line.getAmount()); + } + String operator = SecurityUtils.getUsername(); + PayBill bill = new PayBill(); + bill.setBillNo(nextNo()); + bill.setSupplierId(supplier.getSupplierId()); + bill.setSupplierName(supplier.getSupplierName()); + bill.setCurrency(currency); + bill.setSourceType(mode(command)); + bill.setPeriodStart(parseDay(command.getBeginDate())); + bill.setPeriodEnd(parseDay(command.getEndDate())); + bill.setOrderCount(orders.size()); + bill.setTotalAmount(total.setScale(2, RoundingMode.HALF_UP)); + bill.setCreateBy(operator); + bill.setRemark(command.getRemark()); + payBillMapper.insertPayBill(bill); + for (PayBillLine line : lines) + { + line.setBillId(bill.getBillId()); + } + payBillMapper.insertLines(lines); + writeLog(bill.getBillId(), "CREATE", null, "DRAFT", "生成应付账单,订单 " + orders.size() + " 票", operator); + return selectPayBillDetail(bill.getBillId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int confirm(Long billId) + { + PayBill bill = required(billId); + if (!"DRAFT".equals(bill.getStatus())) + { + throw new ServiceException("只有草稿可以确认"); + } + String operator = SecurityUtils.getUsername(); + bill.setStatus("CONFIRMED"); + bill.setUpdateBy(operator); + payBillMapper.updatePayBillStatus(bill); + writeLog(billId, "CONFIRM", "DRAFT", "CONFIRMED", "确认应付账单", operator); + return 1; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int markPaid(Long billId) + { + PayBill bill = required(billId); + if (!"CONFIRMED".equals(bill.getStatus())) + { + throw new ServiceException("请先确认账单,再登记付款"); + } + String operator = SecurityUtils.getUsername(); + bill.setStatus("PAID"); + bill.setUpdateBy(operator); + payBillMapper.updatePayBillStatus(bill); + writeLog(billId, "PAY", "CONFIRMED", "PAID", "登记付款", operator); + return 1; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int voidBill(PayBillCommand command) + { + if (command == null || command.getBillId() == null) + { + throw new ServiceException("请选择应付账单"); + } + if (command.getVoidReason() == null || command.getVoidReason().isBlank()) + { + throw new ServiceException("请填写作废原因"); + } + PayBill bill = required(command.getBillId()); + if ("VOID".equals(bill.getStatus())) + { + throw new ServiceException("账单已经作废"); + } + if ("PAID".equals(bill.getStatus())) + { + throw new ServiceException("已付款的账单不能直接作废,请先冲回付款"); + } + String operator = SecurityUtils.getUsername(); + String from = bill.getStatus(); + bill.setStatus("VOID"); + bill.setVoidReason(command.getVoidReason().trim()); + bill.setVoidBy(operator); + bill.setVoidTime(new Date()); + bill.setUpdateBy(operator); + payBillMapper.updatePayBillStatus(bill); + writeLog(bill.getBillId(), "VOID", from, "VOID", command.getVoidReason().trim(), operator); + return 1; + } + + private PayBillPreview collect(PayBillCommand command, boolean forGenerate) + { + String mode = mode(command); + List orderNos = "ORDER".equals(mode) ? tokens(command.getOrderText()) : new ArrayList<>(); + List ladingNos = "LADING".equals(mode) ? tokens(command.getLadingText()) : new ArrayList<>(); + if ("ORDER".equals(mode) && orderNos.isEmpty()) + { + throw new ServiceException("请填写订单号"); + } + if ("TIME".equals(mode) && (command.getBeginDate() == null || command.getBeginDate().isBlank() + || command.getEndDate() == null || command.getEndDate().isBlank())) + { + throw new ServiceException("请选择计费日起止"); + } + if ("LADING".equals(mode) && ladingNos.isEmpty()) + { + throw new ServiceException("请填写提单号"); + } + List found = payBillMapper.selectCandidateOrders( + orderNos.isEmpty() ? null : orderNos, + "TIME".equals(mode) ? command.getBeginDate() : null, + "TIME".equals(mode) ? command.getEndDate() : null, + ladingNos.isEmpty() ? null : ladingNos); + if ("TIME".equals(mode)) + { + found.removeIf(order -> order.getBizDate() == null); + } + PayBillPreview preview = new PayBillPreview(); + if ("ORDER".equals(mode)) + { + for (String no : orderNos) + { + boolean hit = false; + for (PayBillOrder order : found) + { + if (no.equals(order.getOrderNo())) + { + hit = true; + break; + } + } + if (!hit) + { + preview.getMissingNos().add(no); + } + } + } + for (PayBillOrder order : found) + { + if (order.getOccupiedBillNo() != null && !order.getOccupiedBillNo().isBlank()) + { + preview.getOccupied().add(order); + } + else + { + preview.getOrders().add(order); + } + } + if (preview.getOrders().size() >= MAX_ORDERS) + { + preview.setMessage("一次最多处理 " + MAX_ORDERS + " 票,请缩小范围"); + } + if (!forGenerate && !preview.getMissingNos().isEmpty()) + { + preview.setMessage("未找到订单:" + String.join("、", preview.getMissingNos())); + } + return preview; + } + + private List freeOrders(PayBillPreview preview) + { + return preview.getOrders(); + } + + private String currencyOf(List orders) + { + String currency = ""; + for (PayBillOrder order : orders) + { + if (order.getCurrency() != null && !order.getCurrency().isBlank()) + { + currency = order.getCurrency(); + break; + } + } + return currency.isEmpty() ? "CNY" : currency; + } + + private PayBillOrder findOrder(List orders, String billId) + { + for (PayBillOrder order : orders) + { + if (order.getBillId().equals(billId)) + { + return order; + } + } + return null; + } + + private PayBill required(Long billId) + { + PayBill bill = payBillMapper.selectPayBillById(billId); + if (bill == null) + { + throw new ServiceException("应付账单不存在"); + } + return bill; + } + + private void writeLog(Long billId, String type, String from, String to, String desc, String operator) + { + PayBillLog log = new PayBillLog(); + log.setBillId(billId); + log.setOperationType(type); + log.setFromStatus(from); + log.setToStatus(to); + log.setOperationDesc(desc); + log.setOperatorName(operator); + payBillMapper.insertLog(log); + } + + private String nextNo() + { + return "AP" + new SimpleDateFormat("yyyyMM").format(new Date()) + + IdUtils.fastSimpleUUID().substring(0, 8).toUpperCase(); + } + + private Date parseDay(String text) + { + if (text == null || text.isBlank()) + { + return null; + } + LocalDate day = LocalDate.parse(text.trim()); + return java.sql.Date.valueOf(day); + } + + private String mode(PayBillCommand command) + { + if (command == null || command.getSourceType() == null || command.getSourceType().isBlank()) + { + throw new ServiceException("请选择查询方式"); + } + return command.getSourceType().trim().toUpperCase(Locale.ROOT); + } + + static List tokens(String text) + { + LinkedHashSet values = new LinkedHashSet<>(); + if (text == null) + { + return new ArrayList<>(); + } + for (String part : text.split("[,,、;;\\s]+")) + { + String token = part.trim(); + if (!token.isEmpty()) + { + values.add(token); + } + } + return new ArrayList<>(values); + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayOrderCostServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayOrderCostServiceImpl.java new file mode 100644 index 00000000..28e5fec7 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayOrderCostServiceImpl.java @@ -0,0 +1,317 @@ +package com.bms.price.service.impl; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.bms.common.exception.ServiceException; +import com.bms.common.utils.SecurityUtils; +import com.bms.masterdata.domain.ServiceChannel; +import com.bms.masterdata.mapper.ServiceChannelMapper; +import com.bms.price.domain.PayCostLine; +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.PayWeightBand; +import com.bms.price.domain.PrcOrderBill; +import com.bms.price.domain.PrcOrderBillExt; +import com.bms.price.domain.PrcPayBill; +import com.bms.price.domain.PrcPayBillFee; +import com.bms.price.mapper.PayPostcodeMapper; +import com.bms.price.mapper.PrcOrderBillExtMapper; +import com.bms.price.mapper.PrcOrderBillMapper; +import com.bms.price.mapper.PrcPayBillFeeMapper; +import com.bms.price.mapper.PrcPayBillMapper; +import com.bms.price.pay.PayCostCalculator; +import com.bms.price.pay.PayZoneMatcher; +import com.bms.price.service.IPayOrderCostService; +import com.bms.price.service.IPayProductService; + +/** + * 订单找到渠道,渠道找到当前应付产品,再按重量、尺寸、邮编计算应付并落库。 + */ +@Service +public class PayOrderCostServiceImpl implements IPayOrderCostService +{ + @Autowired + private PrcOrderBillMapper orderBillMapper; + + @Autowired + private PrcOrderBillExtMapper orderBillExtMapper; + + @Autowired + private ServiceChannelMapper serviceChannelMapper; + + @Autowired + private IPayProductService payProductService; + + @Autowired + private PrcPayBillMapper payBillMapper; + + @Autowired + private PrcPayBillFeeMapper payBillFeeMapper; + + @Autowired + private PayPostcodeMapper payPostcodeMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public PayOrderCostResult generate(String billId) + { + if (billId == null || billId.isBlank()) + { + throw new ServiceException("请选择订单"); + } + PrcOrderBill bill = orderBillMapper.selectPrcOrderBillById(billId.trim()); + if (bill == null) + { + throw new ServiceException("订单不存在"); + } + PrcOrderBillExt ext = orderBillExtMapper.selectByBillId(bill.getBillId()); + ServiceChannel channel = resolveChannel(ext); + java.util.Date bizDate = bill.getBizDate() != null ? bill.getBizDate() : bill.getOrderTime(); + PayProduct product = payProductService.selectPriceForDate(channel.getId(), bizDate); + BigDecimal weight = firstPositive(bill.getChargeWeight(), bill.getWeight(), bill.getActualWeight(), bill.getReceiveWeight()); + Integer orderStatus = bill.getOrderStatus() != null ? bill.getOrderStatus() : (ext == null ? null : ext.getOrderStatus()); + String zoneName = resolveZone(channel, product, bill.getDestPostcode(), bill.getCountryCode(), bill.getCountryName()); + PayOrderCostResult result = PayCostCalculator.calculate(product, weight, bill.getPkgLength(), bill.getPkgWidth(), + bill.getPkgHeight(), bill.getDestPostcode(), zoneName, bizDate, orderStatus); + result.setBillId(bill.getBillId()); + result.setChannelId(channel.getId()); + result.setChannelName(channel.getChannelName()); + result.setVersionNo(product.getVersionNo()); + if (result.getFees() == null || result.getFees().isEmpty()) + { + throw new ServiceException(result.getMessage() == null ? "没有算出应付费用" : result.getMessage()); + } + String operator = SecurityUtils.getUsername(); + PrcPayBill payBill = saveBill(bill, result, operator); + saveFees(bill.getBillId(), payBill.getPayBillId(), result.getFees()); + orderBillMapper.updatePayTotal(bill.getBillId(), result.getPayTotal()); + String channelName = channel.getChannelName() == null ? channel.getChannelCode() : channel.getChannelName(); + String end = product.getTermEnd() == null ? "不限" : day(product.getTermEnd()); + result.setMessage("渠道「" + channelName + "」产品编号 " + product.getOriginId() + " 第 " + product.getVersionNo() + + " 版(" + day(product.getTermStart()) + " 至 " + end + ")," + result.getMessage()); + return result; + } + + /** 价卡分成多个地区时,用邮编在对应分区表里选出价格列。 */ + private String resolveZone(ServiceChannel channel, PayProduct product, String postcode, String countryCode, String countryName) + { + List zoneNames = zoneNames(product); + if (zoneNames.size() <= 1) + { + return null; + } + List tokens = PayZoneMatcher.searchTokens(product.getProductName(), product.getSheetName()); + if (tokens.isEmpty()) + { + tokens = PayZoneMatcher.searchTokens(channel.getChannelName(), channel.getChannelCode()); + } + if (tokens.isEmpty()) + { + throw new ServiceException("产品「" + product.getProductName() + "」有多个地区,订单邮编无法对应到产品"); + } + List groups = new ArrayList<>(); + for (PayPostcodeGroup group : payPostcodeMapper.selectGroupsByTokens(tokens)) + { + if (sameCountry(group, countryCode, countryName)) + { + groups.add(group); + } + } + if (groups.isEmpty()) + { + throw new ServiceException("产品「" + product.getProductName() + "」有多个地区,没有找到对应的邮编分区"); + } + List groupIds = new ArrayList<>(); + for (PayPostcodeGroup group : groups) + { + groupIds.add(group.getGroupId()); + } + Set usable = new LinkedHashSet<>(); + for (PayPostcodeLine key : payPostcodeMapper.selectRegionKeys(groupIds)) + { + String region = key.getZoneName() != null && !key.getZoneName().isBlank() ? key.getZoneName() : key.getRegionName(); + if (PayZoneMatcher.mapRegion(zoneNames, region) != null) + { + usable.add(key.getGroupId()); + } + } + if (usable.isEmpty()) + { + throw new ServiceException("产品「" + product.getProductName() + "」有多个地区,邮编分区对不上价卡上的地区"); + } + PayZoneMatcher.Decision decision = PayZoneMatcher.resolve(zoneNames, payPostcodeMapper.selectLinesByGroups(new ArrayList<>(usable)), postcode); + if (decision.getMessage() != null) + { + throw new ServiceException(decision.getMessage()); + } + if (decision.getZoneName() == null) + { + throw new ServiceException("邮编没有对上产品「" + product.getProductName() + "」的地区"); + } + return decision.getZoneName(); + } + + private static List zoneNames(PayProduct product) + { + Set names = new LinkedHashSet<>(); + if (product.getWeightBands() == null) + { + return new ArrayList<>(); + } + for (PayWeightBand band : product.getWeightBands()) + { + if (band != null && band.getZoneName() != null && !band.getZoneName().isBlank()) + { + names.add(band.getZoneName().trim()); + } + } + return new ArrayList<>(names); + } + + private static boolean sameCountry(PayPostcodeGroup group, String countryCode, String countryName) + { + if (blank(countryCode) && blank(countryName)) + { + return true; + } + if (blank(group.getCountryCode()) && blank(group.getCountryName())) + { + return true; + } + if (!blank(countryCode) && countryCode.equalsIgnoreCase(group.getCountryCode())) + { + return true; + } + return !blank(countryName) && countryName.equals(group.getCountryName()); + } + + private ServiceChannel resolveChannel(PrcOrderBillExt ext) + { + if (ext == null) + { + throw new ServiceException("订单没有渠道服务商"); + } + ServiceChannel channel = blank(ext.getProductChannelId()) ? null : serviceChannelMapper.selectServiceChannelById(ext.getProductChannelId().trim()); + if (channel == null && !blank(ext.getProductFloorCode())) + { + channel = serviceChannelMapper.selectServiceChannelByCode(ext.getProductFloorCode().trim()); + } + if (channel == null && !blank(ext.getProductFloor())) + { + List named = serviceChannelMapper.selectServiceChannelByName(ext.getProductFloor().trim()); + if (named != null && named.size() > 1) + { + throw new ServiceException("渠道名称「" + ext.getProductFloor().trim() + "」对应了多条渠道,请先在订单上指定渠道"); + } + channel = named == null || named.isEmpty() ? null : named.get(0); + } + if (channel == null) + { + throw new ServiceException("订单没有渠道服务商,或渠道尚未同步"); + } + return channel; + } + + private PrcPayBill saveBill(PrcOrderBill bill, PayOrderCostResult result, String operator) + { + PrcPayBill existed = payBillMapper.selectByBillId(bill.getBillId()); + PrcPayBill row = existed == null ? new PrcPayBill() : existed; + if (existed == null) + { + row.setPayBillId(UUID.randomUUID().toString()); + row.setBillId(bill.getBillId()); + row.setCreateBy(operator); + } + row.setCurrency(result.getCurrency()); + row.setCostAmount(result.getPayTotal()); + row.setPayTotal(result.getPayTotal()); + row.setBelongSaler(bill.getBelongSaler()); + row.setBelongSalerCode(bill.getBelongSalerCode()); + row.setSettlementMan(bill.getSettlementMan()); + row.setSettlementManCode(bill.getSettlementManCode()); + row.setUpdateBy(operator); + if (existed == null) + { + payBillMapper.insertPrcPayBill(row); + } + else + { + payBillMapper.updatePrcPayBill(row); + } + return row; + } + + private void saveFees(String billId, String payBillId, List fees) + { + payBillFeeMapper.deleteByBillId(billId); + List rows = new java.util.ArrayList<>(); + long fallback = 1L; + for (PayCostLine fee : fees) + { + PrcPayBillFee row = new PrcPayBillFee(); + row.setFeeId(UUID.randomUUID().toString()); + row.setBillId(billId); + row.setPayBillId(payBillId); + row.setFeeType(fee.getFeeType()); + row.setFeeName(fee.getFeeName()); + long scId = fee.getScId() == null ? 0L : fee.getScId(); + if ("SURCHARGE".equals(fee.getFeeType()) && scId == 0L) + { + scId = fallback++; + } + row.setScId(scId); + row.setAmount(fee.getAmount()); + row.setCurrency(fee.getCurrency()); + row.setRoundMode("HALF_UP"); + row.setRemark(fee.getRemark()); + rows.add(row); + } + if (!rows.isEmpty()) + { + payBillFeeMapper.insertBatch(rows); + } + } + + private static String day(java.util.Date date) + { + if (date == null) + { + return "不限"; + } + if (date instanceof java.sql.Date sql) + { + return sql.toLocalDate().toString(); + } + return date.toInstant().atZone(java.time.ZoneId.of("GMT+8")).toLocalDate().toString(); + } + + private static BigDecimal firstPositive(BigDecimal... values) + { + if (values == null) + { + return null; + } + for (BigDecimal value : values) + { + if (value != null && value.compareTo(BigDecimal.ZERO) > 0) + { + return value; + } + } + return null; + } + + private static boolean blank(String value) + { + return value == null || value.isBlank(); + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayPostcodeServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayPostcodeServiceImpl.java new file mode 100644 index 00000000..104b9d3d --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayPostcodeServiceImpl.java @@ -0,0 +1,177 @@ +package com.bms.price.service.impl; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.bms.common.exception.ServiceException; +import com.bms.price.domain.PayPostcodeGroup; +import com.bms.price.domain.PayPostcodeImportResult; +import com.bms.price.domain.PayPostcodeLine; +import com.bms.price.mapper.PayPostcodeMapper; +import com.bms.price.pay.PayPostcodeImporter; +import com.bms.price.service.IPayPostcodeService; + +/** + * 导入时按文件和工作表覆盖。附加费在解析阶段已经按国家和金额合并。 + */ +@Service +public class PayPostcodeServiceImpl implements IPayPostcodeService +{ + private static final int BATCH = 400; + + @Autowired + private PayPostcodeMapper payPostcodeMapper; + + private final PayPostcodeImporter importer = new PayPostcodeImporter(); + + @Override + public List selectGroupList(PayPostcodeGroup query) + { + return payPostcodeMapper.selectGroupList(query); + } + + @Override + public PayPostcodeGroup selectGroupById(Long groupId) + { + return payPostcodeMapper.selectGroupById(groupId); + } + + @Override + public int updateGroup(PayPostcodeGroup group) + { + if (group == null || group.getGroupId() == null) + { + throw new ServiceException("请选择要修改的邮编配置"); + } + if (payPostcodeMapper.selectGroupById(group.getGroupId()) == null) + { + throw new ServiceException("邮编配置不存在或已删除"); + } + group.setCoverage(normalizeCoverage(group.getCoverage())); + group.setChannelName(trimToEmpty(group.getChannelName())); + group.setCountryName(trimToEmpty(group.getCountryName())); + group.setCountryCode(trimToEmpty(group.getCountryCode())); + group.setServiceType(trimToEmpty(group.getServiceType())); + group.setCatalogCountry(trimToEmpty(group.getCatalogCountry())); + group.setProductName(trimToEmpty(group.getProductName())); + group.setQuoteSheet(trimToEmpty(group.getQuoteSheet())); + group.setFeeName(trimToEmpty(group.getFeeName())); + group.setFeeText(trimToEmpty(group.getFeeText())); + group.setBlockTitle(trimToEmpty(group.getBlockTitle())); + group.setRemark(trimToEmpty(group.getRemark())); + if (!"FEE".equals(group.getConfigKind()) && !"ZONE".equals(group.getConfigKind())) + { + throw new ServiceException("类型只能是附加费或邮编分区"); + } + return payPostcodeMapper.updateGroup(group); + } + + @Override + public List selectLineList(PayPostcodeLine query) + { + return payPostcodeMapper.selectLineList(query); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public PayPostcodeImportResult importExcel(byte[] bytes, String filename, String username) + { + List groups = importer.parse(bytes, filename); + if (groups.isEmpty()) + { + throw new ServiceException("没有识别到邮编或附加费邮编,请确认表头里有邮编、ZIP 或附加费"); + } + Set sheets = new LinkedHashSet<>(); + for (PayPostcodeGroup group : groups) + { + sheets.add(group.getSheetName()); + } + String md5 = groups.get(0).getFileMd5(); + for (String sheet : sheets) + { + List oldIds = payPostcodeMapper.selectGroupIds(md5, sheet); + if (oldIds != null && !oldIds.isEmpty()) + { + payPostcodeMapper.deleteLines(oldIds); + payPostcodeMapper.deleteGroups(oldIds); + } + } + int feeCount = 0; + int zoneCount = 0; + int lineCount = 0; + for (PayPostcodeGroup group : groups) + { + group.setCreateBy(username); + if ("FEE".equals(group.getConfigKind())) + { + feeCount++; + } + else + { + zoneCount++; + } + payPostcodeMapper.insertGroup(group); + List lines = group.getLines(); + for (PayPostcodeLine line : lines) + { + line.setGroupId(group.getGroupId()); + } + for (int i = 0; i < lines.size(); i += BATCH) + { + payPostcodeMapper.insertLines(new ArrayList<>(lines.subList(i, Math.min(i + BATCH, lines.size())))); + } + lineCount += lines.size(); + } + PayPostcodeImportResult result = new PayPostcodeImportResult(); + result.setSourceFile(groups.get(0).getSourceFile()); + result.setGroupCount(groups.size()); + result.setFeeCount(feeCount); + result.setZoneCount(zoneCount); + result.setLineCount(lineCount); + result.setMessage("已导入 " + groups.size() + " 条配置,其中附加费 " + feeCount + " 条、邮编分区 " + zoneCount + " 条,邮编 " + lineCount + " 行"); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int deleteByIds(Long[] groupIds) + { + if (groupIds == null || groupIds.length == 0) + { + return 0; + } + List ids = new ArrayList<>(); + for (Long id : groupIds) + { + if (id != null) + { + ids.add(id); + } + } + if (ids.isEmpty()) + { + return 0; + } + payPostcodeMapper.deleteLines(ids); + return payPostcodeMapper.deleteGroups(ids); + } + + private static String trimToEmpty(String value) + { + return value == null ? "" : value.trim(); + } + + private static String normalizeCoverage(String value) + { + String coverage = trimToEmpty(value).toUpperCase(java.util.Locale.ROOT); + if (coverage.isEmpty() || "REACH".equals(coverage) || "UNREACH".equals(coverage) || "REMOTE".equals(coverage)) + { + return coverage; + } + throw new ServiceException("邮编属性只能是可达、不可达或偏远"); + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayProductServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayProductServiceImpl.java new file mode 100644 index 00000000..7c3e700c --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PayProductServiceImpl.java @@ -0,0 +1,1077 @@ +package com.bms.price.service.impl; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.bms.common.core.domain.entity.SysDictData; +import com.bms.common.exception.ServiceException; +import com.bms.common.utils.SecurityUtils; +import com.bms.price.domain.ChannelProductMap; +import com.bms.price.domain.PayExtraLine; +import com.bms.price.domain.PayPreview; +import com.bms.price.domain.PayPriceCompare; +import com.bms.price.domain.PayPriceDiff; +import com.bms.price.domain.PayProduct; +import com.bms.price.domain.PayProductTerm; +import com.bms.price.domain.PaySaveCommand; +import com.bms.price.domain.PaySurcharge; +import com.bms.price.domain.PayWeightBand; +import com.bms.price.mapper.ChannelProductMapMapper; +import com.bms.price.mapper.PayProductMapper; +import com.bms.price.mapper.PayProductTermMapper; +import com.bms.price.pay.PayWorkbookParser; +import com.bms.price.service.IPayProductService; +import com.bms.system.service.ISysDictTypeService; + +/** + * 应付价卡保存。同一文件、同一工作表、同一产品名称视为同一条配置。 + */ +@Service +public class PayProductServiceImpl implements IPayProductService +{ + private static final int BATCH = 400; + + private static final String DICT_TYPE = "payment_product_type"; + + private static final String PAY_TYPE_AP = "AP"; + + private static final String PAY_TYPE_AR = "AR"; + + @Autowired + private PayProductMapper payProductMapper; + + @Autowired + private ChannelProductMapMapper channelProductMapMapper; + + @Autowired + private PayProductTermMapper payProductTermMapper; + + @Autowired + private ISysDictTypeService dictTypeService; + + private final PayWorkbookParser parser = new PayWorkbookParser(); + + @Override + public PayPreview preview(byte[] bytes, String filename, String dictValue) + { + PayPreview preview = parser.parseMatched(bytes, filename, dictValue, loadDictValues()); + for (PayProduct product : preview.getProducts()) + { + Long existsId = isBlank(product.getDictValue()) ? null : payProductMapper.selectTemplateId(product.getDictValue()); + product.setDuplicated(existsId != null); + product.setExistsId(existsId); + } + return preview; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public String save(PaySaveCommand command) + { + if (command == null || command.getProducts() == null || command.getProducts().isEmpty()) + { + throw new ServiceException("请先勾选要保存的产品"); + } + if (command.getProducts().size() > 80) + { + throw new ServiceException("一次最多保存80个产品"); + } + String operator = SecurityUtils.getUsername(); + int saved = 0; + int replaced = 0; + int skipped = 0; + for (PayProduct product : command.getProducts()) + { + if (product == null || isBlank(product.getProductName()) || isBlank(product.getSheetName()) || isBlank(product.getFileMd5())) + { + throw new ServiceException("产品名称、工作表和文件摘要不能为空"); + } + Long existsId = templateId(product); + if (existsId != null && !command.isOverwrite()) + { + skipped++; + continue; + } + product.setProductType(requireProductType(product.getProductType())); + prepareCounts(product); + fitColumns(product); + product.setProductId(null); + product.setChannelId(null); + product.setCreateBy(operator); + product.setCreateTime(new Date()); + if (existsId != null) + { + appendVersion(existsId, product, operator); + replaced++; + } + else + { + product.setOriginId(null); + product.setVersionNo(1); + payProductMapper.insertPayProduct(product); + insertBands(product); + insertSurcharges(product); + insertExtras(product); + openTerm(product, false); + } + saved++; + } + return "已保存" + saved + "个产品,新版本" + replaced + "个,跳过" + skipped + "个已存在的产品"; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int updateDetail(PayProduct product) + { + if (product == null || product.getProductId() == null) + { + throw new ServiceException("请选择要修改的产品"); + } + PayProduct existing = payProductMapper.selectPayProductById(product.getProductId()); + if (existing == null) + { + throw new ServiceException("产品不存在或已删除"); + } + if (!isBlank(existing.getChannelId())) + { + throw new ServiceException("渠道价格请在渠道页面保存,这里只改公共模板"); + } + product.setDictValue(blankTo(product.getDictValue(), existing.getDictValue())); + product.setOriginId(existing.getOriginId()); + product.setChannelId(null); + product.setVersionNo(existing.getVersionNo() == null ? 1 : existing.getVersionNo()); + product.setProductType(isBlank(product.getProductType()) ? existing.getProductType() : payTypeOf(product.getProductType())); + product.setFileMd5(blankTo(product.getFileMd5(), existing.getFileMd5())); + product.setSourceFile(blankTo(product.getSourceFile(), existing.getSourceFile())); + product.setSheetName(blankTo(product.getSheetName(), existing.getSheetName())); + product.setUpdateBy(SecurityUtils.getUsername()); + prepareCounts(product); + fitColumns(product); + payProductMapper.updatePayProduct(product); + replaceChildren(product.getProductId(), product); + return 1; + } + + @Override + public List selectTemplateOptions(String payType) + { + if (!PAY_TYPE_AP.equals(payTypeOf(payType))) + { + return Collections.emptyList(); + } + return payProductMapper.selectTemplateOptions(); + } + + @Override + public PayProduct selectChannelPrice(String channelId) + { + if (isBlank(channelId)) + { + return null; + } + Long root = channelProductRoot(channelId); + if (root == null) + { + return null; + } + return loadVersion(root, today(), false); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public PayProduct saveChannelVersion(String channelId, PayProduct edited) + { + if (isBlank(channelId)) + { + throw new ServiceException("请先保存渠道,再维护价格"); + } + if (edited == null) + { + throw new ServiceException("没有可保存的价格"); + } + Long root = resolveRoot(edited.getOriginId() != null ? edited.getOriginId() : edited.getProductId()); + String operator = SecurityUtils.getUsername(); + PayProduct saved = appendVersion(root, edited, operator); + saved.setOriginId(root); + return saved; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public PayProduct reimportChannel(String channelId, byte[] bytes, String filename) + { + Long root = channelProductRoot(channelId); + PayProduct current = root == null ? null : payProductMapper.selectPayProductById(root); + String dictValue = current == null ? null : blankTo(current.getDictValue(), current.getProductName()); + PayPreview preview = preview(bytes, filename, dictValue); + if (preview.getProducts().size() != 1) + { + throw new ServiceException("重新导入时请使用单个产品文件,或先选定渠道上的应付产品"); + } + PayProduct parsed = preview.getProducts().get(0); + if (root == null) + { + root = parsed.getExistsId() != null ? resolveRoot(parsed.getExistsId()) : null; + } + if (root == null) + { + throw new ServiceException("请先选择一个已导入的应付产品"); + } + PayProduct saved = appendVersion(root, parsed, SecurityUtils.getUsername()); + saved.setOriginId(root); + return saved; + } + + private Set loadDictValues() + { + List rows = dictTypeService.selectDictDataByType(DICT_TYPE); + Set values = new LinkedHashSet<>(); + if (rows != null) + { + for (SysDictData row : rows) + { + if (row == null || isBlank(row.getDictValue())) + { + continue; + } + if (row.getStatus() != null && !"0".equals(row.getStatus())) + { + continue; + } + values.add(row.getDictValue().trim()); + } + } + if (values.isEmpty()) + { + throw new ServiceException("应付产品字典还没有数据"); + } + return values; + } + + private Long templateId(PayProduct product) + { + if (!isBlank(product.getDictValue())) + { + return payProductMapper.selectTemplateId(product.getDictValue()); + } + if (product.getExistsId() != null) + { + return product.getExistsId(); + } + if (isBlank(product.getFileMd5()) || isBlank(product.getSheetName()) || isBlank(product.getProductName())) + { + return null; + } + return payProductMapper.selectExistingId(product.getFileMd5(), product.getSheetName(), product.getProductName()); + } + + private void prepareCounts(PayProduct product) + { + if (product.getWeightBands() == null) + { + product.setWeightBands(new ArrayList<>()); + } + if (product.getSurcharges() == null) + { + product.setSurcharges(new ArrayList<>()); + } + if (product.getExtraLines() == null) + { + product.setExtraLines(new ArrayList<>()); + } + product.setWeightCount(product.getWeightBands().size()); + product.setSurchargeCount(product.getSurcharges().size()); + product.setExtraCount(product.getExtraLines().size()); + if ("续重计费".equals(product.getChargeType())) + { + product.setExtraPerKg(PayWorkbookParser.extraPerKgOf(product)); + } + else if ("单票价".equals(product.getChargeType()) || "常规计重价".equals(product.getChargeType())) + { + product.setExtraPerKg(null); + } + if (isBlank(product.getParseStatus())) + { + product.setParseStatus(product.getWeightCount() + product.getSurchargeCount() + product.getExtraCount() == 0 ? "EMPTY" : "OK"); + } + } + + private void replaceChildren(Long productId, PayProduct product) + { + List ids = Collections.singletonList(productId); + payProductMapper.deleteWeightBands(ids); + payProductMapper.deleteSurcharges(ids); + payProductMapper.deleteExtraLines(ids); + product.setProductId(productId); + insertBands(product); + insertSurcharges(product); + insertExtras(product); + } + + private PayProduct copyHeader(PayProduct origin, PayProduct edited) + { + PayProduct copy = new PayProduct(); + copy.setProductName(blankTo(edited.getProductName(), origin.getProductName())); + copy.setProductType(blankTo(edited.getProductType(), origin.getProductType())); + copy.setDictValue(blankTo(edited.getDictValue(), origin.getDictValue())); + copy.setCountryScope(blankTo(edited.getCountryScope(), origin.getCountryScope())); + copy.setChargeType(blankTo(edited.getChargeType(), origin.getChargeType())); + copy.setExtraPerKg(blankTo(edited.getExtraPerKg(), origin.getExtraPerKg())); + copy.setServiceType(blankTo(edited.getServiceType(), origin.getServiceType())); + copy.setClassification(blankTo(edited.getClassification(), origin.getClassification())); + copy.setCatalogRemark(blankTo(edited.getCatalogRemark(), origin.getCatalogRemark())); + copy.setUpdateContent(blankTo(edited.getUpdateContent(), origin.getUpdateContent())); + copy.setWeightLimit(blankTo(edited.getWeightLimit(), origin.getWeightLimit())); + copy.setSheetName(blankTo(edited.getSheetName(), origin.getSheetName())); + copy.setRelatedSheet(blankTo(edited.getRelatedSheet(), origin.getRelatedSheet())); + copy.setSourceFile(blankTo(edited.getSourceFile(), origin.getSourceFile())); + copy.setFileMd5(blankTo(edited.getFileMd5(), origin.getFileMd5())); + copy.setSupplierName(blankTo(edited.getSupplierName(), origin.getSupplierName())); + copy.setEffectiveDate(edited.getEffectiveDate() != null ? edited.getEffectiveDate() : origin.getEffectiveDate()); + copy.setCurrency(blankTo(edited.getCurrency(), origin.getCurrency())); + copy.setServiceNote(blankTo(edited.getServiceNote(), origin.getServiceNote())); + copy.setParseStatus(blankTo(edited.getParseStatus(), origin.getParseStatus())); + copy.setRemark(edited.getRemark() != null ? edited.getRemark() : origin.getRemark()); + copy.setWeightBands(edited.getWeightBands() != null ? edited.getWeightBands() : new ArrayList<>()); + copy.setSurcharges(edited.getSurcharges() != null ? edited.getSurcharges() : new ArrayList<>()); + copy.setExtraLines(edited.getExtraLines() != null ? edited.getExtraLines() : new ArrayList<>()); + return copy; + } + + @Override + public List listTerms(Long productId) + { + PayProduct product = productId == null ? null : payProductMapper.selectPayProductById(productId); + if (product == null) + { + throw new ServiceException("产品不存在或已删除"); + } + return payProductTermMapper.selectByDict(termKey(product)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int updateTerm(PayProductTerm term) + { + if (term == null || term.getEffectiveStart() == null) + { + throw new ServiceException("请填写生效开始"); + } + if (term.getEffectiveEnd() != null && term.getEffectiveEnd().before(term.getEffectiveStart())) + { + throw new ServiceException("生效结束不能早于生效开始"); + } + String operator = SecurityUtils.getUsername(); + if (term.getTermId() == null) + { + if (term.getProductId() == null) + { + throw new ServiceException("请选择要修改的版本"); + } + PayProduct product = payProductMapper.selectPayProductById(term.getProductId()); + if (product == null) + { + throw new ServiceException("产品不存在或已删除"); + } + PayProductTerm exists = payProductTermMapper.selectByProductId(product.getProductId()); + if (exists != null) + { + term.setTermId(exists.getTermId()); + } + else + { + term.setDictValue(termKey(product)); + term.setVersionNo(product.getVersionNo() == null ? 1 : product.getVersionNo()); + term.setStatus("0"); + term.setCreateBy(operator); + payProductTermMapper.insertTerm(term); + } + } + if (term.getTermId() != null) + { + PayProductTerm existing = payProductTermMapper.selectById(term.getTermId()); + if (existing == null) + { + throw new ServiceException("有效期不存在"); + } + term.setProductId(existing.getProductId()); + term.setUpdateBy(operator); + rejectOverlap(existing, term); + payProductTermMapper.updatePeriod(term); + } + payProductTermMapper.updateProductEffectiveDate(term.getProductId(), term.getEffectiveStart()); + return 1; + } + + @Override + public int changeTermStatus(PayProductTerm term) + { + if (term == null || (!"0".equals(term.getStatus()) && !"1".equals(term.getStatus()))) + { + throw new ServiceException("请选择生效或失效"); + } + if (term.getTermId() == null && term.getProductId() != null) + { + PayProductTerm exists = payProductTermMapper.selectByProductId(term.getProductId()); + if (exists != null) + { + term.setTermId(exists.getTermId()); + } + } + if (term.getTermId() == null) + { + throw new ServiceException("请选择要修改的版本"); + } + term.setUpdateBy(SecurityUtils.getUsername()); + return payProductTermMapper.updateStatus(term); + } + + /** 给刚保存的这一版记一条生效记录。新版本从生效日开始,旧版本收到生效日前一天。 */ + private void openTerm(PayProduct product, boolean replacePrevious) + { + String key = termKey(product); + if (product.getEffectiveDate() == null) + { + product.setEffectiveDate(today()); + } + if (replacePrevious) + { + java.time.LocalDate start = toLocalDate(product.getEffectiveDate()); + payProductTermMapper.closeOpenTerms(key, toDate(start.minusDays(1)), product.getCreateBy()); + } + PayProductTerm term = new PayProductTerm(); + term.setDictValue(key); + term.setProductId(product.getProductId()); + term.setVersionNo(product.getVersionNo() == null ? 1 : product.getVersionNo()); + term.setEffectiveStart(product.getEffectiveDate()); + term.setStatus("1"); + term.setCreateBy(product.getCreateBy()); + payProductTermMapper.insertTerm(term); + } + + private String termKey(PayProduct product) + { + return blankTo(product.getDictValue(), product.getProductName()); + } + + @Override + public List selectPayProductList(PayProduct query) + { + return payProductMapper.selectPayProductList(query); + } + + @Override + public List selectPayProductDetails(PayProduct query) + { + List products = payProductMapper.selectPayProductList(query); + loadDetails(products); + return products; + } + + @Override + public PayProduct selectPayProductDetail(Long productId) + { + PayProduct product = payProductMapper.selectPayProductById(productId); + if (product == null) + { + return null; + } + List ids = Collections.singletonList(productId); + product.setWeightBands(payProductMapper.selectWeightBands(ids)); + product.setSurcharges(payProductMapper.selectSurcharges(ids)); + product.setExtraLines(payProductMapper.selectExtraLines(ids)); + return product; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int deletePayProducts(Long[] productIds) + { + if (productIds == null || productIds.length == 0) + { + return 0; + } + List ids = new ArrayList<>(); + for (Long productId : productIds) + { + if (productId == null) + { + continue; + } + PayProduct product = payProductMapper.selectPayProductById(productId); + Long root = product == null || product.getOriginId() == null ? productId : product.getOriginId(); + channelProductMapMapper.deleteByProductId(root); + List family = payProductMapper.selectFamilyIds(root); + if (family != null) + { + ids.addAll(family); + } + } + if (ids.isEmpty()) + { + return 0; + } + payProductTermMapper.deleteByProductIds(ids); + payProductMapper.deleteWeightBands(ids); + payProductMapper.deleteSurcharges(ids); + payProductMapper.deleteExtraLines(ids); + return payProductMapper.deletePayProducts(ids); + } + + /** 按当前查询条件装上子表,供明细导出使用 */ + public Map loadDetails(List products) + { + Map map = new LinkedHashMap<>(); + if (products == null || products.isEmpty()) + { + return map; + } + List ids = new ArrayList<>(); + for (PayProduct product : products) + { + map.put(product.getProductId(), product); + ids.add(product.getProductId()); + product.setWeightBands(new ArrayList<>()); + product.setSurcharges(new ArrayList<>()); + product.setExtraLines(new ArrayList<>()); + } + for (PayWeightBand band : payProductMapper.selectWeightBands(ids)) + { + PayProduct owner = map.get(band.getProductId()); + if (owner != null) + { + owner.getWeightBands().add(band); + } + } + for (PaySurcharge fee : payProductMapper.selectSurcharges(ids)) + { + PayProduct owner = map.get(fee.getProductId()); + if (owner != null) + { + owner.getSurcharges().add(fee); + } + } + for (PayExtraLine line : payProductMapper.selectExtraLines(ids)) + { + PayProduct owner = map.get(line.getProductId()); + if (owner != null) + { + owner.getExtraLines().add(line); + } + } + return map; + } + + private void insertBands(PayProduct product) + { + List rows = product.getWeightBands(); + if (rows == null || rows.isEmpty()) + { + return; + } + for (PayWeightBand row : rows) + { + row.setProductId(product.getProductId()); + row.setVersionNo(product.getVersionNo() == null ? 1 : product.getVersionNo()); + } + for (int i = 0; i < rows.size(); i += BATCH) + { + payProductMapper.insertWeightBands(rows.subList(i, Math.min(i + BATCH, rows.size()))); + } + } + + private void insertSurcharges(PayProduct product) + { + List rows = product.getSurcharges(); + if (rows == null || rows.isEmpty()) + { + return; + } + for (PaySurcharge row : rows) + { + row.setProductId(product.getProductId()); + row.setVersionNo(product.getVersionNo() == null ? 1 : product.getVersionNo()); + } + for (int i = 0; i < rows.size(); i += BATCH) + { + payProductMapper.insertSurcharges(rows.subList(i, Math.min(i + BATCH, rows.size()))); + } + } + + private void insertExtras(PayProduct product) + { + List rows = product.getExtraLines(); + if (rows == null || rows.isEmpty()) + { + return; + } + if (rows.size() > 20000) + { + rows = new ArrayList<>(rows.subList(0, 20000)); + product.setExtraLines(rows); + product.setExtraCount(20000); + } + for (PayExtraLine row : rows) + { + row.setProductId(product.getProductId()); + } + for (int i = 0; i < rows.size(); i += BATCH) + { + payProductMapper.insertExtraLines(rows.subList(i, Math.min(i + BATCH, rows.size()))); + } + } + + /** 按表字段长度截断,整本价卡里的长说明不会把保存打断 */ + private void fitColumns(PayProduct product) + { + product.setProductName(cut(product.getProductName(), 200)); + product.setDictValue(cut(product.getDictValue(), 100)); + product.setChannelId(cut(product.getChannelId(), 64)); + product.setCountryScope(cut(product.getCountryScope(), 200)); + product.setChargeType(cut(product.getChargeType(), 50)); + product.setExtraPerKg(cut(product.getExtraPerKg(), 40)); + product.setServiceType(cut(product.getServiceType(), 100)); + product.setClassification(cut(product.getClassification(), 100)); + product.setCatalogRemark(cut(product.getCatalogRemark(), 1000)); + product.setUpdateContent(cut(product.getUpdateContent(), 500)); + product.setWeightLimit(cut(product.getWeightLimit(), 100)); + product.setSheetName(cut(product.getSheetName(), 200)); + product.setRelatedSheet(cut(product.getRelatedSheet(), 500)); + product.setSourceFile(cut(product.getSourceFile(), 500)); + product.setSupplierName(cut(product.getSupplierName(), 100)); + product.setCurrency(cut(product.getCurrency(), 20)); + product.setServiceNote(cut(product.getServiceNote(), 4000)); + product.setParseStatus(cut(product.getParseStatus(), 20)); + product.setRemark(cut(product.getRemark(), 500)); + if (product.getWeightBands() != null) + { + for (PayWeightBand band : product.getWeightBands()) + { + band.setSectionName(cut(band.getSectionName(), 200)); + band.setCountryName(cut(band.getCountryName(), 200)); + band.setCountryCode(cut(band.getCountryCode(), 32)); + band.setZoneName(cut(band.getZoneName(), 200)); + band.setWeightLabel(cut(band.getWeightLabel(), 100)); + band.setVolumeText(cut(band.getVolumeText(), 40)); + band.setChargeName(cut(band.getChargeName(), 200)); + band.setPriceUnit(cut(band.getPriceUnit(), 40)); + } + } + if (product.getSurcharges() != null) + { + for (PaySurcharge fee : product.getSurcharges()) + { + fee.setSectionName(cut(fee.getSectionName(), 200)); + fee.setFeeName(cut(fee.getFeeName(), 200)); + fee.setZoneName(cut(fee.getZoneName(), 200)); + fee.setChargeText(cut(fee.getChargeText(), 500)); + fee.setCurrency(cut(fee.getCurrency(), 20)); + fee.setFeeDesc(cut(fee.getFeeDesc(), 2000)); + fee.setPeriodText(cut(fee.getPeriodText(), 500)); + fee.setChargeTiming(cut(fee.getChargeTiming(), 40)); + } + } + if (product.getExtraLines() != null) + { + for (PayExtraLine line : product.getExtraLines()) + { + line.setLineType(cut(line.getLineType(), 20)); + line.setLineText(cut(line.getLineText(), 1000)); + } + } + } + + @Override + public void bindChannel(String channelId, Long productId, Long priceSetId, String payType) + { + if (isBlank(channelId) || productId == null) + { + throw new ServiceException("请先保存渠道,再选择产品"); + } + String type = payTypeOf(payType); + Long storedId = PAY_TYPE_AP.equals(type) ? resolveRoot(productId) : productId; + ChannelProductMap row = new ChannelProductMap(); + row.setChannelId(channelId); + row.setProductId(storedId); + row.setPriceSetId(priceSetId); + row.setPayType(type); + String operator = SecurityUtils.getUsername(); + row.setCreateBy(operator); + row.setUpdateBy(operator); + channelProductMapMapper.upsert(row); + } + + @Override + public void unbindChannel(String channelId, String payType) + { + if (isBlank(channelId)) + { + return; + } + channelProductMapMapper.deleteByChannelAndType(channelId, payTypeOf(payType)); + } + + @Override + public PayProduct selectPriceForDate(String channelId, Date bizDate) + { + if (isBlank(channelId)) + { + throw new ServiceException("订单没有渠道服务商"); + } + Long root = channelProductRoot(channelId); + if (root == null) + { + throw new ServiceException("渠道还没有选择应付产品"); + } + Date day = bizDate == null ? today() : bizDate; + PayProduct version = loadVersion(root, day, true); + if (version == null) + { + throw new ServiceException("订单时间没有落在价格有效期内"); + } + return version; + } + + @Override + public PayPriceCompare compare(Long leftId, Long rightId) + { + PayProduct left = selectPayProductDetail(leftId); + PayProduct right = selectPayProductDetail(rightId); + if (left == null || right == null) + { + throw new ServiceException("请选择两个价格版本"); + } + int leftNo = left.getVersionNo() == null ? 1 : left.getVersionNo(); + int rightNo = right.getVersionNo() == null ? 1 : right.getVersionNo(); + PayProduct older = leftNo <= rightNo ? left : right; + PayProduct newer = older == left ? right : left; + PayPriceCompare compared = new PayPriceCompare(); + compared.setOlder(older); + compared.setNewer(newer); + compared.setWeightDiffs(diffWeights(older, newer)); + compared.setSurchargeDiffs(diffFees(older, newer)); + return compared; + } + + /** 在同一个产品编号下追加一版价格,不新开产品编号。 */ + private PayProduct appendVersion(Long rootId, PayProduct incoming, String operator) + { + Long root = resolveRoot(rootId); + PayProduct base = payProductMapper.selectPayProductById(root); + PayProduct copy = copyHeader(base, incoming == null ? base : incoming); + copy.setProductId(null); + copy.setOriginId(root); + copy.setChannelId(null); + copy.setDictValue(blankTo(copy.getDictValue(), base.getDictValue())); + Integer maxVersion = payProductMapper.selectMaxVersion(root); + copy.setVersionNo((maxVersion == null ? 1 : maxVersion) + 1); + if (copy.getEffectiveDate() == null) + { + copy.setEffectiveDate(today()); + } + copy.setCreateBy(operator); + copy.setCreateTime(new Date()); + prepareCounts(copy); + fitColumns(copy); + payProductMapper.insertPayProduct(copy); + insertBands(copy); + insertSurcharges(copy); + insertExtras(copy); + openTerm(copy, true); + PayProduct saved = selectPayProductDetail(copy.getProductId()); + if (saved != null) + { + saved.setOriginId(root); + } + return saved; + } + + /** 渠道应付绑定记在映射表,产品编号是不变的根产品。 */ + private Long channelProductRoot(String channelId) + { + ChannelProductMap link = channelProductMapMapper.selectByChannelAndType(channelId, PAY_TYPE_AP); + if (link == null || link.getProductId() == null) + { + return null; + } + return stableId(link.getProductId()); + } + + private String requireProductType(String productType) + { + if (isBlank(productType)) + { + throw new ServiceException("请选择支付类型"); + } + return payTypeOf(productType); + } + + private String payTypeOf(String payType) + { + if (isBlank(payType)) + { + return PAY_TYPE_AP; + } + String type = payType.trim().toUpperCase(); + if (!PAY_TYPE_AP.equals(type) && !PAY_TYPE_AR.equals(type)) + { + throw new ServiceException("收付类型只能是应收AR或应付AP"); + } + return type; + } + + private Long resolveRoot(Long productId) + { + if (productId == null) + { + throw new ServiceException("请先选择一个已导入的应付产品"); + } + return stableId(productId); + } + + private Long stableId(Long productId) + { + PayProduct product = payProductMapper.selectPayProductById(productId); + if (product == null) + { + throw new ServiceException("应付产品不存在或已删除"); + } + return product.getOriginId() == null ? product.getProductId() : product.getOriginId(); + } + + private PayProduct loadVersion(Long root, Date day, boolean required) + { + Long versionId = payProductMapper.selectEffectiveVersionId(root, day); + if (versionId == null && required) + { + PayProduct blocked = payProductMapper.selectInactiveCovering(root, day); + if (blocked != null) + { + String blockedName = blocked.getProductName() == null ? String.valueOf(root) : blocked.getProductName(); + String versionText = blocked.getVersionNo() == null ? "-" : String.valueOf(blocked.getVersionNo()); + throw new ServiceException("未配置应付计算规则:" + blockedName + " 第 " + versionText + " 版,取价日 " + formatDay(day)); + } + } + if (versionId == null && !required) + { + versionId = payProductMapper.selectLatestVersionId(root); + } + if (versionId == null) + { + if (!required) + { + return null; + } + PayProduct rootRow = payProductMapper.selectPayProductById(root); + String name = rootRow == null ? String.valueOf(root) : rootRow.getProductName(); + throw new ServiceException("订单时间 " + formatDay(day) + " 没有落在产品「" + name + "」的价格有效期内"); + } + PayProduct detail = selectPayProductDetail(versionId); + if (detail == null) + { + return null; + } + detail.setOriginId(root); + PayProductTerm term = payProductTermMapper.selectByProductId(versionId); + if (term != null) + { + detail.setTermId(term.getTermId()); + detail.setTermStart(term.getEffectiveStart()); + detail.setTermEnd(term.getEffectiveEnd()); + detail.setTermStatus(term.getStatus()); + detail.setVersionNo(term.getVersionNo()); + } + return detail; + } + + private void rejectOverlap(PayProductTerm current, PayProductTerm edited) + { + List family = payProductTermMapper.selectByDict(current.getDictValue()); + if (family == null) + { + return; + } + for (PayProductTerm other : family) + { + if (other.getTermId() != null && other.getTermId().equals(current.getTermId())) + { + continue; + } + if (overlaps(edited.getEffectiveStart(), edited.getEffectiveEnd(), other.getEffectiveStart(), other.getEffectiveEnd())) + { + throw new ServiceException("有效期与第 " + (other.getVersionNo() == null ? "-" : other.getVersionNo()) + " 版重叠"); + } + } + } + + private static boolean overlaps(Date start, Date end, Date otherStart, Date otherEnd) + { + long left = start == null ? Long.MIN_VALUE : start.getTime(); + long right = end == null ? Long.MAX_VALUE : end.getTime(); + long otherLeft = otherStart == null ? Long.MIN_VALUE : otherStart.getTime(); + long otherRight = otherEnd == null ? Long.MAX_VALUE : otherEnd.getTime(); + return left <= otherRight && otherLeft <= right; + } + + private List diffWeights(PayProduct older, PayProduct newer) + { + Map oldRows = new LinkedHashMap<>(); + Map newRows = new LinkedHashMap<>(); + if (older.getWeightBands() != null) + { + for (PayWeightBand band : older.getWeightBands()) + { + oldRows.put(bandKey(band), band); + } + } + if (newer.getWeightBands() != null) + { + for (PayWeightBand band : newer.getWeightBands()) + { + newRows.put(bandKey(band), band); + } + } + List diffs = new ArrayList<>(); + for (Map.Entry entry : oldRows.entrySet()) + { + PayWeightBand current = newRows.remove(entry.getKey()); + PayPriceDiff diff = new PayPriceDiff(); + diff.setName(blankTo(entry.getValue().getWeightLabel(), "")); + diff.setZoneName(blankTo(entry.getValue().getZoneName(), "")); + diff.setOldValue(priceText(entry.getValue())); + diff.setNewValue(current == null ? "" : priceText(current)); + diff.setChange(current == null ? "删除" : (diff.getOldValue().equals(diff.getNewValue()) ? "相同" : "变更")); + diffs.add(diff); + } + for (PayWeightBand band : newRows.values()) + { + PayPriceDiff diff = new PayPriceDiff(); + diff.setName(blankTo(band.getWeightLabel(), "")); + diff.setZoneName(blankTo(band.getZoneName(), "")); + diff.setOldValue(""); + diff.setNewValue(priceText(band)); + diff.setChange("新增"); + diffs.add(diff); + } + return diffs; + } + + private List diffFees(PayProduct older, PayProduct newer) + { + Map oldRows = new LinkedHashMap<>(); + Map newRows = new LinkedHashMap<>(); + if (older.getSurcharges() != null) + { + for (PaySurcharge fee : older.getSurcharges()) + { + oldRows.put(feeKey(fee), fee); + } + } + if (newer.getSurcharges() != null) + { + for (PaySurcharge fee : newer.getSurcharges()) + { + newRows.put(feeKey(fee), fee); + } + } + List diffs = new ArrayList<>(); + for (Map.Entry entry : oldRows.entrySet()) + { + PaySurcharge current = newRows.remove(entry.getKey()); + PayPriceDiff diff = new PayPriceDiff(); + diff.setName(blankTo(entry.getValue().getFeeName(), "")); + diff.setZoneName(blankTo(entry.getValue().getZoneName(), "")); + diff.setOldValue(blankTo(entry.getValue().getChargeText(), "")); + diff.setNewValue(current == null ? "" : blankTo(current.getChargeText(), "")); + diff.setChange(current == null ? "删除" : (diff.getOldValue().equals(diff.getNewValue()) ? "相同" : "变更")); + diffs.add(diff); + } + for (PaySurcharge fee : newRows.values()) + { + PayPriceDiff diff = new PayPriceDiff(); + diff.setName(blankTo(fee.getFeeName(), "")); + diff.setZoneName(blankTo(fee.getZoneName(), "")); + diff.setOldValue(""); + diff.setNewValue(blankTo(fee.getChargeText(), "")); + diff.setChange("新增"); + diffs.add(diff); + } + return diffs; + } + + private static String bandKey(PayWeightBand band) + { + return blankTo(band.getZoneName(), "") + "|" + blankTo(band.getWeightLabel(), "") + "|" + blankTo(band.getChargeName(), ""); + } + + private static String feeKey(PaySurcharge fee) + { + return blankTo(fee.getFeeName(), "") + "|" + blankTo(fee.getZoneName(), "") + "|" + blankTo(fee.getCalcRule(), ""); + } + + private static String priceText(PayWeightBand band) + { + String price = band.getPrice() == null ? "" : band.getPrice().stripTrailingZeros().toPlainString(); + String unit = blankTo(band.getPriceUnit(), ""); + String volume = blankTo(band.getVolumeText(), ""); + String text = (price + " " + unit).trim(); + return volume.isEmpty() ? text : (text + " " + volume).trim(); + } + + private static Date today() + { + return toDate(java.time.LocalDate.now(java.time.ZoneId.of("GMT+8"))); + } + + private static Date toDate(java.time.LocalDate day) + { + return java.sql.Date.valueOf(day); + } + + private static java.time.LocalDate toLocalDate(Date date) + { + if (date instanceof java.sql.Date sql) + { + return sql.toLocalDate(); + } + return date.toInstant().atZone(java.time.ZoneId.of("GMT+8")).toLocalDate(); + } + + private static String formatDay(Date date) + { + return toLocalDate(date).toString(); + } + + private static String cut(String value, int max) + { + if (value == null || value.length() <= max) + { + return value; + } + return value.substring(0, max); + } + + private static int size(List rows) + { + return rows == null ? 0 : rows.size(); + } + + private static boolean isBlank(String value) + { + return value == null || value.trim().isEmpty(); + } + + private static String blankTo(String value, String fallback) + { + return isBlank(value) ? fallback : value; + } +} diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcProfitReportServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcProfitReportServiceImpl.java index 9ed6a667..8ab3023c 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcProfitReportServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcProfitReportServiceImpl.java @@ -107,6 +107,10 @@ public class PrcProfitReportServiceImpl implements IPrcProfitReportService { return; } + if ("COMMISSION_MISSING".equals(query.getDetailType())) + { + throw new ServiceException("提成功能已暂停使用"); + } query.setOrderNos(splitFilterValues(query.getOrderNo(), "订单号")); query.setCustomerCodes(splitFilterValues(query.getCustomerCode(), "客户代码")); query.setCustomerNames(splitFilterValues(query.getCustomerName(), "客户名称")); diff --git a/bms-back/bms-system/src/main/resources/mapper/masterdata/ServiceChannelMapper.xml b/bms-back/bms-system/src/main/resources/mapper/masterdata/ServiceChannelMapper.xml index 4d694601..8c13255a 100644 --- a/bms-back/bms-system/src/main/resources/mapper/masterdata/ServiceChannelMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/masterdata/ServiceChannelMapper.xml @@ -85,6 +85,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" limit 1 + + diff --git a/bms-back/bms-system/src/main/resources/mapper/price/ChannelProductMapMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/ChannelProductMapMapper.xml new file mode 100644 index 00000000..67b37d14 --- /dev/null +++ b/bms-back/bms-system/src/main/resources/mapper/price/ChannelProductMapMapper.xml @@ -0,0 +1,37 @@ + + + + + + + + insert into srv_channel_product_map ( + channel_id, product_id, price_set_id, pay_type, create_by, create_time, update_by, update_time + ) values ( + #{channelId}, #{productId}, #{priceSetId}, #{payType}, #{createBy}, sysdate(), #{updateBy}, sysdate() + ) + on duplicate key update + product_id = values(product_id), + price_set_id = values(price_set_id), + update_by = values(update_by), + update_time = sysdate() + + + + delete from srv_channel_product_map + where channel_id = #{channelId} and pay_type = #{payType} + + + + delete from srv_channel_product_map where product_id = #{productId} + + diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PayBillMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PayBillMapper.xml new file mode 100644 index 00000000..d4f0d6f1 --- /dev/null +++ b/bms-back/bms-system/src/main/resources/mapper/price/PayBillMapper.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + insert into pay_bill ( + bill_no, supplier_id, supplier_name, currency, source_type, period_start, period_end, + order_count, total_amount, status, create_by, create_time, remark + ) values ( + #{billNo}, #{supplierId}, #{supplierName}, #{currency}, #{sourceType}, #{periodStart}, #{periodEnd}, + #{orderCount}, #{totalAmount}, 'DRAFT', #{createBy}, sysdate(), #{remark} + ) + + + + update pay_bill + set status = #{status}, + void_reason = #{voidReason}, + void_by = #{voidBy}, + void_time = #{voidTime}, + update_by = #{updateBy}, + update_time = sysdate() + where bill_id = #{billId} + + + + insert into pay_bill_line ( + bill_id, order_bill_id, order_no, lading_no, lading_fee_id, fee_name, amount, currency, + price_date, version_no, remote_flag, density_level, line_source + ) values + + (#{item.billId}, #{item.orderBillId}, #{item.orderNo}, #{item.ladingNo}, #{item.ladingFeeId}, + #{item.feeName}, #{item.amount}, #{item.currency}, #{item.priceDate}, #{item.versionNo}, + #{item.remoteFlag}, #{item.densityLevel}, #{item.lineSource}) + + + + + insert into pay_bill_log (bill_id, operation_type, from_status, to_status, operation_desc, operator_name, create_time) + values (#{billId}, #{operationType}, #{fromStatus}, #{toStatus}, #{operationDesc}, #{operatorName}, sysdate()) + + diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PayPostcodeMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PayPostcodeMapper.xml new file mode 100644 index 00000000..be9052d8 --- /dev/null +++ b/bms-back/bms-system/src/main/resources/mapper/price/PayPostcodeMapper.xml @@ -0,0 +1,140 @@ + + + + + + + + + + insert into pay_postcode_group ( + source_file, file_md5, sheet_name, block_title, config_kind, coverage, channel_name, country_name, country_code, + service_type, catalog_country, product_name, quote_sheet, + fee_name, fee_text, line_count, create_by, create_time, remark + ) values ( + #{sourceFile}, #{fileMd5}, #{sheetName}, #{blockTitle}, #{configKind}, #{coverage}, #{channelName}, #{countryName}, #{countryCode}, + #{serviceType}, #{catalogCountry}, #{productName}, #{quoteSheet}, + #{feeName}, #{feeText}, #{lineCount}, #{createBy}, sysdate(), #{remark} + ) + + + + insert into pay_postcode_line ( + group_id, country_name, country_code, region_name, region_name_cn, city_name, region_code, + zone_name, postcode, postcode_raw, postcode_end, extra_text, sort_no + ) values + + (#{item.groupId}, #{item.countryName}, #{item.countryCode}, #{item.regionName}, #{item.regionNameCn}, + #{item.cityName}, #{item.regionCode}, #{item.zoneName}, #{item.postcode}, #{item.postcodeRaw}, + #{item.postcodeEnd}, #{item.extraText}, #{item.sortNo}) + + + + + + + update pay_postcode_group + set coverage = #{coverage}, + channel_name = #{channelName}, + country_name = #{countryName}, + country_code = #{countryCode}, + service_type = #{serviceType}, + catalog_country = #{catalogCountry}, + product_name = #{productName}, + quote_sheet = #{quoteSheet}, + config_kind = #{configKind}, + fee_name = #{feeName}, + fee_text = #{feeText}, + block_title = #{blockTitle}, + remark = #{remark} + where group_id = #{groupId} + + + + + + + + + + + + delete from pay_postcode_line where group_id in + #{id} + + + + delete from pay_postcode_group where group_id in + #{id} + + diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PayProductMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PayProductMapper.xml new file mode 100644 index 00000000..b72a5913 --- /dev/null +++ b/bms-back/bms-system/src/main/resources/mapper/price/PayProductMapper.xml @@ -0,0 +1,355 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + and (pay_product.channel_id is null or pay_product.channel_id = '') + + + and pay_product.origin_id is null + + + and pay_product.product_name like concat('%', #{productName}, '%') + + + and pay_product.country_scope like concat('%', #{countryScope}, '%') + + + and pay_product.sheet_name like concat('%', #{sheetName}, '%') + + + and pay_product.source_file like concat('%', #{sourceFile}, '%') + + + and pay_product.supplier_name like concat('%', #{supplierName}, '%') + + + and pay_product.service_type like concat('%', #{serviceType}, '%') + + + and pay_product.parse_status = #{parseStatus} + + + and ifnull(t.effective_start, pay_product.effective_date) >= #{effectiveBegin} + + + and ifnull(t.effective_start, pay_product.effective_date) <= #{effectiveEnd} + + + and exists ( + select 1 from pay_surcharge s + where s.product_id = pay_product.product_id + and (s.fee_name like concat('%', #{surchargeName}, '%') + or s.charge_text like concat('%', #{surchargeName}, '%') + or s.fee_desc like concat('%', #{surchargeName}, '%') + or s.period_text like concat('%', #{surchargeName}, '%')) + ) + + + and exists ( + select 1 from pay_weight_band b + where b.product_id = pay_product.product_id + and (b.weight_label like concat('%', #{weightKeyword}, '%') + or b.charge_name like concat('%', #{weightKeyword}, '%') + or b.zone_name like concat('%', #{weightKeyword}, '%') + or b.country_name like concat('%', #{weightKeyword}, '%') + or b.section_name like concat('%', #{weightKeyword}, '%') + or b.volume_text like concat('%', #{weightKeyword}, '%')) + ) + + + + + + + + + + + + insert into pay_product ( + product_name, product_type, dict_value, origin_id, channel_id, version_no, country_scope, charge_type, extra_per_kg, service_type, classification, + catalog_remark, update_content, weight_limit, sheet_name, related_sheet, source_file, file_md5, + supplier_name, effective_date, currency, service_note, parse_status, weight_count, surcharge_count, + extra_count, created_by, create_time, remark + ) values ( + #{productName}, #{productType}, #{dictValue}, #{originId}, #{channelId}, ifnull(#{versionNo}, 1), #{countryScope}, #{chargeType}, #{extraPerKg}, + #{serviceType}, #{classification}, #{catalogRemark}, #{updateContent}, #{weightLimit}, #{sheetName}, + #{relatedSheet}, #{sourceFile}, #{fileMd5}, #{supplierName}, #{effectiveDate}, #{currency}, + #{serviceNote}, #{parseStatus}, #{weightCount}, #{surchargeCount}, #{extraCount}, #{createBy}, sysdate(), + #{remark} + ) + + + + update pay_product + set product_name = #{productName}, + product_type = #{productType}, + dict_value = #{dictValue}, + origin_id = #{originId}, + channel_id = #{channelId}, + version_no = ifnull(#{versionNo}, version_no), + country_scope = #{countryScope}, + charge_type = #{chargeType}, + extra_per_kg = #{extraPerKg}, + service_type = #{serviceType}, + classification = #{classification}, + catalog_remark = #{catalogRemark}, + update_content = #{updateContent}, + weight_limit = #{weightLimit}, + sheet_name = #{sheetName}, + related_sheet = #{relatedSheet}, + source_file = #{sourceFile}, + file_md5 = #{fileMd5}, + supplier_name = #{supplierName}, + effective_date = #{effectiveDate}, + currency = #{currency}, + service_note = #{serviceNote}, + parse_status = #{parseStatus}, + weight_count = #{weightCount}, + surcharge_count = #{surchargeCount}, + extra_count = #{extraCount}, + updated_by = #{updateBy}, + update_time = sysdate(), + remark = #{remark} + where product_id = #{productId} + + + + + + + + + + insert into pay_weight_band ( + product_id, version_no, section_name, country_name, country_code, zone_name, weight_label, weight_min, weight_max, + volume_text, charge_name, price, price_unit, sort_no + ) values + + (#{row.productId}, ifnull(#{row.versionNo}, 1), #{row.sectionName}, #{row.countryName}, #{row.countryCode}, #{row.zoneName}, + #{row.weightLabel}, #{row.weightMin}, #{row.weightMax}, #{row.volumeText}, #{row.chargeName}, #{row.price}, + #{row.priceUnit}, #{row.sortNo}) + + + + + insert into pay_surcharge ( + product_id, version_no, section_name, fee_name, zone_name, charge_text, amount, currency, fee_desc, period_text, + effective_start, effective_end, charge_timing, calc_rule, calc_param, sort_no + ) values + + (#{row.productId}, ifnull(#{row.versionNo}, 1), #{row.sectionName}, #{row.feeName}, #{row.zoneName}, #{row.chargeText}, #{row.amount}, + #{row.currency}, #{row.feeDesc}, #{row.periodText}, #{row.effectiveStart}, #{row.effectiveEnd}, + #{row.chargeTiming}, ifnull(#{row.calcRule}, 'MANUAL'), #{row.calcParam}, #{row.sortNo}) + + + + + insert into pay_extra_line (product_id, line_type, line_text, sort_no) values + + (#{row.productId}, #{row.lineType}, #{row.lineText}, #{row.sortNo}) + + + + + + + + + + + delete from pay_weight_band where product_id in + #{id} + + + + delete from pay_surcharge where product_id in + #{id} + + + + delete from pay_extra_line where product_id in + #{id} + + + + delete from pay_product where product_id in + #{id} + + + + + + + + + + + diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PayProductTermMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PayProductTermMapper.xml new file mode 100644 index 00000000..f761b557 --- /dev/null +++ b/bms-back/bms-system/src/main/resources/mapper/price/PayProductTermMapper.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + insert into pay_product_term ( + dict_value, product_id, version_no, effective_start, effective_end, status, created_by, create_time, remark + ) values ( + #{dictValue}, #{productId}, ifnull(#{versionNo}, 1), #{effectiveStart}, #{effectiveEnd}, + ifnull(#{status}, '0'), #{createBy}, sysdate(), #{remark} + ) + + + + update pay_product_term + set effective_start = #{effectiveStart}, + effective_end = #{effectiveEnd}, + updated_by = #{updateBy}, + update_time = sysdate() + where term_id = #{termId} + + + + update pay_product_term + set status = #{status}, + updated_by = #{updateBy}, + update_time = sysdate() + where term_id = #{termId} + + + + update pay_product_term + set status = '1', + effective_end = case + when effective_end is not null and effective_end < #{endDate} then effective_end + when effective_start is not null and effective_start > #{endDate} then effective_start + else #{endDate} + end, + updated_by = #{updateBy}, + update_time = sysdate() + where dict_value = #{dictValue} + and status = '0' + + + + delete from pay_product_term where product_id in + #{id} + + + + update pay_product + set effective_date = #{effectiveDate}, + update_time = sysdate() + where product_id = #{productId} + + diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml index 24acf2dd..2b09758b 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml @@ -942,6 +942,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where bill_id = #{billId} + + update prc_order_bill + set pay_total = #{payTotal}, + update_time = sysdate() + where bill_id = #{billId} + + update prc_order_bill set surcharge_amount = #{surchargeAmount}, diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PrcPayBillFeeMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PrcPayBillFeeMapper.xml index 5ba00782..a8c7410a 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PrcPayBillFeeMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PrcPayBillFeeMapper.xml @@ -44,6 +44,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - delete from prc_pay_bill_fee where bill_id = #{billId} + delete from prc_pay_bill_fee + where bill_id = #{billId} + and (fee_source is null or fee_source = '' or fee_source = 'CALC') diff --git a/bms-back/sql/data/bms_pay_type.sql b/bms-back/sql/data/bms_pay_type.sql new file mode 100644 index 00000000..c75b8b14 --- /dev/null +++ b/bms-back/sql/data/bms_pay_type.sql @@ -0,0 +1,17 @@ +-- 收付类型。字典 bms_pay_type。可重复执行。 +SET NAMES utf8mb4; + +INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark) +SELECT '收付类型', 'bms_pay_type', '0', 'admin', sysdate(), 'AR应收,AP应付' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'bms_pay_type'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) +SELECT v.dict_sort, v.dict_label, v.dict_value, 'bms_pay_type', '', 'default', v.is_default, '0', 'admin', sysdate(), v.remark +FROM ( + SELECT 1 AS dict_sort, '应收' AS dict_label, 'AR' AS dict_value, 'N' AS is_default, '应收' AS remark + UNION ALL SELECT 2, '应付', 'AP', 'Y', '应付' +) v +WHERE NOT EXISTS ( + SELECT 1 FROM sys_dict_data d + WHERE d.dict_type = 'bms_pay_type' AND d.dict_value = v.dict_value +); diff --git a/bms-back/sql/data/order_charge_type.sql b/bms-back/sql/data/order_charge_type.sql new file mode 100644 index 00000000..57da8ae6 --- /dev/null +++ b/bms-back/sql/data/order_charge_type.sql @@ -0,0 +1,18 @@ +-- 订单收费类型。字典 order_charge_type。可重复执行。 +SET NAMES utf8mb4; + +INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark) +SELECT '订单收费类型', 'order_charge_type', '0', 'admin', sysdate(), '订单收费类型' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'order_charge_type'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) +SELECT v.dict_sort, v.dict_label, v.dict_value, 'order_charge_type', '', 'default', 'N', '0', 'admin', sysdate(), v.remark +FROM ( + SELECT 1 AS dict_sort, '续重计费' AS dict_label, '续重计费' AS dict_value, '首重之外按每公斤续重' AS remark + UNION ALL SELECT 2, '单票价', '单票价', '每个重量段收一票价格' + UNION ALL SELECT 3, '常规计重价', '常规计重价', '单价乘公斤再加挂号费' +) v +WHERE NOT EXISTS ( + SELECT 1 FROM sys_dict_data d + WHERE d.dict_type = 'order_charge_type' AND d.dict_value = v.dict_value +); diff --git a/bms-back/sql/data/pay_postcode.sql b/bms-back/sql/data/pay_postcode.sql new file mode 100644 index 00000000..de223e71 --- /dev/null +++ b/bms-back/sql/data/pay_postcode.sql @@ -0,0 +1,140 @@ +-- 应付邮编配置。挂在数据管理下面。可重复执行。 +-- 附加费按国家、费用名称、金额原文合并成一条;邮编行仍按 Excel 逐条保存。 + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `pay_postcode_group` ( + `group_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '配置ID', + `source_file` varchar(500) NOT NULL COMMENT '来源文件名', + `file_md5` char(32) NOT NULL COMMENT '来源文件MD5', + `sheet_name` varchar(200) NOT NULL COMMENT '工作表', + `block_title` varchar(200) DEFAULT NULL COMMENT '表内区块标题,按Excel原文', + `config_kind` varchar(20) NOT NULL COMMENT 'ZONE邮编分区 FEE附加费', + `channel_name` varchar(100) DEFAULT NULL COMMENT '渠道名称,从Excel表头识别', + `country_name` varchar(100) DEFAULT NULL COMMENT '国家', + `country_code` varchar(20) DEFAULT NULL COMMENT '国家二字码', + `fee_name` varchar(200) DEFAULT NULL COMMENT '附加费名称,邮编分区可空', + `fee_text` varchar(200) DEFAULT NULL COMMENT '附加费金额原文。同国家同费用同原文只保留一条', + `line_count` int(11) NOT NULL DEFAULT 0 COMMENT '邮编行数', + `create_by` varchar(64) DEFAULT '' COMMENT '创建者', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`group_id`), + KEY `idx_pay_postcode_group_file` (`file_md5`, `sheet_name`), + KEY `idx_pay_postcode_group_country` (`country_code`), + KEY `idx_pay_postcode_group_fee` (`fee_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付邮编配置:分区或合并后的附加费'; + +CREATE TABLE IF NOT EXISTS `pay_postcode_line` ( + `line_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '邮编行ID', + `group_id` bigint(20) NOT NULL COMMENT '配置ID', + `country_name` varchar(100) DEFAULT NULL COMMENT '国家', + `country_code` varchar(20) DEFAULT NULL COMMENT '国家二字码', + `region_name` varchar(200) DEFAULT NULL COMMENT '州或地区,Excel原文', + `region_name_cn` varchar(200) DEFAULT NULL COMMENT '地区中文', + `city_name` varchar(200) DEFAULT NULL COMMENT '城市', + `region_code` varchar(50) DEFAULT NULL COMMENT '地区代码', + `zone_name` varchar(200) DEFAULT NULL COMMENT '分区,Excel原文', + `postcode` varchar(200) NOT NULL COMMENT '邮编,已去掉末尾星号', + `postcode_raw` varchar(200) DEFAULT NULL COMMENT '邮编原文', + `postcode_end` varchar(200) DEFAULT NULL COMMENT '截止邮编,已去掉末尾星号', + `extra_text` varchar(500) DEFAULT NULL COMMENT '其余列,从左到右用 | 连接', + `sort_no` int(11) NOT NULL DEFAULT 0 COMMENT 'Excel中的先后顺序', + PRIMARY KEY (`line_id`), + KEY `idx_pay_postcode_line_group` (`group_id`), + KEY `idx_pay_postcode_line_code` (`postcode`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付邮编明细,一行对应Excel一条邮编'; + +SET @parentId := ( + SELECT menu_id FROM sys_menu + WHERE path = 'data' AND menu_type = 'M' AND parent_id = 0 + ORDER BY menu_id ASC LIMIT 1 +); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '应付邮编配置', @parentId, 8, 'payPostcode', 'data/payPostcode/index', '', 'PayPostcode', 1, 0, 'C', '0', '0', 'data:payPostcode:list', 'international', 'admin', sysdate(), '按Excel导入应付邮编和附加费邮编' +FROM DUAL WHERE @parentId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'data:payPostcode:list'); + +SET @menuId := (SELECT menu_id FROM sys_menu WHERE perms = 'data:payPostcode:list' LIMIT 1); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '应付邮编查询', @menuId, 1, '', '', 1, 0, 'F', '0', '0', 'data:payPostcode:query', '#', 'admin', sysdate() +FROM DUAL WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'data:payPostcode:query'); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '应付邮编导入', @menuId, 2, '', '', 1, 0, 'F', '0', '0', 'data:payPostcode:import', '#', 'admin', sysdate() +FROM DUAL WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'data:payPostcode:import'); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '应付邮编修改', @menuId, 3, '', '', 1, 0, 'F', '0', '0', 'data:payPostcode:edit', '#', 'admin', sysdate() +FROM DUAL WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'data:payPostcode:edit'); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '应付邮编删除', @menuId, 4, '', '', 1, 0, 'F', '0', '0', 'data:payPostcode:remove', '#', 'admin', sysdate() +FROM DUAL WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'data:payPostcode:remove'); + +SET @has_channel_name := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_postcode_group' AND COLUMN_NAME = 'channel_name' +); +SET @add_channel_name := IF( + @has_channel_name = 0, + 'ALTER TABLE pay_postcode_group ADD COLUMN channel_name varchar(100) DEFAULT NULL COMMENT ''渠道名称,从Excel表头识别'' AFTER config_kind', + 'SELECT 1' +); +PREPARE add_channel_name_stmt FROM @add_channel_name; +EXECUTE add_channel_name_stmt; +DEALLOCATE PREPARE add_channel_name_stmt; + +SET @has_coverage := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_postcode_group' AND COLUMN_NAME = 'coverage' +); +SET @add_coverage := IF( + @has_coverage = 0, + 'ALTER TABLE pay_postcode_group ADD COLUMN coverage varchar(20) DEFAULT NULL COMMENT ''REACH可达 UNREACH不可达 REMOTE偏远,普通分区为空'' AFTER config_kind', + 'SELECT 1' +); +PREPARE add_coverage_stmt FROM @add_coverage; +EXECUTE add_coverage_stmt; +DEALLOCATE PREPARE add_coverage_stmt; + +SET @has_service_type := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_postcode_group' AND COLUMN_NAME = 'service_type' +); +SET @add_service_type := IF( + @has_service_type = 0, + 'ALTER TABLE pay_postcode_group ADD COLUMN service_type varchar(100) DEFAULT NULL COMMENT ''目录服务类型'' AFTER country_code, ADD COLUMN catalog_country varchar(100) DEFAULT NULL COMMENT ''目录国家,和邮编目的国分开'' AFTER service_type, ADD COLUMN product_name varchar(200) DEFAULT NULL COMMENT ''目录销售产品'' AFTER catalog_country, ADD COLUMN quote_sheet varchar(200) DEFAULT NULL COMMENT ''目录报价工作表'' AFTER product_name', + 'SELECT 1' +); +PREPARE add_service_type_stmt FROM @add_service_type; +EXECUTE add_service_type_stmt; +DEALLOCATE PREPARE add_service_type_stmt; + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT 1, m.menu_id +FROM sys_menu m +WHERE m.perms LIKE 'data:payPostcode:%' + AND NOT EXISTS (SELECT 1 FROM sys_role_menu x WHERE x.role_id = 1 AND x.menu_id = m.menu_id); + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT DISTINCT rm.role_id, dst.menu_id +FROM sys_role_menu rm +JOIN sys_menu src ON src.menu_id = rm.menu_id AND src.perms = 'data:region:list' +JOIN sys_menu dst ON dst.perms IN ('data:payPostcode:list', 'data:payPostcode:query') +WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = dst.menu_id); + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT DISTINCT rm.role_id, dst.menu_id +FROM sys_role_menu rm +JOIN sys_menu src ON src.menu_id = rm.menu_id AND src.perms = 'data:region:edit' +JOIN sys_menu dst ON dst.perms IN ('data:payPostcode:import', 'data:payPostcode:edit') +WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = dst.menu_id); + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT DISTINCT rm.role_id, dst.menu_id +FROM sys_role_menu rm +JOIN sys_menu src ON src.menu_id = rm.menu_id AND src.perms = 'data:region:remove' +JOIN sys_menu dst ON dst.perms = 'data:payPostcode:remove' +WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = dst.menu_id); diff --git a/bms-back/sql/data/payment_product_type.sql b/bms-back/sql/data/payment_product_type.sql new file mode 100644 index 00000000..b10d2d65 --- /dev/null +++ b/bms-back/sql/data/payment_product_type.sql @@ -0,0 +1,43 @@ +-- 应付渠道产品。字典类型 payment_product_type,字典项为价卡文件名。 +-- 可重复执行。 +SET NAMES utf8mb4; + +INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark) +SELECT '应付渠道产品', 'payment_product_type', '0', 'admin', sysdate(), '应付渠道产品' +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_dict_type WHERE dict_type = 'payment_product_type'); + +INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark) +SELECT v.dict_sort, v.dict_label, v.dict_value, 'payment_product_type', '', 'default', 'N', '0', 'admin', sysdate(), '应付价卡文件名' +FROM ( + SELECT 1 AS dict_sort, 'Colissimo' AS dict_label, 'Colissimo' AS dict_value + UNION ALL SELECT 2, 'Corres Express', 'Corres Express' + UNION ALL SELECT 3, 'CTT 48H', 'CTT 48H' + UNION ALL SELECT 4, 'DE-iMile', 'DE-iMile' + UNION ALL SELECT 5, 'DHL International', 'DHL International' + UNION ALL SELECT 6, 'DHL Paket', 'DHL Paket' + UNION ALL SELECT 7, 'DHL泛欧', 'DHL泛欧' + UNION ALL SELECT 8, 'Evri 48H', 'Evri 48H' + UNION ALL SELECT 9, 'IT-iMile', 'IT-iMile' + UNION ALL SELECT 10, 'NL-DHL', 'NL-DHL' + UNION ALL SELECT 11, 'PosteItaliane', 'PosteItaliane' + UNION ALL SELECT 12, 'Royal Mail 48H', 'Royal Mail 48H' + UNION ALL SELECT 13, '德国DHL', '德国DHL' + UNION ALL SELECT 14, '德国DPD挂号 (DEDPD)', '德国DPD挂号 (DEDPD)' + UNION ALL SELECT 15, '德国DPD挂号-国际件', '德国DPD挂号-国际件' + UNION ALL SELECT 16, '德国UPS', '德国UPS' + UNION ALL SELECT 17, '法国Cirro', '法国Cirro' + UNION ALL SELECT 18, '法国Colissimo', '法国Colissimo' + UNION ALL SELECT 19, '法国GOFO', '法国GOFO' + UNION ALL SELECT 20, '西班牙CTT', '西班牙CTT' + UNION ALL SELECT 21, '西班牙CTT带电专线(岛屿+其他地区)', '西班牙CTT带电专线(岛屿+其他地区)' + UNION ALL SELECT 22, '西班牙CTT带电专线(主要地区)', '西班牙CTT带电专线(主要地区)' + UNION ALL SELECT 23, '西班牙GLS', '西班牙GLS' + UNION ALL SELECT 24, '西邮-西班牙(全境)', '西邮-西班牙(全境)' + UNION ALL SELECT 25, '西邮-西班牙(主岛)', '西邮-西班牙(主岛)' + UNION ALL SELECT 26, '英国evri', '英国evri' + UNION ALL SELECT 27, '英国RM', '英国RM' +) v +WHERE NOT EXISTS ( + SELECT 1 FROM sys_dict_data d + WHERE d.dict_type = 'payment_product_type' AND d.dict_value = v.dict_value +); diff --git a/bms-back/sql/price/pay_bill.sql b/bms-back/sql/price/pay_bill.sql new file mode 100644 index 00000000..97905bc4 --- /dev/null +++ b/bms-back/sql/price/pay_bill.sql @@ -0,0 +1,153 @@ +-- 应付账单。订单应付仍用 prc_pay_bill、prc_pay_bill_fee,不改这两张表的名字。可重复执行。 +SET NAMES utf8mb4; + +SET @fee_source = ( + SELECT IF( + EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'prc_pay_bill_fee' AND COLUMN_NAME = 'fee_source'), + 'SELECT 1', + 'ALTER TABLE prc_pay_bill_fee ADD COLUMN fee_source varchar(20) NOT NULL DEFAULT ''CALC'' COMMENT ''CALC计算 LADING提单分摊 MANUAL人工'' AFTER remark' + ) +); +PREPARE stmt FROM @fee_source; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS `pay_lading` ( + `lading_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '提单ID', + `lading_no` varchar(64) NOT NULL COMMENT '提单号或航空主单号', + `supplier_id` bigint(20) DEFAULT NULL COMMENT '供应商ID', + `channel_id` varchar(64) DEFAULT NULL COMMENT '渠道ID', + `biz_date` date DEFAULT NULL COMMENT '出运日期', + `pieces` int(11) DEFAULT NULL COMMENT '件数', + `charge_weight` decimal(12,3) DEFAULT NULL COMMENT '计费重', + `currency` varchar(10) DEFAULT NULL COMMENT '币种', + `status` varchar(20) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN可改 ALLOCATED已分摊 SETTLED已进账单', + `create_by` varchar(64) DEFAULT '' COMMENT '创建者', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`lading_id`), + UNIQUE KEY `uk_pay_lading_no` (`lading_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付提单'; + +CREATE TABLE IF NOT EXISTS `pay_lading_fee` ( + `lading_fee_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '提单费用ID', + `lading_id` bigint(20) NOT NULL COMMENT '提单ID', + `fee_name` varchar(100) NOT NULL COMMENT '费用名称', + `amount` decimal(18,4) NOT NULL COMMENT '金额', + `currency` varchar(10) NOT NULL COMMENT '币种', + `alloc_rule` varchar(20) NOT NULL DEFAULT 'NONE' COMMENT 'WEIGHT按计费重 PIECE按件数 ORDER按票 NONE留在主单', + `remark` varchar(500) DEFAULT NULL COMMENT '说明', + `create_by` varchar(64) DEFAULT '' COMMENT '创建者', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`lading_fee_id`), + KEY `idx_pay_lading_fee_lading` (`lading_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='提单费用'; + +CREATE TABLE IF NOT EXISTS `pay_bill` ( + `bill_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '应付账单ID', + `bill_no` varchar(50) NOT NULL COMMENT '账单号', + `supplier_id` bigint(20) NOT NULL COMMENT '供应商ID', + `supplier_name` varchar(100) DEFAULT NULL COMMENT '供应商名称', + `currency` varchar(10) NOT NULL COMMENT '币种', + `source_type` varchar(20) NOT NULL COMMENT 'ORDER订单号 TIME计费日 LADING提单', + `period_start` date DEFAULT NULL COMMENT '计费日起', + `period_end` date DEFAULT NULL COMMENT '计费日止', + `order_count` int(11) NOT NULL DEFAULT '0' COMMENT '订单数', + `total_amount` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '应付总额', + `status` varchar(20) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT草稿 CONFIRMED已确认 PAID已付款 VOID已作废', + `void_reason` varchar(500) DEFAULT NULL COMMENT '作废原因', + `void_by` varchar(64) DEFAULT NULL COMMENT '作废人', + `void_time` datetime DEFAULT NULL COMMENT '作废时间', + `create_by` varchar(64) DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT '' COMMENT '更新者', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`bill_id`), + UNIQUE KEY `uk_pay_bill_no` (`bill_no`), + KEY `idx_pay_bill_status` (`status`, `create_time`), + KEY `idx_pay_bill_supplier` (`supplier_id`, `currency`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付账单,作废后保留'; + +CREATE TABLE IF NOT EXISTS `pay_bill_line` ( + `line_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '账单行ID', + `bill_id` bigint(20) NOT NULL COMMENT '应付账单ID', + `order_bill_id` varchar(36) DEFAULT NULL COMMENT '订单ID', + `order_no` varchar(64) DEFAULT NULL COMMENT '订单号', + `lading_no` varchar(64) DEFAULT NULL COMMENT '提单号', + `lading_fee_id` bigint(20) DEFAULT NULL COMMENT '留在主单上的费用', + `fee_name` varchar(100) NOT NULL COMMENT '费用名称', + `amount` decimal(18,4) NOT NULL COMMENT '金额快照', + `currency` varchar(10) NOT NULL COMMENT '币种', + `price_date` date DEFAULT NULL COMMENT '取价日', + `version_no` int(11) DEFAULT NULL COMMENT '价卡版本', + `remote_flag` char(1) DEFAULT NULL COMMENT 'Y偏远 N非偏远', + `density_level` varchar(20) DEFAULT NULL COMMENT 'HIGH MEDIUM LOW', + `line_source` varchar(20) NOT NULL COMMENT 'ORDER订单费用 LADING主单费用', + PRIMARY KEY (`line_id`), + KEY `idx_pay_bill_line_bill` (`bill_id`), + KEY `idx_pay_bill_line_order` (`order_bill_id`), + KEY `idx_pay_bill_line_lading_fee` (`lading_fee_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付账单费用快照'; + +CREATE TABLE IF NOT EXISTS `pay_bill_log` ( + `log_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '日志ID', + `bill_id` bigint(20) NOT NULL COMMENT '应付账单ID', + `operation_type` varchar(20) NOT NULL COMMENT 'CREATE CONFIRM PAY VOID', + `from_status` varchar(20) DEFAULT NULL COMMENT '变更前状态', + `to_status` varchar(20) DEFAULT NULL COMMENT '变更后状态', + `operation_desc` varchar(500) DEFAULT NULL COMMENT '说明', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人', + `create_time` datetime NOT NULL COMMENT '操作时间', + PRIMARY KEY (`log_id`), + KEY `idx_pay_bill_log_bill` (`bill_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付账单操作日志'; + +SET @lading_col = ( + SELECT IF( + EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'prc_order_bill_ext' AND COLUMN_NAME = 'lading_id'), + 'SELECT 1', + 'ALTER TABLE prc_order_bill_ext ADD COLUMN lading_id bigint(20) DEFAULT NULL COMMENT ''应付提单ID'' AFTER air_no_old' + ) +); +PREPARE stmt FROM @lading_col; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '应付账单', parent.menu_id, 8, 'payBill', 'price/billing/payBill/index', 1, 0, 'C', '0', '0', 'price:payBill:list', 'money', 'admin', sysdate(), '按订单号、计费日或提单生成应付账单,作废留痕' +FROM ( + SELECT menu_id FROM sys_menu WHERE menu_name = '账单管理' AND menu_type = 'M' ORDER BY menu_id LIMIT 1 +) parent +WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:payBill:list'); + +SET @payBillMenu = (SELECT menu_id FROM sys_menu WHERE perms = 'price:payBill:list' LIMIT 1); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '应付账单查询', @payBillMenu, 1, '', '', 1, 0, 'F', '0', '0', 'price:payBill:query', '#', 'admin', sysdate() +FROM DUAL WHERE @payBillMenu IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:payBill:query'); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '应付账单生成', @payBillMenu, 2, '', '', 1, 0, 'F', '0', '0', 'price:payBill:add', '#', 'admin', sysdate() +FROM DUAL WHERE @payBillMenu IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:payBill:add'); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '应付账单确认', @payBillMenu, 3, '', '', 1, 0, 'F', '0', '0', 'price:payBill:confirm', '#', 'admin', sysdate() +FROM DUAL WHERE @payBillMenu IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:payBill:confirm'); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '应付账单作废', @payBillMenu, 4, '', '', 1, 0, 'F', '0', '0', 'price:payBill:void', '#', 'admin', sysdate() +FROM DUAL WHERE @payBillMenu IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:payBill:void'); + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT DISTINCT rm.role_id, dst.menu_id +FROM sys_role_menu rm +JOIN sys_menu src ON src.menu_id = rm.menu_id AND src.component = 'price/billing/bill' +JOIN sys_menu dst ON dst.perms LIKE 'price:payBill:%' +WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = dst.menu_id); + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT 1, m.menu_id +FROM sys_menu m +WHERE m.perms LIKE 'price:payBill:%' + AND NOT EXISTS (SELECT 1 FROM sys_role_menu x WHERE x.role_id = 1 AND x.menu_id = m.menu_id); diff --git a/bms-back/sql/price/pay_price_line_version.sql b/bms-back/sql/price/pay_price_line_version.sql new file mode 100644 index 00000000..ea585073 --- /dev/null +++ b/bms-back/sql/price/pay_price_line_version.sql @@ -0,0 +1,36 @@ +-- 重量段、附加费记录所属价卡版本。已有库可重复执行。 +SET NAMES utf8mb4; + +SET @band_sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE pay_weight_band ADD COLUMN version_no int(11) NOT NULL DEFAULT 1 COMMENT ''价卡版本,随产品版本变更'' AFTER product_id', + 'SELECT 1' + ) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_weight_band' AND COLUMN_NAME = 'version_no' +); +PREPARE stmt FROM @band_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @fee_sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE pay_surcharge ADD COLUMN version_no int(11) NOT NULL DEFAULT 1 COMMENT ''价卡版本,随产品版本变更'' AFTER product_id', + 'SELECT 1' + ) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_surcharge' AND COLUMN_NAME = 'version_no' +); +PREPARE stmt FROM @fee_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +UPDATE pay_weight_band b +INNER JOIN pay_product p ON p.product_id = b.product_id +SET b.version_no = IFNULL(p.version_no, 1); + +UPDATE pay_surcharge s +INNER JOIN pay_product p ON p.product_id = s.product_id +SET s.version_no = IFNULL(p.version_no, 1); diff --git a/bms-back/sql/price/pay_product.sql b/bms-back/sql/price/pay_product.sql new file mode 100644 index 00000000..7c270536 --- /dev/null +++ b/bms-back/sql/price/pay_product.sql @@ -0,0 +1,140 @@ +-- 应付价卡配置。目录里的一个产品一行,重量段和附加费挂在产品下面。 +-- 表名使用 pay_ 前缀。可重复执行。 + +CREATE TABLE IF NOT EXISTS `pay_product` ( + `product_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '产品ID', + `product_name` varchar(200) NOT NULL COMMENT '目录中的产品名称', + `product_type` varchar(30) DEFAULT NULL COMMENT '产品类型:AR:应收、AP:应付', + `country_scope` varchar(200) DEFAULT NULL COMMENT '目录中的国家或口岸', + `service_type` varchar(100) DEFAULT NULL COMMENT '服务类型', + `classification` varchar(100) DEFAULT NULL COMMENT '目录分类', + `catalog_remark` varchar(1000) DEFAULT NULL COMMENT '目录备注', + `update_content` varchar(500) DEFAULT NULL COMMENT '目录更新内容', + `weight_limit` varchar(100) DEFAULT NULL COMMENT '目录重量限制', + `sheet_name` varchar(200) NOT NULL COMMENT '报价工作表', + `related_sheet` varchar(500) DEFAULT NULL COMMENT '目录里一并指向的分区或附加表,不单独入库', + `source_file` varchar(500) NOT NULL COMMENT '来源文件名', + `file_md5` char(32) NOT NULL COMMENT '来源文件MD5', + `supplier_name` varchar(100) DEFAULT NULL COMMENT '价卡归属,如威狮、易德、GWE', + `effective_date` date DEFAULT NULL COMMENT '价卡生效日', + `currency` varchar(20) DEFAULT NULL COMMENT '报价币种', + `service_note` varchar(4000) DEFAULT NULL COMMENT '报价表中的服务说明', + `parse_status` varchar(20) NOT NULL DEFAULT 'OK' COMMENT '识别结果:OK已识别 EMPTY未识别到配置 MISSING缺少工作表', + `weight_count` int(11) NOT NULL DEFAULT 0 COMMENT '重量段行数', + `surcharge_count` int(11) NOT NULL DEFAULT 0 COMMENT '附加费行数', + `extra_count` int(11) NOT NULL DEFAULT 0 COMMENT '邮编或其他配置行数', + `created_by` varchar(64) DEFAULT '' COMMENT '创建者', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(64) DEFAULT '' COMMENT '更新者', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`product_id`), + KEY `idx_pay_product_name` (`product_name`), + KEY `idx_pay_product_file` (`file_md5`, `sheet_name`), + KEY `idx_pay_product_effective` (`effective_date`), + KEY `idx_pay_product_supplier` (`supplier_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付价卡产品'; + +CREATE TABLE IF NOT EXISTS `pay_weight_band` ( + `band_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '重量段ID', + `product_id` bigint(20) NOT NULL COMMENT '产品ID', + `version_no` int(11) NOT NULL DEFAULT 1 COMMENT '价卡版本,随产品版本变更', + `section_name` varchar(200) DEFAULT NULL COMMENT '报价区块,如派送费、偏远、额外线路', + `country_name` varchar(200) DEFAULT NULL COMMENT '国家', + `country_code` varchar(32) DEFAULT NULL COMMENT '国家二字码,多个用逗号', + `zone_name` varchar(200) DEFAULT NULL COMMENT '分区或价格列', + `weight_label` varchar(100) DEFAULT NULL COMMENT '重量段原文,如0<W≤1、首重1 Kg', + `weight_min` decimal(10,3) DEFAULT NULL COMMENT '重量下限KG', + `weight_max` decimal(10,3) DEFAULT NULL COMMENT '重量上限KG', + `volume_text` varchar(40) DEFAULT NULL COMMENT '体积上限,数字加单位,如10000cm³', + `charge_name` varchar(200) DEFAULT NULL COMMENT '费用项,如处理费、派送费、续重', + `price` decimal(12,4) DEFAULT NULL COMMENT '单价', + `price_unit` varchar(40) DEFAULT NULL COMMENT '单位,如EUR/ITEM、EUR/KG', + `sort_no` int(11) NOT NULL DEFAULT 0 COMMENT '排序', + PRIMARY KEY (`band_id`), + KEY `idx_pay_weight_product` (`product_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付产品重量段和起重配置'; + +CREATE TABLE IF NOT EXISTS `pay_surcharge` ( + `surcharge_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '附加费ID', + `product_id` bigint(20) NOT NULL COMMENT '产品ID', + `version_no` int(11) NOT NULL DEFAULT 1 COMMENT '价卡版本,随产品版本变更', + `section_name` varchar(200) DEFAULT NULL COMMENT '附加费区块', + `fee_name` varchar(200) NOT NULL COMMENT '费用名称', + `zone_name` varchar(200) DEFAULT NULL COMMENT '适用分区,没有则空', + `charge_text` varchar(500) DEFAULT NULL COMMENT '收费标准原文', + `amount` decimal(12,4) DEFAULT NULL COMMENT '能解析出的金额或百分比', + `currency` varchar(20) DEFAULT NULL COMMENT '币种', + `fee_desc` varchar(2000) DEFAULT NULL COMMENT '费用说明', + `period_text` varchar(500) DEFAULT NULL COMMENT '时效原文', + `effective_start` date DEFAULT NULL COMMENT '生效开始,空表示价卡期内一直有效', + `effective_end` date DEFAULT NULL COMMENT '生效结束', + `charge_timing` varchar(40) DEFAULT NULL COMMENT '收取时点:固定收取、产生时收取、旺季期间', + `sort_no` int(11) NOT NULL DEFAULT 0 COMMENT '排序', + PRIMARY KEY (`surcharge_id`), + KEY `idx_pay_surcharge_product` (`product_id`), + KEY `idx_pay_surcharge_period` (`effective_start`, `effective_end`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付产品附加费,含时效'; + +CREATE TABLE IF NOT EXISTS `pay_extra_line` ( + `line_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '配置行ID', + `product_id` bigint(20) NOT NULL COMMENT '产品ID', + `line_type` varchar(20) NOT NULL COMMENT '行类型:邮编、配置', + `line_text` varchar(1000) NOT NULL COMMENT '原文,列之间用 | 分隔', + `sort_no` int(11) NOT NULL DEFAULT 0 COMMENT '排序', + PRIMARY KEY (`line_id`), + KEY `idx_pay_extra_product` (`product_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付产品上的邮编或其他配置原文'; + +-- 菜单挂在价格基础下面 +SET @parentId := ( + SELECT menu_id FROM sys_menu + WHERE path = 'basic' AND menu_type = 'M' + ORDER BY menu_id ASC LIMIT 1 +); +SET @parentId := IFNULL(@parentId, ( + SELECT parent_id FROM sys_menu + WHERE perms = 'price:product:list' OR component LIKE '%price/basic/product%' + ORDER BY menu_id ASC LIMIT 1 +)); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) +SELECT '应付配置', @parentId, 16, 'pay', 'price/basic/pay/index', 1, 0, 'C', '0', '0', 'price:pay:list', 'money', 'admin', sysdate(), '应付价卡产品、重量段和附加费' +WHERE @parentId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:pay:list' OR component = 'price/basic/pay/index'); + +SET @menuId := (SELECT menu_id FROM sys_menu WHERE perms = 'price:pay:list' LIMIT 1); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '应付配置查询', @menuId, 1, '', '', 1, 0, 'F', '0', '0', 'price:pay:query', '#', 'admin', sysdate() +WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:pay:query'); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '应付配置导入', @menuId, 2, '', '', 1, 0, 'F', '0', '0', 'price:pay:import', '#', 'admin', sysdate() +WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:pay:import'); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '应付配置删除', @menuId, 3, '', '', 1, 0, 'F', '0', '0', 'price:pay:remove', '#', 'admin', sysdate() +WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:pay:remove'); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '应付配置导出', @menuId, 4, '', '', 1, 0, 'F', '0', '0', 'price:pay:export', '#', 'admin', sysdate() +WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:pay:export'); + +-- 已经能维护价格基础的角色,同步拿到应付配置菜单 +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT DISTINCT rm.role_id, m.menu_id +FROM sys_role_menu rm +JOIN sys_menu src ON src.menu_id = rm.menu_id AND src.perms = 'price:product:list' +JOIN sys_menu m ON m.perms LIKE 'price:pay:%' +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = m.menu_id +); + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT 1, m.menu_id +FROM sys_menu m +WHERE m.perms LIKE 'price:pay:%' + AND NOT EXISTS ( + SELECT 1 FROM sys_role_menu x WHERE x.role_id = 1 AND x.menu_id = m.menu_id + ); diff --git a/bms-back/sql/price/pay_product_channel.sql b/bms-back/sql/price/pay_product_channel.sql new file mode 100644 index 00000000..0b170709 --- /dev/null +++ b/bms-back/sql/price/pay_product_channel.sql @@ -0,0 +1,22 @@ +-- 应付产品按字典导入,渠道另存一版,不改模板。可重复执行。 +SET NAMES utf8mb4; + +SET @sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE pay_product + ADD COLUMN dict_value varchar(100) DEFAULT NULL COMMENT ''字典 payment_product_type'' AFTER product_name, + ADD COLUMN origin_id bigint(20) DEFAULT NULL COMMENT ''复制来源,空表示模板'' AFTER dict_value, + ADD COLUMN channel_id varchar(64) DEFAULT NULL COMMENT ''服务渠道ID,空表示公共模板'' AFTER origin_id, + ADD COLUMN version_no int(11) NOT NULL DEFAULT 1 COMMENT ''版本,模板为1'' AFTER channel_id, + ADD KEY idx_pay_product_dict (dict_value, channel_id)', + 'SELECT 1' + ) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_product' AND COLUMN_NAME = 'dict_value' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 渠道和产品的绑定在 srv_channel_product_map,不再使用 srv_channel_pay。 diff --git a/bms-back/sql/price/pay_product_charge_type.sql b/bms-back/sql/price/pay_product_charge_type.sql new file mode 100644 index 00000000..8a0cc720 --- /dev/null +++ b/bms-back/sql/price/pay_product_charge_type.sql @@ -0,0 +1,16 @@ +-- 应付产品增加订单收费类型,对应字典 order_charge_type。可重复执行。 +SET NAMES utf8mb4; + +SET @ddl = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_product' AND COLUMN_NAME = 'charge_type' + ), + 'SELECT 1', + 'ALTER TABLE pay_product ADD COLUMN charge_type varchar(50) DEFAULT NULL COMMENT ''订单收费类型,字典 order_charge_type'' AFTER country_scope' + ) +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/bms-back/sql/price/pay_product_extra_per_kg.sql b/bms-back/sql/price/pay_product_extra_per_kg.sql new file mode 100644 index 00000000..a172d5d2 --- /dev/null +++ b/bms-back/sql/price/pay_product_extra_per_kg.sql @@ -0,0 +1,16 @@ +-- 应付产品增加续重每公斤加价。分区价格相同存一个金额,不同存最小~最大。可重复执行。 +SET NAMES utf8mb4; + +SET @ddl = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_product' AND COLUMN_NAME = 'extra_per_kg' + ), + 'SELECT 1', + 'ALTER TABLE pay_product ADD COLUMN extra_per_kg varchar(40) DEFAULT NULL COMMENT ''续重每公斤加价,分区不同时为最小到最大'' AFTER charge_type' + ) +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/bms-back/sql/price/pay_product_term.sql b/bms-back/sql/price/pay_product_term.sql new file mode 100644 index 00000000..22f3158e --- /dev/null +++ b/bms-back/sql/price/pay_product_term.sql @@ -0,0 +1,53 @@ +-- 应付产品有效期。同一产品多次导入各留一版,用这张表记录生效区间和是否生效。可重复执行。 +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `pay_product_term` ( + `term_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '有效期ID', + `dict_value` varchar(100) NOT NULL COMMENT '产品字典值,同一产品的各版本归在一起', + `product_id` bigint(20) NOT NULL COMMENT '这一版价格', + `version_no` int(11) NOT NULL DEFAULT 1 COMMENT '版本号', + `effective_start` date DEFAULT NULL COMMENT '生效开始', + `effective_end` date DEFAULT NULL COMMENT '生效结束,空表示不限', + `status` char(1) NOT NULL DEFAULT '0' COMMENT '0生效 1失效', + `created_by` varchar(64) DEFAULT '' COMMENT '创建者', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `updated_by` varchar(64) DEFAULT '' COMMENT '更新者', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`term_id`), + UNIQUE KEY `uk_pay_term_product` (`product_id`), + KEY `idx_pay_term_dict` (`dict_value`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应付产品价格有效期'; + +-- 已有模板补一条生效记录,不覆盖后来手工改过的有效期 +INSERT INTO pay_product_term ( + dict_value, product_id, version_no, effective_start, status, created_by, create_time +) +SELECT ifnull(nullif(dict_value, ''), product_name), product_id, ifnull(version_no, 1), effective_date, '0', + ifnull(created_by, ''), ifnull(create_time, sysdate()) +FROM pay_product p +WHERE (p.channel_id IS NULL OR p.channel_id = '') + AND NOT EXISTS (SELECT 1 FROM pay_product_term t WHERE t.product_id = p.product_id); + +SET @menuId := (SELECT menu_id FROM sys_menu WHERE perms = 'price:pay:list' LIMIT 1); + +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time) +SELECT '应付配置修改', @menuId, 5, '', '', 1, 0, 'F', '0', '0', 'price:pay:edit', '#', 'admin', sysdate() +WHERE @menuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perms = 'price:pay:edit'); + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT DISTINCT rm.role_id, m.menu_id +FROM sys_role_menu rm +JOIN sys_menu src ON src.menu_id = rm.menu_id AND src.perms = 'price:pay:list' +JOIN sys_menu m ON m.perms = 'price:pay:edit' +WHERE NOT EXISTS ( + SELECT 1 FROM sys_role_menu x WHERE x.role_id = rm.role_id AND x.menu_id = m.menu_id +); + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT 1, m.menu_id +FROM sys_menu m +WHERE m.perms = 'price:pay:edit' + AND NOT EXISTS ( + SELECT 1 FROM sys_role_menu x WHERE x.role_id = 1 AND x.menu_id = m.menu_id + ); diff --git a/bms-back/sql/price/pay_product_type.sql b/bms-back/sql/price/pay_product_type.sql new file mode 100644 index 00000000..b572e41c --- /dev/null +++ b/bms-back/sql/price/pay_product_type.sql @@ -0,0 +1,15 @@ +-- 应付价卡产品增加支付类型。已有库可重复执行。 +SET NAMES utf8mb4; + +SET @sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE pay_product ADD COLUMN product_type varchar(30) DEFAULT NULL COMMENT ''产品类型:AR:应收、AP:应付'' AFTER product_name', + 'SELECT 1' + ) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_product' AND COLUMN_NAME = 'product_type' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/bms-back/sql/price/pay_surcharge_calc.sql b/bms-back/sql/price/pay_surcharge_calc.sql new file mode 100644 index 00000000..9f61c193 --- /dev/null +++ b/bms-back/sql/price/pay_surcharge_calc.sql @@ -0,0 +1,17 @@ +-- 附加费增加可计算条件。已有库可重复执行。 +SET NAMES utf8mb4; + +SET @sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE pay_surcharge + ADD COLUMN calc_rule varchar(20) NOT NULL DEFAULT ''MANUAL'' COMMENT ''计费条件:ALWAYS每票、POSTCODE邮编、WEIGHT超重、SIZE超尺寸、RETURN退件、MANUAL人工'' AFTER charge_timing, + ADD COLUMN calc_param varchar(1000) DEFAULT NULL COMMENT ''条件参数,如邮编、重量阈值、尺寸表达式'' AFTER calc_rule', + 'SELECT 1' + ) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_surcharge' AND COLUMN_NAME = 'calc_rule' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/bms-back/sql/price/pay_weight_band_volume.sql b/bms-back/sql/price/pay_weight_band_volume.sql new file mode 100644 index 00000000..2517c0ee --- /dev/null +++ b/bms-back/sql/price/pay_weight_band_volume.sql @@ -0,0 +1,16 @@ +-- 重量段增加体积上限。长x宽x高记在这一档上,不记成费用。可重复执行。 +SET NAMES utf8mb4; + +SET @ddl = ( + SELECT IF( + EXISTS ( + SELECT 1 FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pay_weight_band' AND COLUMN_NAME = 'volume_text' + ), + 'SELECT 1', + 'ALTER TABLE pay_weight_band ADD COLUMN volume_text varchar(40) DEFAULT NULL COMMENT ''体积上限,数字加单位,如10000cm³'' AFTER weight_max' + ) +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/bms-back/sql/price/srv_channel_product_map.sql b/bms-back/sql/price/srv_channel_product_map.sql new file mode 100644 index 00000000..59bdfb73 --- /dev/null +++ b/bms-back/sql/price/srv_channel_product_map.sql @@ -0,0 +1,52 @@ +-- 渠道与产品的收付关系。一个渠道同一收付类型只保留一条。可重复执行。 +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `srv_channel_product_map` ( + `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', + `channel_id` varchar(50) NOT NULL COMMENT '渠道ID,对应 srv_service_channel.id', + `product_id` bigint(20) NOT NULL COMMENT '产品ID,应付时为价卡产品编号', + `price_set_id` bigint(20) DEFAULT NULL COMMENT '应收套餐ID,对应 prc_price_set.price_set_id', + `pay_type` varchar(10) NOT NULL COMMENT '收付类型,字典 bms_pay_type:AR应收 AP应付', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建者', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新者', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_channel_pay_type` (`channel_id`, `pay_type`), + KEY `idx_channel_product_map_product` (`product_id`), + KEY `idx_channel_product_map_price_set` (`price_set_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='渠道产品关系映射'; + +SET @sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE srv_channel_product_map ADD COLUMN price_set_id bigint(20) DEFAULT NULL COMMENT ''应收套餐ID,对应 prc_price_set.price_set_id'' AFTER product_id, ADD KEY idx_channel_product_map_price_set (price_set_id)', + 'SELECT 1' + ) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'srv_channel_product_map' AND COLUMN_NAME = 'price_set_id' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @copy_sql = ( + SELECT IF( + COUNT(*) > 0, + 'INSERT INTO srv_channel_product_map (channel_id, product_id, pay_type, create_time, update_time) + SELECT c.channel_id, IFNULL(c.origin_id, c.product_id), ''AP'', c.update_time, c.update_time + FROM srv_channel_pay c + WHERE NOT EXISTS ( + SELECT 1 FROM srv_channel_product_map m + WHERE m.channel_id = c.channel_id AND m.pay_type = ''AP'' + )', + 'SELECT 1' + ) + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'srv_channel_pay' +); +PREPARE stmt FROM @copy_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +DROP TABLE IF EXISTS srv_channel_pay; diff --git a/bms-front/src/api/data/payPostcode.js b/bms-front/src/api/data/payPostcode.js new file mode 100644 index 00000000..b6c79184 --- /dev/null +++ b/bms-front/src/api/data/payPostcode.js @@ -0,0 +1,53 @@ +import request from '@/utils/request' + +/** 应付邮编配置:导入整本 Excel,列表只展示合并后的分区和附加费 */ + +export function listPayPostcode(query) { + return request({ + url: '/data/payPostcode/list', + method: 'get', + params: query + }) +} + +export function getPayPostcode(groupId) { + return request({ + url: '/data/payPostcode/' + groupId, + method: 'get' + }) +} + +export function updatePayPostcode(data) { + return request({ + url: '/data/payPostcode', + method: 'put', + data + }) +} + +export function listPayPostcodeLine(query) { + return request({ + url: '/data/payPostcode/line', + method: 'get', + params: query + }) +} + +export function importPayPostcode(file) { + const data = new FormData() + data.append('file', file) + return request({ + url: '/data/payPostcode/import', + method: 'post', + data, + headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false }, + timeout: 10 * 60 * 1000 + }) +} + +export function delPayPostcode(groupIds) { + return request({ + url: '/data/payPostcode/' + groupIds, + method: 'delete' + }) +} diff --git a/bms-front/src/api/price/pay.js b/bms-front/src/api/price/pay.js new file mode 100644 index 00000000..4a4f290a --- /dev/null +++ b/bms-front/src/api/price/pay.js @@ -0,0 +1,149 @@ +import request from '@/utils/request' + +// 应付价卡配置。预览不入库,保存才写入 pay_ 表。 + +/** 分页查询已保存的应付产品 */ +export function listPay(query) { + return request({ + url: '/price/pay/list', + method: 'get', + params: query + }) +} + +/** 产品详情,含重量段、附加费和其他配置 */ +export function getPay(productId) { + return request({ + url: '/price/pay/' + productId, + method: 'get' + }) +} + +/** 按字典匹配工作表,返回预览,不写数据库 */ +export function previewPay(file, dictValue) { + const data = new FormData() + data.append('file', file) + if (dictValue) data.append('dictValue', dictValue) + return request({ + url: '/price/pay/preview', + method: 'post', + data, + // 必须声明 multipart,否则全局 application/json 会把文件流改写成空 JSON + headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false }, + timeout: 10 * 60 * 1000 + }) +} + +/** 修改已保存的公共模板 */ +export function updatePay(data) { + return request({ + url: '/price/pay', + method: 'put', + data + }) +} + +/** 已导入模板,供渠道下拉。payType 默认应付 AP */ +export function payOptions(payType = 'AP') { + return request({ + url: '/price/pay/options', + method: 'get', + params: { payType } + }) +} + +export function bindChannelPay(channelId, productId, payType = 'AP') { + return request({ + url: '/price/pay/channel/' + channelId + '/bind', + method: 'put', + data: { productId, payType } + }) +} + +export function comparePay(leftId, rightId) { + return request({ + url: '/price/pay/compare', + method: 'get', + params: { leftId, rightId } + }) +} + +export function getChannelPay(channelId) { + return request({ + url: '/price/pay/channel/' + channelId, + method: 'get' + }) +} + +export function saveChannelPay(channelId, data) { + return request({ + url: '/price/pay/channel/' + channelId, + method: 'post', + data + }) +} + +export function reimportChannelPay(channelId, file) { + const data = new FormData() + data.append('file', file) + return request({ + url: '/price/pay/channel/' + channelId + '/reimport', + method: 'post', + data, + headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false }, + timeout: 10 * 60 * 1000 + }) +} + +/** 保存预览里勾选的产品 */ +export function savePay(data) { + return request({ + url: '/price/pay/save', + method: 'post', + data, + timeout: 120000 + }) +} + +/** 一个产品的全部价格版本 */ +export function listPayTerm(productId) { + return request({ + url: '/price/pay/term/list', + method: 'get', + params: { productId } + }) +} + +/** 修改某一版的生效起止 */ +export function updatePayTerm(data) { + return request({ + url: '/price/pay/term', + method: 'put', + data + }) +} + +/** 生效或失效 */ +export function changePayTermStatus(data) { + return request({ + url: '/price/pay/term/status', + method: 'put', + data + }) +} + +/** 按订单渠道上的应付产品生成应付账单 */ +export function generatePayBill(billId) { + return request({ + url: '/price/pay/order/' + billId, + method: 'post' + }) +} + +/** 删除产品及其重量段、附加费、其他配置 */ +export function delPay(productIds) { + return request({ + url: '/price/pay/' + productIds, + method: 'delete' + }) +} diff --git a/bms-front/src/api/price/payBill.js b/bms-front/src/api/price/payBill.js new file mode 100644 index 00000000..3c5f3d05 --- /dev/null +++ b/bms-front/src/api/price/payBill.js @@ -0,0 +1,29 @@ +import request from '@/utils/request' + +export function listPayBill(query) { + return request({ url: '/price/payBill/list', method: 'get', params: query }) +} + +export function getPayBill(billId) { + return request({ url: '/price/payBill/' + billId, method: 'get' }) +} + +export function previewPayBill(data) { + return request({ url: '/price/payBill/preview', method: 'post', data }) +} + +export function generatePayBill(data) { + return request({ url: '/price/payBill', method: 'post', data, timeout: 10 * 60 * 1000 }) +} + +export function confirmPayBill(billId) { + return request({ url: '/price/payBill/confirm/' + billId, method: 'put' }) +} + +export function payPayBill(billId) { + return request({ url: '/price/payBill/paid/' + billId, method: 'put' }) +} + +export function voidPayBill(data) { + return request({ url: '/price/payBill/void', method: 'put', data }) +} diff --git a/bms-front/src/components/BasicForm/index.vue b/bms-front/src/components/BasicForm/index.vue index d5ab3a38..afacfc2f 100644 --- a/bms-front/src/components/BasicForm/index.vue +++ b/bms-front/src/components/BasicForm/index.vue @@ -25,7 +25,7 @@ - + @@ -51,7 +51,7 @@ - + @@ -159,13 +159,19 @@ const props = defineProps({ type: [String, Number], default: '0' }, - // 操作区(默认插槽)对齐方式:left / center(center 时独占一行并水平居中) + // 操作区(默认插槽)对齐方式:left / center / right actionAlign: { type: String, default: 'left' } }) +const actionClass = computed(() => { + if (props.actionAlign === 'center') return 'basic-form-action--center' + if (props.actionAlign === 'right') return 'basic-form-action--right' + return '' +}) + const { proxy } = getCurrentInstance() const formRef = ref() @@ -276,4 +282,17 @@ defineExpose({ display: flex; justify-content: center; } +/* 操作区靠右:独占整行,按钮贴在右侧 */ +.basic-form--perrow .basic-form-action--right { + grid-column: 1 / -1; +} +.basic-form .basic-form-action--right { + width: 100%; + flex: 0 0 100%; + margin-right: 0; +} +.basic-form-action--right :deep(.el-form-item__content) { + display: flex; + justify-content: flex-end; +} diff --git a/bms-front/src/components/BasicTable/index.vue b/bms-front/src/components/BasicTable/index.vue index 7562a7dd..77aaa722 100644 --- a/bms-front/src/components/BasicTable/index.vue +++ b/bms-front/src/components/BasicTable/index.vue @@ -208,7 +208,7 @@ const props = defineProps({ type: [String, Number], default: 'auto' }, - // 搜索按钮区对齐:left / center(搜索项较多按钮换行时可用 center 让按钮居中) + // 搜索按钮区对齐:left / center / right searchActionAlign: { type: String, default: 'left' diff --git a/bms-front/src/views/data/payPostcode/index.vue b/bms-front/src/views/data/payPostcode/index.vue new file mode 100644 index 00000000..c565e68f --- /dev/null +++ b/bms-front/src/views/data/payPostcode/index.vue @@ -0,0 +1,280 @@ + + + + + diff --git a/bms-front/src/views/data/serviceChannel/index.vue b/bms-front/src/views/data/serviceChannel/index.vue index 6dca5cea..99d47ed8 100644 --- a/bms-front/src/views/data/serviceChannel/index.vue +++ b/bms-front/src/views/data/serviceChannel/index.vue @@ -140,6 +140,14 @@ + + + + + +
{{ payTip }}
+
+
+ @@ -156,12 +165,22 @@ import { listServiceChannel, getServiceChannel, addServiceChannel, updateServiceChannel, delServiceChannel, changeServiceChannelStatus } from '@/api/masterdata/serviceChannel' +import { payOptions as listPayOptions, getChannelPay, bindChannelPay } from '@/api/price/pay' const { proxy } = getCurrentInstance() const tableRef = ref() const formRef = ref() const open = ref(false) const title = ref('') +const payOptions = ref([]) +const payOriginId = ref() +const payProduct = ref(null) +const syncingPay = ref(false) +const payTip = computed(() => { + if (!form.value?.id) return '先保存渠道,再选择应付产品。渠道绑定的是产品编号,更新价格不会换编号。' + if (payProduct.value?.versionNo) return `当前按有效期使用第 ${payProduct.value.versionNo} 版。历史订单按下单或计费时间取当时的版本。` + return '选择产品后即绑定,按应付 AP 保存。' +}) const openOptions = [ { label: '启用', value: '1' }, @@ -233,8 +252,42 @@ function emptyForm() { } } +function resetPay() { + syncingPay.value = true + payOriginId.value = undefined + payProduct.value = null + nextTick(() => { + syncingPay.value = false + }) +} + +function payOptionLabel(item) { + const version = item.versionNo > 1 ? `(当前第 ${item.versionNo} 版)` : '' + return `${item.productName}${version}` +} + +function loadPayOptions() { + listPayOptions('AP').then(res => { + payOptions.value = res.data || [] + }) +} + +function loadChannelPay(channelId) { + syncingPay.value = true + getChannelPay(channelId).then(res => { + payProduct.value = res.data || null + payOriginId.value = res.data?.originId || res.data?.productId + }).finally(() => { + nextTick(() => { + syncingPay.value = false + }) + }) +} + function handleAdd() { form.value = emptyForm() + resetPay() + loadPayOptions() title.value = '新增渠道' open.value = true nextTick(() => formRef.value && formRef.value.clearValidate()) @@ -243,12 +296,28 @@ function handleAdd() { function handleUpdate(row) { getServiceChannel(row.id).then(res => { form.value = Object.assign(emptyForm(), res.data || {}) + resetPay() + loadPayOptions() + loadChannelPay(row.id) title.value = '修改渠道' open.value = true nextTick(() => formRef.value && formRef.value.clearValidate()) }) } +function onPayOrigin(id) { + if (!form.value.id || syncingPay.value) return + if (!id) { + payProduct.value = null + bindChannelPay(form.value.id, null, 'AP') + return + } + bindChannelPay(form.value.id, id, 'AP').then(res => { + payProduct.value = res.data || null + payOriginId.value = res.data?.originId || id + }) +} + function submitForm() { formRef.value.validate(valid => { if (!valid) return @@ -302,3 +371,12 @@ function handleExport() { }, `service_channel_${new Date().getTime()}.xlsx`) } + + diff --git a/bms-front/src/views/price/basic/pay/components/PayConfigDetail.vue b/bms-front/src/views/price/basic/pay/components/PayConfigDetail.vue new file mode 100644 index 00000000..45525231 --- /dev/null +++ b/bms-front/src/views/price/basic/pay/components/PayConfigDetail.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/bms-front/src/views/price/basic/pay/components/PayImportDialog.vue b/bms-front/src/views/price/basic/pay/components/PayImportDialog.vue new file mode 100644 index 00000000..bc06fa3c --- /dev/null +++ b/bms-front/src/views/price/basic/pay/components/PayImportDialog.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/bms-front/src/views/price/basic/pay/index.vue b/bms-front/src/views/price/basic/pay/index.vue new file mode 100644 index 00000000..b0ffbacd --- /dev/null +++ b/bms-front/src/views/price/basic/pay/index.vue @@ -0,0 +1,396 @@ + + + + + diff --git a/bms-front/src/views/price/billing/bill.vue b/bms-front/src/views/price/billing/bill.vue index 96b9de3a..98c8b446 100644 --- a/bms-front/src/views/price/billing/bill.vue +++ b/bms-front/src/views/price/billing/bill.vue @@ -164,6 +164,8 @@