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 index 434d7755..0de22d4d 100644 --- 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 @@ -27,6 +27,12 @@ public class PayBillController extends BaseController @Autowired private IPayBillService payBillService; + /** + * 分页查询应付账单。 + * + * @param query 账单号、供应商、状态、币种,空着的条件不过滤 + * @return 账单列表,不含费用明细 + */ @PreAuthorize("@ss.hasPermi('price:payBill:list')") @GetMapping("/list") public TableDataInfo list(PayBill query) @@ -36,6 +42,12 @@ public class PayBillController extends BaseController return getDataTable(list); } + /** + * 查询一张应付账单的明细和操作日志。 + * + * @param billId 应付账单ID + * @return 账单头、费用行和日志 + */ @PreAuthorize("@ss.hasPermi('price:payBill:query')") @GetMapping("/{billId}") public AjaxResult detail(@PathVariable Long billId) @@ -43,6 +55,12 @@ public class PayBillController extends BaseController return success(payBillService.selectPayBillDetail(billId)); } + /** + * 预览本次能生成账单的订单。只查询,不重算费用,也不写账单。 + * + * @param command 查询方式,以及订单号、计费日或提单号 + * @return 可生成订单、未找到的单号、已经入账的订单 + */ @PreAuthorize("@ss.hasPermi('price:payBill:query')") @PostMapping("/preview") public AjaxResult preview(@RequestBody PayBillCommand command) @@ -50,6 +68,12 @@ public class PayBillController extends BaseController return success(payBillService.preview(command)); } + /** + * 生成应付账单。先重算每票订单的尾程应付,再把当时的费用抄进草稿账单。 + * + * @param command 供应商,以及按订单号、计费日或提单号划定的范围 + * @return 刚生成的草稿账单 + */ @PreAuthorize("@ss.hasPermi('price:payBill:add')") @PostMapping public AjaxResult generate(@RequestBody PayBillCommand command) @@ -57,6 +81,11 @@ public class PayBillController extends BaseController return success(payBillService.generate(command)); } + /** + * 确认草稿账单。确认后才可以登记付款。 + * + * @param billId 应付账单ID + */ @PreAuthorize("@ss.hasPermi('price:payBill:confirm')") @PutMapping("/confirm/{billId}") public AjaxResult confirm(@PathVariable Long billId) @@ -65,6 +94,11 @@ public class PayBillController extends BaseController return success(); } + /** + * 把已确认的账单登记为已付款。已付款的账单不能直接作废。 + * + * @param billId 应付账单ID + */ @PreAuthorize("@ss.hasPermi('price:payBill:confirm')") @PutMapping("/paid/{billId}") public AjaxResult paid(@PathVariable Long billId) @@ -73,6 +107,11 @@ public class PayBillController extends BaseController return success(); } + /** + * 作废应付账单。账单和费用行保留,只改状态并记下作废原因。 + * + * @param command 账单ID和作废原因 + */ @PreAuthorize("@ss.hasPermi('price:payBill:void')") @PutMapping("/void") public AjaxResult voidBill(@RequestBody PayBillCommand command) 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 index 7c361881..4332169d 100644 --- 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 @@ -1,12 +1,15 @@ package com.bms.price.pay; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.Test; import com.bms.price.domain.PayOrderCostResult; +import com.bms.price.domain.PayPostcodeGroup; +import com.bms.price.domain.PayPostcodeLine; import com.bms.price.domain.PayProduct; import com.bms.price.domain.PaySurcharge; import com.bms.price.domain.PayWeightBand; @@ -113,6 +116,133 @@ class PayCostCalculatorTest assertTrue(hasFee(result, "基础运费", "1.57")); } + @Test + void remoteZoneUsesMainlandFreightAndItsOwnSurcharge() + { + PayProduct product = new PayProduct(); + product.setProductName("Evri 48H"); + product.setCurrency("GBP"); + product.setWeightBands(new ArrayList<>(List.of( + band("0", "1", "1.51", "UK Mainland"), + band("1", "2", "1.72", "UK Mainland")))); + PaySurcharge light = fee(1L, "偏远附加费", "5.95", "GBP", "ZONE", null); + light.setZoneName("Highlands& Islands"); + light.setWeightMin(BigDecimal.ZERO); + light.setWeightMax(BigDecimal.ONE); + PaySurcharge heavy = fee(2L, "偏远附加费", "6.20", "GBP", "ZONE", null); + heavy.setZoneName("Highlands& Islands"); + heavy.setWeightMin(BigDecimal.ONE); + heavy.setWeightMax(new BigDecimal("2")); + product.setSurcharges(List.of(light, heavy)); + + PayOrderCostResult remote = PayCostCalculator.calculate(product, new BigDecimal("0.5"), + null, null, null, "IV1 1AA", "Highlands& Islands", null, 1); + assertTrue(hasFee(remote, "基础运费", "1.51")); + assertTrue(hasFee(remote, "偏远附加费", "5.95")); + assertEquals(1, remote.getFees().stream().filter(fee -> "偏远附加费".equals(fee.getFeeName())).count()); + + PayOrderCostResult mainland = PayCostCalculator.calculate(product, new BigDecimal("0.5"), + null, null, null, "LL22 8PJ", "UK Mainland", null, 1); + assertTrue(hasFee(mainland, "基础运费", "1.51")); + assertTrue(mainland.getFees().stream().noneMatch(fee -> "偏远附加费".equals(fee.getFeeName()))); + } + + @Test + void spanishPrefixChargesFirstWeightThenExtraKg() + { + PayProduct product = new PayProduct(); + product.setProductName("CTT 48H"); + product.setCurrency("EUR"); + product.setWeightBands(new ArrayList<>(List.of(band("0", "5", "2.05", "马德里")))); + PaySurcharge first = fee(1L, "偏远附加费", "7.1", "EUR", "POSTCODE", "07"); + first.setZoneName("Balearic Islands"); + first.setPostcodeParam("07"); + first.setWeightMin(BigDecimal.ZERO); + first.setWeightMax(BigDecimal.ONE); + PaySurcharge extra = fee(2L, "偏远附加费", "4", "EUR", "EXTRA_KG", "1"); + extra.setZoneName("Balearic Islands"); + extra.setPostcodeParam("07"); + extra.setPriceUnit("EUR/KG"); + product.setSurcharges(List.of(first, extra)); + + PayOrderCostResult light = PayCostCalculator.calculate(product, new BigDecimal("0.8"), + null, null, null, "07008", "马德里", null, 1); + assertTrue(hasFee(light, "基础运费", "2.05")); + assertTrue(hasFee(light, "偏远附加费", "7.1")); + assertEquals(1, light.getFees().stream().filter(fee -> "偏远附加费".equals(fee.getFeeName())).count()); + + PayOrderCostResult heavy = PayCostCalculator.calculate(product, new BigDecimal("3"), + null, null, null, "07008", "马德里", null, 1); + assertTrue(hasFee(heavy, "基础运费", "2.05")); + assertTrue(hasFee(heavy, "偏远附加费", "7.1")); + assertTrue(hasFee(heavy, "偏远附加费", "8")); + + PayOrderCostResult mainland = PayCostCalculator.calculate(product, new BigDecimal("3"), + null, null, null, "28001", "马德里", null, 1); + assertTrue(mainland.getFees().stream().noneMatch(fee -> "偏远附加费".equals(fee.getFeeName()))); + assertTrue(PaySurchargeRules.matchesPostcode("07,35,38,51,52,9", "35001")); + assertTrue(PaySurchargeRules.matchesPostcode("07,35,38,51,52,9", "9000")); + assertFalse(PaySurchargeRules.matchesPostcode("07,35,38,51,52,9", "28001")); + assertTrue(PaySurchargeRules.matchesPostcode("IV,HS,KA27-28", "KA27 8AA")); + assertEquals("07", PaySurchargeRules.remotePrefixes("Balearic Islands 07邮编开头")); + assertEquals("35,38", PaySurchargeRules.remotePrefixes("Canary Islands 35、38邮编开头")); + assertEquals("9", PaySurchargeRules.remotePrefixes("Portuguese Island 9邮编开头")); + assertEquals("", PaySurchargeRules.remotePrefixes("岛屿邮编地区")); + } + + @Test + void productPostcodeBookChoosesTheMatchingRemoteFee() + { + List hits = List.of(new PayRemoteBook.Hit("偏远附加费", new BigDecimal("1.80"), "", "Highlands")); + PayOrderCostResult result = PayCostCalculator.calculate(royalMail(), new BigDecimal("4"), + null, null, null, "IV1 1AA", null, null, 1, hits); + assertTrue(hasFee(result, "偏远地区附加费", "1.80")); + assertTrue(result.getFees().stream().noneMatch(fee -> "偏远地区附加费".equals(fee.getFeeName()) + && fee.getAmount().compareTo(new BigDecimal("1.00")) == 0)); + } + + @Test + void linkedRemoteBookWithoutAHitDoesNotUseThePriceCardList() + { + PayOrderCostResult result = PayCostCalculator.calculate(royalMail(), new BigDecimal("4"), + null, null, null, "IV1 1AA", null, null, 1, List.of()); + assertTrue(result.getFees().stream().noneMatch(fee -> fee.getFeeName().contains("偏远"))); + assertTrue(hasFee(result, "基础运费", "1.62")); + } + + @Test + void remoteBookChargesAManualIslandFeeAndPerKg() + { + PayProduct product = new PayProduct(); + product.setProductName("德国DPD挂号-国际件"); + product.setCurrency("EUR"); + product.setWeightBands(new ArrayList<>(List.of(band("0", "5", "3.20", "")))); + PaySurcharge island = fee(31L, "岛屿附加费", "12", "EUR", "MANUAL", null); + PaySurcharge perKg = fee(32L, "偏远附加费", "0.35", "EUR", "MANUAL", null); + perKg.setPriceUnit("EUR/KG"); + product.setSurcharges(List.of(island, perKg)); + List hits = List.of(new PayRemoteBook.Hit("", null, "", "岛屿")); + PayOrderCostResult result = PayCostCalculator.calculate(product, new BigDecimal("2"), + null, null, null, "1234", null, null, 1, hits); + assertTrue(hasFee(result, "岛屿附加费", "12")); + assertTrue(hasFee(result, "偏远附加费", "0.70")); + } + + @Test + void remoteHitsFollowTheProductGroupAndCountry() + { + PayPostcodeGroup germany = group(44L, "西班牙GLS", "REMOTE", "", "DE"); + PayPostcodeGroup france = group(45L, "西班牙GLS", "REMOTE", "", "FR"); + PayPostcodeLine berlin = line(44L, "10115"); + PayPostcodeLine paris = line(45L, "75001"); + List de = PayRemoteBook.hits(List.of(germany, france), List.of(berlin, paris), "10115", "DE", "德国"); + assertEquals(1, de.size()); + List none = PayRemoteBook.hits(List.of(germany, france), List.of(berlin, paris), "28001", "ES", "西班牙"); + assertTrue(none.isEmpty()); + PayPostcodeGroup delivery = group(42L, "西班牙CTT", "", "", "ES"); + assertTrue(PayRemoteBook.hits(List.of(delivery), List.of(line(42L, "28001")), "28001", "ES", "西班牙") == null); + } + @Test void severalZonesCannotBePricedFromTheOrder() { @@ -192,6 +322,26 @@ class PayCostCalculatorTest return band; } + private static PayPostcodeGroup group(Long id, String quoteSheet, String coverage, String feeName, String country) + { + PayPostcodeGroup group = new PayPostcodeGroup(); + group.setGroupId(id); + group.setQuoteSheet(quoteSheet); + group.setCoverage(coverage); + group.setFeeName(feeName); + group.setCountryCode(country); + group.setConfigKind("ZONE"); + return group; + } + + private static PayPostcodeLine line(Long groupId, String postcode) + { + PayPostcodeLine line = new PayPostcodeLine(); + line.setGroupId(groupId); + line.setPostcode(postcode); + return line; + } + private static PaySurcharge fee(Long id, String name, String amount, String currency, String rule, String param) { PaySurcharge fee = new PaySurcharge(); 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 index 3927b095..3608e284 100644 --- 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 @@ -204,7 +204,7 @@ class PayWorkbookParserTest @Test void dictMatchReadsNamedSheetAndSkipsOthers() throws Exception { - Path file = Path.of("D:/payment/Evri 48H.xlsx"); + Path file = priceCard("Evri 48H.xlsx"); Assumptions.assumeTrue(Files.exists(file)); PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "Evri 48H", List.of("Evri 48H", "DHL Paket")); @@ -213,6 +213,14 @@ class PayWorkbookParserTest assertEquals("Evri 48H", evri.getDictValue()); assertEquals("Evri 48H", evri.getSheetName()); assertFalse(evri.getWeightBands().isEmpty()); + assertTrue(evri.getWeightBands().stream().allMatch(band -> "派送费".equals(band.getChargeName()))); + assertTrue(evri.getWeightBands().stream().noneMatch(band -> bandText(band).contains("偏远") || bandText(band).contains("Highlands"))); + assertTrue(evri.getWeightBands().stream().anyMatch(band -> decimal(band, "1.51") && band.getZoneName().contains("Mainland"))); + PaySurcharge highlands = evri.getSurcharges().stream() + .filter(fee -> "偏远附加费".equals(fee.getFeeName()) && fee.getZoneName().contains("Highlands") && decimalFee(fee, "5.95")) + .findFirst().orElseThrow(); + assertEquals("ZONE", highlands.getCalcRule()); + assertTrue(highlands.getWeightMax() != null && highlands.getWeightMax().compareTo(java.math.BigDecimal.ONE) == 0); } @Test @@ -280,7 +288,7 @@ class PayWorkbookParserTest @Test void royalMailReadsRemoteAreaTableAndSkipsRemarks() throws Exception { - Path file = Path.of("D:/payment/英国RM.xlsx"); + Path file = priceCard("英国RM.xlsx"); Assumptions.assumeTrue(Files.exists(file)); PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "英国RM", List.of("英国RM")); assertEquals(1, preview.getProducts().size()); @@ -318,6 +326,21 @@ class PayWorkbookParserTest assertTrue(rm.getExtraLines().isEmpty()); } + @Test + void royalMail48hRemoteRowsMatchPostcodePrefixes() throws Exception + { + Path file = priceCard("Royal Mail 48H.xlsx"); + Assumptions.assumeTrue(Files.exists(file)); + PayProduct rm = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "Royal Mail 48H", + List.of("Royal Mail 48H")).getProducts().get(0); + List remote = rm.getSurcharges().stream().filter(fee -> fee.getFeeName() != null && fee.getFeeName().contains("偏远")).toList(); + assertFalse(remote.isEmpty(), rm.getSurcharges().stream().map(fee -> fee.getFeeName() + "/" + fee.getCalcRule() + "/" + fee.getCalcParam()).toList().toString()); + assertTrue(remote.stream().allMatch(fee -> "POSTCODE".equals(fee.getCalcRule())), + remote.stream().map(fee -> fee.getFeeName() + "/" + fee.getCalcParam() + "/" + fee.getFeeDesc()).toList().toString()); + assertTrue(remote.stream().anyMatch(fee -> fee.getCalcParam() != null && fee.getCalcParam().contains("IV") && !fee.getCalcParam().contains("邮编"))); + assertTrue(rm.getWeightBands().stream().noneMatch(band -> bandText(band).contains("偏远"))); + } + @Test void evriOtherFeesKeepOneRowPerRegion() throws Exception { @@ -418,7 +441,7 @@ class PayWorkbookParserTest @Test void ctt48hFileKeepsSurchargeTablesOutOfWeightBands() throws Exception { - Path file = Path.of("D:/payment/CTT 48H.xlsx"); + Path file = priceCard("CTT 48H.xlsx"); PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "CTT 48H", List.of("CTT 48H")); PayProduct ctt = preview.getProducts().get(0); @@ -431,8 +454,17 @@ class PayWorkbookParserTest .filter(fee -> "偏远附加费".equals(fee.getFeeName()) && fee.getZoneName().contains("Balearic") && decimalFee(fee, "7.1")) .findFirst().orElseThrow(); assertTrue(balearic.getFeeDesc().contains("首重")); - assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> "偏远附加费".equals(fee.getFeeName()) - && fee.getZoneName().contains("Canary") && decimalFee(fee, "4.5") && fee.getChargeText().contains("KG"))); + assertEquals("POSTCODE", balearic.getCalcRule()); + assertTrue(balearic.getCalcParam() != null && balearic.getCalcParam().contains("07")); + assertTrue(balearic.getWeightMax() != null && balearic.getWeightMax().compareTo(new java.math.BigDecimal("1")) == 0); + PaySurcharge canary = ctt.getSurcharges().stream() + .filter(fee -> "偏远附加费".equals(fee.getFeeName()) && fee.getZoneName().contains("Canary") && decimalFee(fee, "4.5") + && fee.getChargeText().contains("KG")) + .findFirst().orElseThrow(); + assertEquals("EXTRA_KG", canary.getCalcRule()); + assertEquals("1", canary.getCalcParam()); + assertTrue(canary.getPostcodeParam() != null && canary.getPostcodeParam().contains("35") && canary.getPostcodeParam().contains("38")); + assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("额外线路运输费") && "MANUAL".equals(fee.getCalcRule()))); assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("额外线路运输费") && decimalFee(fee, "0.09"))); assertTrue(ctt.getSurcharges().stream().anyMatch(fee -> fee.getFeeName().contains("额外线路运输费") && fee.getFeeDesc().contains("800公里"))); @@ -442,7 +474,7 @@ class PayWorkbookParserTest @Test void spainCttMainlandReadsBillableConcepts() throws Exception { - Path file = Path.of("D:/payment/西班牙CTT带电专线(主要地区).xlsx"); + Path file = priceCard("西班牙CTT带电专线(主要地区).xlsx"); Assumptions.assumeTrue(Files.exists(file)); PayPreview preview = parser.parseMatched(Files.readAllBytes(file), file.getFileName().toString(), "西班牙CTT带电专线(主要地区)", List.of("西班牙CTT带电专线(主要地区)")); @@ -711,6 +743,16 @@ class PayWorkbookParserTest return fee.getAmount() != null && fee.getAmount().compareTo(new java.math.BigDecimal(amount)) == 0; } + private static Path priceCard(String name) + { + Path current = Path.of("D:/A-payment").resolve(name); + if (Files.exists(current)) + { + return current; + } + return Path.of("D:/payment").resolve(name); + } + private String bandText(PayWeightBand band) { return (band.getChargeName() == null ? "" : band.getChargeName()) + " " 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 index 0597e3cf..57034158 100644 --- 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 @@ -6,22 +6,30 @@ import lombok.Data; @Data public class PayBillCommand { - /** ORDER、TIME、LADING */ + /** 取数方式:ORDER 订单号,TIME 计费日,LADING 提单号 */ private String sourceType; + /** 订单号原文,可用逗号、空格或换行分隔 */ private String orderText; + /** 计费日起,yyyy-MM-dd。sourceType 为 TIME 时必填 */ private String beginDate; + /** 计费日止,yyyy-MM-dd,含当天 */ private String endDate; + /** 提单号原文,可用逗号、空格或换行分隔 */ private String ladingText; + /** 供应商ID。生成账单时必填,预览不用 */ private Long supplierId; + /** 账单备注 */ private String remark; + /** 作废原因。作废时必填 */ private String voidReason; + /** 要作废的应付账单ID */ private Long billId; } 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 index f2c72759..c36f0892 100644 --- 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 @@ -8,11 +8,15 @@ 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/PaySurcharge.java b/bms-back/bms-system/src/main/java/com/bms/price/domain/PaySurcharge.java index 7c0dca04..4856e32b 100644 --- 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 @@ -51,11 +51,20 @@ public class PaySurcharge /** 固定收取、产生时收取、旺季期间 */ private String chargeTiming; - /** ALWAYS、POSTCODE、WEIGHT、SIZE、RETURN、EXTRA_KG、MANUAL */ + /** ALWAYS、POSTCODE、ZONE、WEIGHT、SIZE、RETURN、EXTRA_KG、MANUAL */ private String calcRule; - /** 邮编列表、重量阈值或尺寸表达式 */ + /** 邮编列表、重量阈值或尺寸表达式。续重时这里是公斤阈值 */ private String calcParam; + /** 偏远邮编规则。续重行的阈值在 calcParam,邮编放这里,避免互相覆盖 */ + private String postcodeParam; + + /** 适用重量下限,空表示不限。首重从 0 开始 */ + private BigDecimal weightMin; + + /** 适用重量上限,空表示不限。分区价按这个区间只收一档 */ + private BigDecimal weightMax; + private Integer sortNo; } 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 index 84fd3760..d73f563e 100644 --- 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 @@ -8,26 +8,90 @@ import com.bms.price.domain.PayBillLog; import com.bms.price.domain.PayBillOrder; import com.bms.price.domain.PrcPayBillFee; +/** 供应商应付账单 pay_bill 及其明细、日志。 */ public interface PayBillMapper { + /** + * 按账单号、供应商、状态、币种查询账单头。 + * + * @param query 查询条件 + * @return 账单头列表 + */ List selectPayBillList(PayBill query); + /** + * 按主键读取一张账单头。 + * + * @param billId 应付账单ID + * @return 账单头,不存在时为 null + */ PayBill selectPayBillById(Long billId); + /** + * 读取一张账单的费用快照。 + * + * @param billId 应付账单ID + * @return 费用行 + */ List selectLines(Long billId); + /** + * 读取一张账单的操作日志。 + * + * @param billId 应付账单ID + * @return 按写入顺序排列的日志 + */ List selectLogs(Long billId); + /** + * 按订单号、计费日或提单号查找可出账订单,并标出已挂在未作废账单上的账单号。 + * + * @param orderNos 订单号,空则不按订单号限制 + * @param beginDate 计费日起,yyyy-MM-dd,空则不限制 + * @param endDate 计费日止,含当天,空则不限制 + * @param ladingNos 提单号,同时匹配订单上的航空主单号和应付提单 + * @return 最多 2000 票订单 + */ List selectCandidateOrders(@Param("orderNos") List orderNos, @Param("beginDate") String beginDate, @Param("endDate") String endDate, @Param("ladingNos") List ladingNos); + /** + * 读取这些订单当前的应付费用,供生成账单时抄成快照。 + * + * @param billIds 计费订单ID + * @return 订单应付费用 + */ List selectOrderFees(@Param("billIds") List billIds); + /** + * 插入一张草稿应付账单。 + * + * @param bill 账单头,账单号和供应商由调用方填好 + * @return 影响行数 + */ int insertPayBill(PayBill bill); + /** + * 更新账单状态。作废时同时写原因、作废人和作废时间。 + * + * @param bill 至少包含账单ID、状态和更新人 + * @return 影响行数 + */ int updatePayBillStatus(PayBill bill); + /** + * 批量写入账单费用快照。 + * + * @param list 费用行,每行已带账单ID + * @return 影响行数 + */ int insertLines(@Param("list") List list); + /** + * 写入一条账单操作日志。 + * + * @param log 操作类型、前后状态和说明 + * @return 影响行数 + */ 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 index 7d06619f..02bb6c98 100644 --- 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 @@ -24,6 +24,9 @@ public interface PayPostcodeMapper List selectGroupsByTokens(@Param("tokens") List tokens); + /** 报价表、销售产品或工作表与产品名称完全一致的邮编配置 */ + List selectGroupsForProduct(@Param("names") List names); + List selectRegionKeys(@Param("groupIds") List groupIds); List selectLinesByGroups(@Param("groupIds") List groupIds); 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 70900d78..09624ead 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 @@ -137,4 +137,7 @@ public interface PrcOrderBillMapper /** 只更新应付合计,不改应收。 */ int updatePayTotal(@Param("billId") String billId, @Param("payTotal") BigDecimal payTotal); + + /** Count order bills that reference this sales package version. */ + int countByPsvId(@Param("psvId") Long psvId); } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPayBillFeeMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPayBillFeeMapper.java index 63009036..a18e57f3 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPayBillFeeMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPayBillFeeMapper.java @@ -4,14 +4,38 @@ import java.util.List; import org.apache.ibatis.annotations.Param; import com.bms.price.domain.PrcPayBillFee; -/** 应付账单费用 mapper */ +/** 订单应付费用 prc_pay_bill_fee。 */ public interface PrcPayBillFeeMapper { + /** + * 读取一票订单的全部应付费用,运费在前,附加费在后。 + * + * @param billId 计费订单ID + * @return 费用明细 + */ public List selectByBillId(String billId); + /** + * 插入一条订单应付费用。 + * + * @param fee 费用类型、名称、金额和币种 + * @return 影响行数 + */ public int insertPrcPayBillFee(PrcPayBillFee fee); + /** + * 批量插入订单应付费用。 + * + * @param fees 本次要保存的费用 + * @return 影响行数 + */ public int insertBatch(@Param("list") List fees); + /** + * 删除一票订单上来源为计算的费用。提单分摊和人工费用保留。 + * + * @param billId 计费订单ID + * @return 影响行数 + */ public int deleteByBillId(String billId); } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPayBillMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPayBillMapper.java index 7ea27530..14300d4f 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPayBillMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPayBillMapper.java @@ -2,18 +2,54 @@ package com.bms.price.mapper; import com.bms.price.domain.PrcPayBill; -/** prc_pay_bill mapper */ +/** 订单应付主表 prc_pay_bill。一票计费订单对应一行。 */ public interface PrcPayBillMapper { + /** + * 按计费订单读取应付主表。 + * + * @param billId 计费订单ID + * @return 订单应付,没有时为 null + */ PrcPayBill selectByBillId(String billId); + /** + * 按应付主表ID读取。 + * + * @param payBillId 订单应付ID + * @return 订单应付,没有时为 null + */ PrcPayBill selectByPayBillId(String payBillId); + /** + * 新增一票订单的应付主表。 + * + * @param row 应付合计、币种和业务员 + * @return 影响行数 + */ int insertPrcPayBill(PrcPayBill row); + /** + * 更新已有订单应付的合计和业务员。 + * + * @param row 含应付主表ID的更新内容 + * @return 影响行数 + */ int updatePrcPayBill(PrcPayBill row); + /** + * 按计费订单插入或更新应付主表。 + * + * @param row 订单应付 + * @return 影响行数 + */ int upsertPrcPayBill(PrcPayBill row); + /** + * 删除一票订单的应付主表。 + * + * @param billId 计费订单ID + * @return 影响行数 + */ int deleteByBillId(String billId); } 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 index 103abc87..d4a86313 100644 --- 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 @@ -28,14 +28,62 @@ public final class PayCostCalculator { } + /** + * 按重量、尺寸和邮编计算一票尾程应付。不指定地区,也不传入偏远命中结果。 + * + * @param product 当前价卡版本,含重量段和附加费 + * @param weight 计费重,公斤 + * @param length 长,厘米 + * @param width 宽,厘米 + * @param height 高,厘米 + * @param postcode 收件邮编 + * @param bizDate 取价日,用来判断附加费是否在有效期内 + * @param orderStatus 订单状态,退件时才收退件类附加费 + * @return 费用明细和算不了的附加费说明 + */ 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); } + /** + * 在已经选定价卡地区时计算尾程应付。偏远仍按价卡上的邮编条件判断。 + * + * @param product 当前价卡版本 + * @param weight 计费重,公斤 + * @param length 长,厘米 + * @param width 宽,厘米 + * @param height 高,厘米 + * @param postcode 收件邮编 + * @param zoneName 邮编对上的价卡地区,空表示不按地区过滤 + * @param bizDate 取价日 + * @param orderStatus 订单状态 + * @return 费用明细和算不了的附加费说明 + */ public static PayOrderCostResult calculate(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width, BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus) + { + return calculate(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, null); + } + + /** + * 计算一票尾程应付。remoteHits 为空列表表示产品有偏远邮编但这次没命中;为 null 表示没有这份配置,仍按价卡上的邮编条件。 + * + * @param product 当前价卡版本 + * @param weight 计费重,公斤 + * @param length 长,厘米 + * @param width 宽,厘米 + * @param height 高,厘米 + * @param postcode 收件邮编 + * @param zoneName 价卡地区 + * @param bizDate 取价日 + * @param orderStatus 订单状态 + * @param remoteHits 偏远邮编命中结果 + * @return 费用明细和算不了的附加费说明 + */ + public static PayOrderCostResult calculate(PayProduct product, BigDecimal weight, BigDecimal length, BigDecimal width, + BigDecimal height, String postcode, String zoneName, Date bizDate, Integer orderStatus, List remoteHits) { PayOrderCostResult result = new PayOrderCostResult(); result.setFees(new ArrayList<>()); @@ -52,7 +100,12 @@ public final class PayCostCalculator result.setMessage("订单没有计费重"); return result; } - List bands = forZone(usableBands(product.getWeightBands()), zoneName); + List usable = usableBands(product.getWeightBands()); + List bands = forZone(usable, zoneName); + if (zoneName != null && !zoneName.isBlank() && bands.isEmpty() && hasZoneFee(product, zoneName)) + { + bands = deliveryBands(usable, product); + } if (zoneName != null && !zoneName.isBlank() && bands.isEmpty()) { result.setMessage("邮编对应的地区「" + zoneName + "」不在价卡重量段里"); @@ -88,7 +141,7 @@ public final class PayCostCalculator result.getFees().add(freightLine); result.setCurrency(currency); addSurcharges(product, weight, length, width, height, postcode, zoneName, bizDate, orderStatus, freight, currency, - band.getSectionName(), result); + band.getSectionName(), remoteHits, result); BigDecimal total = BigDecimal.ZERO; for (PayCostLine fee : result.getFees()) { @@ -99,6 +152,79 @@ public final class PayCostCalculator return result; } + /** 邮编落到偏远分区,而价卡重量段只有大陆派送费时,运费用大陆档。 */ + private static boolean hasZoneFee(PayProduct product, String zoneName) + { + if (product.getSurcharges() == null) + { + return false; + } + for (PaySurcharge fee : product.getSurcharges()) + { + if (fee != null && PaySurchargeRules.ZONE.equals(fee.getCalcRule()) && PayZoneMatcher.sameZone(fee.getZoneName(), zoneName)) + { + return true; + } + } + return false; + } + + private static List deliveryBands(List bands, PayProduct product) + { + List remoteZones = new ArrayList<>(); + if (product.getSurcharges() != null) + { + for (PaySurcharge fee : product.getSurcharges()) + { + if (fee != null && PaySurchargeRules.ZONE.equals(fee.getCalcRule()) && fee.getZoneName() != null && !fee.getZoneName().isBlank()) + { + remoteZones.add(fee.getZoneName()); + } + } + } + List kept = new ArrayList<>(); + for (PayWeightBand band : bands) + { + boolean remote = false; + for (String zone : remoteZones) + { + if (PayZoneMatcher.sameZone(band.getZoneName(), zone)) + { + remote = true; + break; + } + } + if (!remote) + { + kept.add(band); + } + } + return kept; + } + + /** 首重对更重的包裹仍然收取,超出部分由续重另计。 */ + private static boolean weightAtLeast(PaySurcharge fee, BigDecimal weight) + { + if (fee.getWeightMin() == null || weight == null) + { + return true; + } + return weight.compareTo(fee.getWeightMin()) >= 0; + } + + private static boolean weightIn(PaySurcharge fee, BigDecimal weight) + { + if (weight == null || (fee.getWeightMin() == null && fee.getWeightMax() == null)) + { + return false; + } + if (fee.getWeightMin() != null && weight.compareTo(fee.getWeightMin()) < 0) + { + return false; + } + return fee.getWeightMax() == null || weight.compareTo(fee.getWeightMax()) <= 0; + } + private static List forZone(List bands, String zoneName) { if (zoneName == null || zoneName.isBlank()) @@ -118,7 +244,7 @@ public final class PayCostCalculator 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, - String freightSection, PayOrderCostResult result) + String freightSection, List remoteHits, PayOrderCostResult result) { if (product.getSurcharges() == null) { @@ -131,24 +257,59 @@ public final class PayCostCalculator { 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 (remoteHits != null && remoteFee(fee)) { + String name = blank(fee.getFeeName()); if (postcode == null || postcode.isBlank()) { result.getSkipped().add(name + ":订单没有邮编"); } - else if (PaySurchargeRules.matchesPostcode(fee.getCalcParam(), postcode)) + else if (bookApplies(remoteHits, fee, rule)) + { + String remark = "邮编 " + postcode.trim(); + if (PaySurchargeRules.EXTRA_KG.equals(rule)) + { + addExtraKg(result.getFees(), fee, weight, currency); + } + else if (perKg(fee)) + { + addPerKg(result.getFees(), fee, weight, currency, remark); + } + else + { + addFlat(result.getFees(), fee, currency, remark); + } + } + continue; + } + boolean postcodeDriven = PaySurchargeRules.POSTCODE.equals(rule) || !blank(fee.getPostcodeParam()).isEmpty(); + if (!postcodeDriven && zoneName != null && !zoneName.isBlank() && fee.getZoneName() != null && !fee.getZoneName().isBlank() + && !PayZoneMatcher.sameZone(fee.getZoneName(), zoneName)) + { + continue; + } + String name = blank(fee.getFeeName()); + if (PaySurchargeRules.POSTCODE.equals(rule)) + { + String param = blank(fee.getPostcodeParam()).isEmpty() ? fee.getCalcParam() : fee.getPostcodeParam(); + if (postcode == null || postcode.isBlank()) + { + result.getSkipped().add(name + ":订单没有邮编"); + } + else if (PaySurchargeRules.matchesPostcode(param, postcode) && weightAtLeast(fee, weight)) { addFlat(result.getFees(), fee, currency, "邮编 " + postcode.trim()); } } + else if (PaySurchargeRules.ZONE.equals(rule)) + { + boolean already = result.getFees().stream().anyMatch(line -> name.equals(line.getFeeName())); + if (!already && zoneName != null && !zoneName.isBlank() && PayZoneMatcher.sameZone(fee.getZoneName(), zoneName) && weightIn(fee, weight)) + { + addFlat(result.getFees(), fee, currency, "地区 " + zoneName.replace('\n', ' ')); + } + } else if (PaySurchargeRules.WEIGHT.equals(rule)) { if (PaySurchargeRules.matchesWeight(fee.getCalcParam(), weight)) @@ -180,7 +341,18 @@ public final class PayCostCalculator } else if (PaySurchargeRules.EXTRA_KG.equals(rule)) { - if (sameSection(fee.getSectionName(), freightSection)) + if (postcodeDriven) + { + if (postcode == null || postcode.isBlank()) + { + result.getSkipped().add(name + ":订单没有邮编"); + } + else if (PaySurchargeRules.matchesPostcode(fee.getPostcodeParam(), postcode)) + { + addExtraKg(result.getFees(), fee, weight, currency); + } + } + else if (sameSection(fee.getSectionName(), freightSection)) { addExtraKg(result.getFees(), fee, weight, currency); } @@ -278,7 +450,8 @@ public final class PayCostCalculator } for (PaySurcharge fee : product.getSurcharges()) { - if (fee == null || !PaySurchargeRules.EXTRA_KG.equals(fee.getCalcRule()) || fee.getAmount() == null) + if (fee == null || !PaySurchargeRules.EXTRA_KG.equals(fee.getCalcRule()) || fee.getAmount() == null + || !blank(fee.getPostcodeParam()).isEmpty()) { continue; } @@ -319,6 +492,90 @@ public final class PayCostCalculator return top; } + private static boolean remoteFee(PaySurcharge fee) + { + String name = blank(fee.getFeeName()).toLowerCase(Locale.ROOT); + return name.contains("偏远") || name.contains("岛屿") || name.contains("remote") || name.contains("island"); + } + + /** 邮编配置命中后,金额相同的那一档才收;分区写明时还要对上地区。 */ + private static boolean bookApplies(List hits, PaySurcharge fee, String rule) + { + boolean extra = PaySurchargeRules.EXTRA_KG.equals(rule); + boolean zoned = !blank(fee.getZoneName()).isEmpty(); + for (PayRemoteBook.Hit hit : hits) + { + if (hit == null || !sameFeeName(hit.getFeeName(), fee.getFeeName())) + { + continue; + } + boolean amountKnown = hit.getAmount() != null && fee.getAmount() != null; + if (amountKnown && hit.getAmount().compareTo(fee.getAmount()) != 0 && !extra) + { + continue; + } + boolean areaKnown = !blank(hit.getZoneName()).isEmpty() || !blank(hit.getRegionName()).isEmpty(); + if (zoned && areaKnown && !sameArea(hit, fee)) + { + continue; + } + if (extra && zoned && !sameArea(hit, fee)) + { + continue; + } + if (extra && amountKnown && hit.getAmount().compareTo(fee.getAmount()) != 0 && !sameArea(hit, fee)) + { + continue; + } + return true; + } + return false; + } + + private static boolean sameFeeName(String bookName, String feeName) + { + String book = feeKey(bookName); + String fee = feeKey(feeName); + if (book.isEmpty()) + { + return fee.contains("偏远") || fee.contains("岛屿") || fee.contains("remote") || fee.contains("island"); + } + return fee.contains(book) || book.contains(fee); + } + + private static String feeKey(String name) + { + return blank(name).toLowerCase(Locale.ROOT).replace("地区", "").replace(" ", ""); + } + + private static boolean sameArea(PayRemoteBook.Hit hit, PaySurcharge fee) + { + return PayZoneMatcher.sameZone(fee.getZoneName(), hit.getZoneName()) + || PayZoneMatcher.sameZone(fee.getZoneName(), hit.getRegionName()); + } + + private static boolean perKg(PaySurcharge fee) + { + String unit = blank(fee.getPriceUnit()).toUpperCase(Locale.ROOT); + return unit.contains("KG") || unit.contains("公斤"); + } + + private static void addPerKg(List fees, PaySurcharge fee, BigDecimal weight, String currency, String remark) + { + if (fee.getAmount() == null || weight == null) + { + return; + } + String feeCurrency = normalize(fee.getCurrency()); + if (!feeCurrency.isEmpty() && !feeCurrency.equals(currency)) + { + return; + } + BigDecimal amount = fee.getAmount().multiply(weight).setScale(2, RoundingMode.HALF_UP); + long scId = fee.getSurchargeId() == null ? 0L : fee.getSurchargeId(); + fees.add(line("SURCHARGE", blank(fee.getFeeName()), scId, amount, currency, remark + " × " + plain(weight) + "kg")); + } + private static void addFlat(List fees, PaySurcharge fee, String currency, String remark) { PayCostLine line = matchedFee(fee, null, currency, remark); diff --git a/bms-back/bms-system/src/main/java/com/bms/price/pay/PayRemoteBook.java b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayRemoteBook.java new file mode 100644 index 00000000..9b134711 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/price/pay/PayRemoteBook.java @@ -0,0 +1,177 @@ +package com.bms.price.pay; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import com.bms.price.domain.PayPostcodeGroup; +import com.bms.price.domain.PayPostcodeLine; + +/** + * 产品在应付邮编配置里的偏远名单。报价表或销售产品与价卡一致时,这份名单决定是否加偏远附加费。 + */ +public final class PayRemoteBook +{ + private static final Pattern AMOUNT = Pattern.compile("(\\d+(?:\\.\\d+)?)"); + + private PayRemoteBook() + { + } + + /** 命中的一条偏远邮编,带上这一组的费用名和金额。 */ + public static final class Hit + { + private final String feeName; + + private final BigDecimal amount; + + private final String zoneName; + + private final String regionName; + + public Hit(String feeName, BigDecimal amount, String zoneName, String regionName) + { + this.feeName = feeName == null ? "" : feeName; + this.amount = amount; + this.zoneName = zoneName == null ? "" : zoneName; + this.regionName = regionName == null ? "" : regionName; + } + + public String getFeeName() + { + return feeName; + } + + public BigDecimal getAmount() + { + return amount; + } + + public String getZoneName() + { + return zoneName; + } + + public String getRegionName() + { + return regionName; + } + } + + public static boolean remoteGroup(PayPostcodeGroup group) + { + if (group == null) + { + return false; + } + String coverage = group.getCoverage() == null ? "" : group.getCoverage().trim(); + if ("REMOTE".equalsIgnoreCase(coverage)) + { + return true; + } + if ("REACH".equalsIgnoreCase(coverage) || "UNREACH".equalsIgnoreCase(coverage)) + { + return false; + } + String fee = group.getFeeName() == null ? "" : group.getFeeName(); + return fee.contains("偏远") || fee.toLowerCase(Locale.ROOT).contains("remote"); + } + + /** + * 没有偏远配置时返回 null,沿用价卡上的邮编条件。 + * 有配置但邮编没命中时返回空列表,不加偏远附加费。 + */ + public static List hits(List groups, List lines, String postcode, + String countryCode, String countryName) + { + if (groups == null || groups.isEmpty()) + { + return null; + } + boolean anyRemote = false; + Map remote = new LinkedHashMap<>(); + for (PayPostcodeGroup group : groups) + { + if (group == null || group.getGroupId() == null || !remoteGroup(group)) + { + continue; + } + anyRemote = true; + if (sameCountry(group, countryCode, countryName)) + { + remote.put(group.getGroupId(), group); + } + } + if (!anyRemote) + { + return null; + } + if (remote.isEmpty()) + { + return new ArrayList<>(); + } + if (postcode == null || postcode.isBlank() || lines == null || lines.isEmpty()) + { + return new ArrayList<>(); + } + List hits = new ArrayList<>(); + for (PayPostcodeLine line : lines) + { + if (line == null || !remote.containsKey(line.getGroupId()) || !PayZoneMatcher.matchesLine(line, postcode)) + { + continue; + } + PayPostcodeGroup group = remote.get(line.getGroupId()); + String region = first(line.getRegionName(), line.getRegionNameCn()); + hits.add(new Hit(group.getFeeName(), amountOf(group.getFeeText()), line.getZoneName(), region)); + } + return hits; + } + + public static BigDecimal amountOf(String feeText) + { + if (feeText == null || feeText.isBlank()) + { + return null; + } + Matcher matcher = AMOUNT.matcher(feeText); + if (!matcher.find()) + { + return null; + } + return new BigDecimal(matcher.group(1)); + } + + private static boolean sameCountry(PayPostcodeGroup group, String countryCode, String countryName) + { + boolean orderBlank = blank(countryCode) && blank(countryName); + boolean groupBlank = blank(group.getCountryCode()) && blank(group.getCountryName()); + if (orderBlank || groupBlank) + { + return true; + } + if (!blank(countryCode) && countryCode.equalsIgnoreCase(group.getCountryCode().trim())) + { + return true; + } + return !blank(countryName) && countryName.trim().equals(group.getCountryName() == null ? "" : group.getCountryName().trim()); + } + + private static String first(String left, String right) + { + if (left != null && !left.isBlank()) + { + return left.trim(); + } + return right == null ? "" : right.trim(); + } + + private static boolean blank(String value) + { + return value == null || value.isBlank(); + } +} 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 index 44a299cc..c99b9c3c 100644 --- 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 @@ -29,6 +29,11 @@ public final class PaySurchargeRules /** 超过重量段上限后,按超出的公斤数乘单价 */ public static final String EXTRA_KG = "EXTRA_KG"; + /** 邮编已经落到这个分区,再按重量区间收取,例如英国高地 */ + public static final String ZONE = "ZONE"; + + private static final Pattern REMOTE_PREFIX = Pattern.compile("(\\d{1,5}(?:\\s*[、,,]\\s*\\d{1,5})*)\\s*邮编"); + 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})"); @@ -107,14 +112,24 @@ public final class PaySurchargeRules public static boolean matchesPostcode(String param, String postcode) { - UkPostcode parsed = parseUk(postcode); - if (parsed == null || param == null || param.isBlank()) + if (param == null || param.isBlank() || postcode == null || postcode.isBlank()) { return false; } + UkPostcode parsed = parseUk(postcode); + String digits = postcode.replaceAll("\\D", ""); for (String token : param.split(",")) { - if (tokenMatches(token.trim().toUpperCase(Locale.ROOT), parsed)) + String value = token.trim().toUpperCase(Locale.ROOT); + if (value.isEmpty()) + { + continue; + } + if (parsed != null && tokenMatches(value, parsed)) + { + return true; + } + if (parsed == null && value.matches("\\d{1,5}") && !digits.isEmpty() && digits.startsWith(value)) { return true; } @@ -122,6 +137,28 @@ public final class PaySurchargeRules return false; } + /** 从“07邮编开头”“35、38邮编开头”取出数字前缀。没有邮编字样时不猜。 */ + public static String remotePrefixes(String text) + { + if (text == null || !text.contains("邮编")) + { + return ""; + } + List tokens = new ArrayList<>(); + Matcher matcher = REMOTE_PREFIX.matcher(text); + while (matcher.find()) + { + for (String part : matcher.group(1).split("[、,,\\s]+")) + { + if (part.matches("\\d{1,5}") && !tokens.contains(part)) + { + tokens.add(part); + } + } + } + return String.join(",", tokens); + } + public static boolean matchesWeight(String param, BigDecimal weight) { if (param == null || param.isBlank() || weight == null) @@ -212,7 +249,7 @@ public final class PaySurchargeRules } 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('.', ','); + String upper = source.replace("邮编开头", " ").toUpperCase(Locale.ROOT).replace(',', ',').replace('、', ',').replace('.', ','); List tokens = new ArrayList<>(); for (String raw : upper.split("[,\\s]+")) { 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 index b6666ea0..e08a23c4 100644 --- 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 @@ -454,6 +454,9 @@ public class PayWorkbookParser copy.setChargeTiming(fee.getChargeTiming()); copy.setCalcRule(fee.getCalcRule()); copy.setCalcParam(fee.getCalcParam()); + copy.setPostcodeParam(fee.getPostcodeParam()); + copy.setWeightMin(fee.getWeightMin()); + copy.setWeightMax(fee.getWeightMax()); return copy; } @@ -1250,6 +1253,7 @@ public class PayWorkbookParser kind[col] = columnKind(head, numericBelow(grid, col, data)); } int added = product.getSurcharges().size(); + Map includedKg = new LinkedHashMap<>(); while (data < grid.rows()) { if (grid.blankRow(data) || isHardHeader(grid, data)) @@ -1296,8 +1300,7 @@ public class PayWorkbookParser 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); + applyFeeRateRule(fee, feeName, zone, weight, includedKg); } if (!priced && joinRow(grid, data).length() > 40) { @@ -1452,6 +1455,11 @@ public class PayWorkbookParser } } priced = true; + if (isSurchargePriceHead(parent[col])) + { + addPricedSurcharge(product, blankTo(section, "价格"), parent[col], zoneOf(sub[col], parent[col]), weight, price); + continue; + } boolean feeHead = isPriceHead(parent[col]); PayWeightBand band = new PayWeightBand(); band.setSectionName(blankTo(section, "价格")); @@ -4156,6 +4164,60 @@ public class PayWorkbookParser || lower.contains("line haul"); } + /** + * 偏远行按邮编前缀计费:首重整段收取,每增加只收超出部分。没有邮编列表的费用保持人工。 + */ + private static void applyFeeRateRule(PaySurcharge fee, String feeName, String zone, String weight, Map includedKg) + { + boolean remote = feeName != null && feeName.contains("偏远"); + boolean step = weight != null && (weight.contains("每增加") || weight.toLowerCase(Locale.ROOT).contains("additional")); + BigDecimal[] bounds = PayTexts.weightBounds(weight); + String prefixes = PaySurchargeRules.remotePrefixes(zone); + if (!step && bounds[1] != null) + { + includedKg.put(zone, bounds[1]); + } + BigDecimal included = includedKg.get(zone); + if (remote && step && !prefixes.isEmpty()) + { + fee.setCalcRule(PaySurchargeRules.EXTRA_KG); + fee.setCalcParam(included == null ? "1" : included.stripTrailingZeros().toPlainString()); + fee.setPostcodeParam(prefixes); + fee.setWeightMin(null); + fee.setWeightMax(null); + } + else if (remote && !prefixes.isEmpty()) + { + fee.setCalcRule(PaySurchargeRules.POSTCODE); + fee.setCalcParam(prefixes); + fee.setPostcodeParam(prefixes); + fee.setWeightMin(bounds[0]); + fee.setWeightMax(bounds[1]); + } + else + { + fee.setCalcRule(PaySurchargeRules.MANUAL); + fee.setCalcParam(weight); + } + } + + /** 价格表里和派送费并排的偏远列,按分区和重量区间记附加费,不进重量段。 */ + private void addPricedSurcharge(PayProduct product, String section, String head, String zone, String weight, BigDecimal price) + { + String feeName = shortCharge(head); + String standard = price.stripTrailingZeros().toPlainString() + " " + feeUnit(head, weight); + addSurcharge(product, section, feeName, zone, standard.trim(), weight, ""); + PaySurcharge fee = product.getSurcharges().get(product.getSurcharges().size() - 1); + BigDecimal[] bounds = PayTexts.weightBounds(weight); + if (bounds[0] != null && bounds[1] != null) + { + fee.setWeightMin(bounds[0]); + fee.setWeightMax(bounds[1]); + fee.setCalcRule(PaySurchargeRules.ZONE); + fee.setCalcParam(null); + } + } + private static String feeUnit(String head, String weight) { String unit = unitOf(head + " " + weight); 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 index 4419a041..4cccd8aa 100644 --- 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 @@ -88,6 +88,11 @@ public final class PayZoneMatcher } } + public static boolean matchesLine(PayPostcodeLine line, String postcode) + { + return line != null && matchedLength(line, postcode) >= 0; + } + public static Decision resolve(List zoneNames, List lines, String postcode) { if (zoneNames == null || zoneNames.size() <= 1) 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 index 991f6202..2c40a95c 100644 --- 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 @@ -5,19 +5,65 @@ import com.bms.price.domain.PayBill; import com.bms.price.domain.PayBillCommand; import com.bms.price.domain.PayBillPreview; +/** + * 应付账单。查询、预览、生成,以及确认、付款、作废。 + */ public interface IPayBillService { + /** + * 按条件查询应付账单列表。 + * + * @param query 账单号、供应商、状态、币种,空着的条件不过滤 + * @return 账单头列表 + */ List selectPayBillList(PayBill query); + /** + * 查询一张应付账单,带上费用明细和操作日志。 + * + * @param billId 应付账单ID + * @return 账单详情 + */ PayBill selectPayBillDetail(Long billId); + /** + * 预览可生成的订单。不重算费用,也不写账单。 + * + * @param command 查询方式,以及订单号、计费日起止或提单号 + * @return 可生成订单、未找到的单号、已经入账的订单 + */ PayBillPreview preview(PayBillCommand command); + /** + * 生成应付账单。重算范围内订单的尾程应付,再把订单费用抄成账单明细。 + * 已经挂在未作废账单上的订单不会进入本次账单。 + * + * @param command 供应商和取数范围 + * @return 草稿账单,含明细和生成日志 + */ PayBill generate(PayBillCommand command); + /** + * 把草稿确认为已确认。 + * + * @param billId 应付账单ID + * @return 成功时为 1 + */ int confirm(Long billId); + /** + * 把已确认账单登记为已付款。 + * + * @param billId 应付账单ID + * @return 成功时为 1 + */ int markPaid(Long billId); + /** + * 作废账单。已付款的不能作废,作废后数据仍保留。 + * + * @param command 账单ID和作废原因 + * @return 成功时为 1 + */ 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 index 37478ef7..b07849d4 100644 --- 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 @@ -2,8 +2,16 @@ package com.bms.price.service; import com.bms.price.domain.PayOrderCostResult; -/** 按订单生成应付:渠道服务商上的应付产品,用订单基础数据计算。 */ +/** + * 按订单计算尾程应付。用渠道上的应付产品和订单重量、尺寸、邮编取价,并写入订单应付。 + */ public interface IPayOrderCostService { + /** + * 重算一票订单的尾程应付,覆盖原来的计算费用。 + * + * @param billId 计费订单ID,对应 prc_order_bill.bill_id + * @return 本次算出的产品、版本、币种和费用明细 + */ PayOrderCostResult generate(String billId); } 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 index bb2b80d2..2910286c 100644 --- 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 @@ -48,12 +48,24 @@ public class PayBillServiceImpl implements IPayBillService @Autowired private BmsSupplierMapper supplierMapper; + /** + * 按账单号、供应商、状态、币种查询应付账单。 + * + * @param query 查询条件,空字段不参与过滤 + * @return 账单头列表 + */ @Override public List selectPayBillList(PayBill query) { return payBillMapper.selectPayBillList(query); } + /** + * 查询一张应付账单,并装上费用明细和操作日志。 + * + * @param billId 应付账单ID + * @return 账单详情 + */ @Override public PayBill selectPayBillDetail(Long billId) { @@ -67,12 +79,25 @@ public class PayBillServiceImpl implements IPayBillService return bill; } + /** + * 预览可生成的订单。不重算尾程,也不写账单。 + * + * @param command 查询方式,以及订单号、计费日或提单号 + * @return 可生成订单、未找到的单号、已经入账的订单 + */ @Override public PayBillPreview preview(PayBillCommand command) { return collect(command, false); } + /** + * 生成应付账单。校验供应商后,对未入账订单重算尾程,再把订单费用抄成账单明细。 + * 所选订单币种必须一致。账单以草稿写入,并记一条生成日志。 + * + * @param command 供应商和取数范围。sourceType 为 ORDER、TIME 或 LADING + * @return 刚生成的草稿账单,含明细和日志 + */ @Override @Transactional(rollbackFor = Exception.class) public PayBill generate(PayBillCommand command) @@ -165,6 +190,12 @@ public class PayBillServiceImpl implements IPayBillService return selectPayBillDetail(bill.getBillId()); } + /** + * 把草稿账单改为已确认。 + * + * @param billId 应付账单ID + * @return 成功时为 1 + */ @Override @Transactional(rollbackFor = Exception.class) public int confirm(Long billId) @@ -182,6 +213,12 @@ public class PayBillServiceImpl implements IPayBillService return 1; } + /** + * 把已确认账单登记为已付款。 + * + * @param billId 应付账单ID + * @return 成功时为 1 + */ @Override @Transactional(rollbackFor = Exception.class) public int markPaid(Long billId) @@ -199,6 +236,12 @@ public class PayBillServiceImpl implements IPayBillService return 1; } + /** + * 作废账单。必须填写作废原因。已作废的不能再作废,已付款的不能直接作废。 + * + * @param command 账单ID和作废原因 + * @return 成功时为 1 + */ @Override @Transactional(rollbackFor = Exception.class) public int voidBill(PayBillCommand command) @@ -232,6 +275,13 @@ public class PayBillServiceImpl implements IPayBillService return 1; } + /** + * 按订单号、计费日或提单号查出订单,并分成可生成和已入账两组。 + * + * @param command 查询方式和对应的单号或日期 + * @param forGenerate 为 true 时不把未找到的单号写进提示,留给生成时抛错 + * @return 预览结果。可生成订单放在 orders,已入账订单放在 occupied + */ private PayBillPreview collect(PayBillCommand command, boolean forGenerate) { String mode = mode(command); @@ -301,11 +351,23 @@ public class PayBillServiceImpl implements IPayBillService return preview; } + /** + * 取出还没有挂在未作废账单上的订单。 + * + * @param preview 预览结果 + * @return 可以进入本次账单的订单 + */ private List freeOrders(PayBillPreview preview) { return preview.getOrders(); } + /** + * 取这批订单里第一个非空币种。都没有时用 CNY,后面仍会按费用行再核对。 + * + * @param orders 本次要出账的订单 + * @return 币种代码 + */ private String currencyOf(List orders) { String currency = ""; @@ -320,6 +382,13 @@ public class PayBillServiceImpl implements IPayBillService return currency.isEmpty() ? "CNY" : currency; } + /** + * 用计费订单ID找到对应订单,用来回填订单号和提单号。 + * + * @param orders 本次订单 + * @param billId 计费订单ID + * @return 订单;对不上时返回 null + */ private PayBillOrder findOrder(List orders, String billId) { for (PayBillOrder order : orders) @@ -332,6 +401,12 @@ public class PayBillServiceImpl implements IPayBillService return null; } + /** + * 读取账单头。账单不存在时抛出业务异常。 + * + * @param billId 应付账单ID + * @return 账单头 + */ private PayBill required(Long billId) { PayBill bill = payBillMapper.selectPayBillById(billId); @@ -342,6 +417,16 @@ public class PayBillServiceImpl implements IPayBillService return bill; } + /** + * 写一条应付账单操作日志。 + * + * @param billId 应付账单ID + * @param type 操作类型:CREATE 生成、CONFIRM 确认、PAY 付款、VOID 作废 + * @param from 变更前状态,新建时为空 + * @param to 变更后状态 + * @param desc 操作说明 + * @param operator 操作人账号 + */ private void writeLog(Long billId, String type, String from, String to, String desc, String operator) { PayBillLog log = new PayBillLog(); @@ -354,12 +439,23 @@ public class PayBillServiceImpl implements IPayBillService payBillMapper.insertLog(log); } + /** + * 生成应付账单号。格式为 AP、年月、8 位随机码。 + * + * @return 账单号 + */ private String nextNo() { return "AP" + new SimpleDateFormat("yyyyMM").format(new Date()) + IdUtils.fastSimpleUUID().substring(0, 8).toUpperCase(); } + /** + * 把 yyyy-MM-dd 转成日期。空文本返回 null。 + * + * @param text 计费日起或止 + * @return 日期 + */ private Date parseDay(String text) { if (text == null || text.isBlank()) @@ -370,6 +466,12 @@ public class PayBillServiceImpl implements IPayBillService return java.sql.Date.valueOf(day); } + /** + * 读取查询方式,并转成大写。 + * + * @param command 预览或生成参数 + * @return ORDER 订单号、TIME 计费日、LADING 提单号 + */ private String mode(PayBillCommand command) { if (command == null || command.getSourceType() == null || command.getSourceType().isBlank()) @@ -379,6 +481,12 @@ public class PayBillServiceImpl implements IPayBillService return command.getSourceType().trim().toUpperCase(Locale.ROOT); } + /** + * 把订单号或提单号按逗号、顿号、分号、空白拆开,并去掉重复和空白。 + * + * @param text 页面上粘贴的单号原文 + * @return 去重后的单号 + */ static List tokens(String text) { LinkedHashSet values = new LinkedHashSet<>(); 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 index 28e5fec7..d891f113 100644 --- 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 @@ -1,11 +1,8 @@ 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 java.util.*; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +15,7 @@ import com.bms.price.domain.PayOrderCostResult; import com.bms.price.domain.PayPostcodeGroup; import com.bms.price.domain.PayPostcodeLine; import com.bms.price.domain.PayProduct; +import com.bms.price.domain.PaySurcharge; import com.bms.price.domain.PayWeightBand; import com.bms.price.domain.PrcOrderBill; import com.bms.price.domain.PrcOrderBillExt; @@ -29,6 +27,8 @@ 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.PayRemoteBook; +import com.bms.price.pay.PaySurchargeRules; import com.bms.price.pay.PayZoneMatcher; import com.bms.price.service.IPayOrderCostService; import com.bms.price.service.IPayProductService; @@ -60,6 +60,13 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService @Autowired private PayPostcodeMapper payPostcodeMapper; + /** + * 重算一票订单的尾程应付并落库。 + * 先找渠道和计费日对应的价卡版本,再用重量、尺寸、邮编算出费用,写回订单应付。 + * + * @param billId 计费订单ID + * @return 本次使用的渠道、价卡版本和费用明细 + */ @Override @Transactional(rollbackFor = Exception.class) public PayOrderCostResult generate(String billId) @@ -75,13 +82,16 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService } PrcOrderBillExt ext = orderBillExtMapper.selectByBillId(bill.getBillId()); ServiceChannel channel = resolveChannel(ext); - java.util.Date bizDate = bill.getBizDate() != null ? bill.getBizDate() : bill.getOrderTime(); + 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()); + List linked = linkedGroups(product); + String zoneName = resolveZone(channel, product, bill.getDestPostcode(), bill.getCountryCode(), bill.getCountryName(), linked); + // 根据邮编判断是否是偏远地区 + List remoteHits = remoteHits(linked, bill.getDestPostcode(), bill.getCountryCode(), bill.getCountryName()); PayOrderCostResult result = PayCostCalculator.calculate(product, weight, bill.getPkgLength(), bill.getPkgWidth(), - bill.getPkgHeight(), bill.getDestPostcode(), zoneName, bizDate, orderStatus); + bill.getPkgHeight(), bill.getDestPostcode(), zoneName, bizDate, orderStatus, remoteHits); result.setBillId(bill.getBillId()); result.setChannelId(channel.getId()); result.setChannelName(channel.getChannelName()); @@ -101,14 +111,109 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService return result; } - /** 价卡分成多个地区时,用邮编在对应分区表里选出价格列。 */ - private String resolveZone(ServiceChannel channel, PayProduct product, String postcode, String countryCode, String countryName) + /** + * 用产品名称、报价工作表、字典值和关联分区表,对上这份产品的邮编配置。 + * + * @param product 当前价卡版本 + * @return 名称能对上的邮编组,没有时为空列表 + */ + private List linkedGroups(PayProduct product) + { + List names = new ArrayList<>(); + addName(names, product.getProductName()); + addName(names, product.getSheetName()); + addName(names, product.getDictValue()); + addName(names, product.getRelatedSheet()); + if (names.isEmpty()) + { + return new ArrayList<>(); + } + List found = payPostcodeMapper.selectGroupsForProduct(names); + return found == null ? new ArrayList<>() : found; + } + + /** + * 把非空名称放进匹配列表,已有的不重复加入。 + * + * @param names 已经收集的名称 + * @param value 产品名、工作表名或字典值 + */ + private static void addName(List names, String value) + { + if (value == null || value.isBlank()) + { + return; + } + String name = value.trim(); + if (!names.contains(name)) + { + names.add(name); + } + } + + /** + * 用产品自己的偏远邮编判断这票是否偏远。没有偏远配置时返回 null,计费改看价卡上的邮编条件。 + * + * @param linked 已对上的邮编组 + * @param postcode 收件邮编 + * @param countryCode 目的国二字码 + * @param countryName 目的国名称 + * @return 命中的偏远分区;有配置但没命中时返回空列表 + */ + private List remoteHits(List linked, String postcode, String countryCode, String countryName) + { + List remoteIds = new ArrayList<>(); + boolean anyRemote = false; + for (PayPostcodeGroup group : linked) + { + if (group == null || group.getGroupId() == null || !PayRemoteBook.remoteGroup(group)) + { + continue; + } + anyRemote = true; + if (sameCountry(group, countryCode, countryName)) + { + remoteIds.add(group.getGroupId()); + } + } + if (!anyRemote) + { + return null; + } + if (remoteIds.isEmpty()) + { + return new ArrayList<>(); + } + List lines = postcode == null || postcode.isBlank() + ? new ArrayList<>() : payPostcodeMapper.selectLinesByGroups(remoteIds); + return PayRemoteBook.hits(linked, lines, postcode, countryCode, countryName); + } + + /** + * 价卡有多个地区时,选出这一票对应的价格列。优先用产品关联的邮编分区,对不上再按名称关键字找。 + * 只有一个地区时返回 null,计费不再按分区过滤。 + * + * @param channel 订单渠道,关键字匹配时备用 + * @param product 当前价卡版本 + * @param postcode 收件邮编 + * @param countryCode 目的国二字码 + * @param countryName 目的国名称 + * @param linked 已对上的邮编组 + * @return 价卡上的地区名 + */ + private String resolveZone(ServiceChannel channel, PayProduct product, String postcode, String countryCode, String countryName, + List linked) { List zoneNames = zoneNames(product); if (zoneNames.size() <= 1) { return null; } + List linkedZoneIds = zoneGroupIds(linked, countryCode, countryName); + if (!linkedZoneIds.isEmpty()) + { + return decideZone(zoneNames, linkedZoneIds, postcode); + } List tokens = PayZoneMatcher.searchTokens(product.getProductName(), product.getSheetName()); if (tokens.isEmpty()) { @@ -148,35 +253,105 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService { throw new ServiceException("产品「" + product.getProductName() + "」有多个地区,邮编分区对不上价卡上的地区"); } - PayZoneMatcher.Decision decision = PayZoneMatcher.resolve(zoneNames, payPostcodeMapper.selectLinesByGroups(new ArrayList<>(usable)), postcode); + return decideZone(zoneNames, new ArrayList<>(usable), postcode); + } + + /** + * 在给定邮编组里,用收件邮编决定价卡地区。对不上时抛出业务异常。 + * + * @param zoneNames 价卡上的地区名 + * @param groupIds 参与匹配的邮编组 + * @param postcode 收件邮编 + * @return 命中的地区名 + */ + private String decideZone(List zoneNames, List groupIds, String postcode) + { + PayZoneMatcher.Decision decision = PayZoneMatcher.resolve(zoneNames, payPostcodeMapper.selectLinesByGroups(groupIds), postcode); if (decision.getMessage() != null) { throw new ServiceException(decision.getMessage()); } if (decision.getZoneName() == null) { - throw new ServiceException("邮编没有对上产品「" + product.getProductName() + "」的地区"); + throw new ServiceException("邮编没有对上价卡地区"); } return decision.getZoneName(); } + /** + * 取出用来选派送费的普通分区。偏远、附加费邮编和不可达不参与选区。 + * + * @param linked 已对上的邮编组 + * @param countryCode 目的国二字码 + * @param countryName 目的国名称 + * @return 可参与选区的邮编组ID + */ + private static List zoneGroupIds(List linked, String countryCode, String countryName) + { + List ids = new ArrayList<>(); + if (linked == null) + { + return ids; + } + for (PayPostcodeGroup group : linked) + { + if (group == null || group.getGroupId() == null || PayRemoteBook.remoteGroup(group) || "FEE".equals(group.getConfigKind())) + { + continue; + } + if ("UNREACH".equalsIgnoreCase(group.getCoverage() == null ? "" : group.getCoverage().trim())) + { + continue; + } + if (!sameCountry(group, countryCode, countryName)) + { + continue; + } + ids.add(group.getGroupId()); + } + return ids; + } + + /** + * 收集价卡重量段和分区附加费上的地区名。 + * + * @param product 当前价卡版本 + * @return 去重后的地区名 + */ private static List zoneNames(PayProduct product) { Set names = new LinkedHashSet<>(); - if (product.getWeightBands() == null) + if (product.getWeightBands() != null) { - return new ArrayList<>(); - } - for (PayWeightBand band : product.getWeightBands()) - { - if (band != null && band.getZoneName() != null && !band.getZoneName().isBlank()) + for (PayWeightBand band : product.getWeightBands()) { - names.add(band.getZoneName().trim()); + if (band != null && band.getZoneName() != null && !band.getZoneName().isBlank()) + { + names.add(band.getZoneName().trim()); + } + } + } + if (product.getSurcharges() != null) + { + for (PaySurcharge fee : product.getSurcharges()) + { + if (fee != null && PaySurchargeRules.ZONE.equals(fee.getCalcRule()) && fee.getZoneName() != null && !fee.getZoneName().isBlank()) + { + names.add(fee.getZoneName().trim()); + } } } return new ArrayList<>(names); } + /** + * 判断邮编组是否和订单目的国相同。两边都没写国家时视为相同。 + * + * @param group 邮编组 + * @param countryCode 订单目的国二字码 + * @param countryName 订单目的国名称 + * @return 国家能对上时为 true + */ private static boolean sameCountry(PayPostcodeGroup group, String countryCode, String countryName) { if (blank(countryCode) && blank(countryName)) @@ -194,6 +369,12 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService return !blank(countryName) && countryName.equals(group.getCountryName()); } + /** + * 从订单扩展找出渠道。先按渠道ID,再按渠道编码,最后按渠道名称。 + * + * @param ext 订单计费扩展 + * @return 服务渠道 + */ private ServiceChannel resolveChannel(PrcOrderBillExt ext) { if (ext == null) @@ -221,6 +402,14 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService return channel; } + /** + * 保存订单应付主表。已有记录则更新合计,没有则新建。合计只写本次尾程结果。 + * + * @param bill 计费订单 + * @param result 本次尾程计算结果 + * @param operator 操作人账号 + * @return 保存后的订单应付 + */ private PrcPayBill saveBill(PrcOrderBill bill, PayOrderCostResult result, String operator) { PrcPayBill existed = payBillMapper.selectByBillId(bill.getBillId()); @@ -250,6 +439,13 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService return row; } + /** + * 用本次算出的尾程费用替换订单上的计算费用。来源不是计算的费用行会保留。 + * + * @param billId 计费订单ID + * @param payBillId 订单应付主表ID + * @param fees 本次算出的费用 + */ private void saveFees(String billId, String payBillId, List fees) { payBillFeeMapper.deleteByBillId(billId); @@ -281,6 +477,12 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService } } + /** + * 把日期格式化成 yyyy-MM-dd,供结果说明使用。空日期表示不限。 + * + * @param date 有效期起或止 + * @return 日期文本 + */ private static String day(java.util.Date date) { if (date == null) @@ -294,6 +496,12 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService return date.toInstant().atZone(java.time.ZoneId.of("GMT+8")).toLocalDate().toString(); } + /** + * 按传入顺序取第一个大于 0 的重量。 + * + * @param values 计费重、重量、实重、收货重 + * @return 第一个正数;都没有时返回 null + */ private static BigDecimal firstPositive(BigDecimal... values) { if (values == null) @@ -310,6 +518,12 @@ public class PayOrderCostServiceImpl implements IPayOrderCostService return null; } + /** + * 判断文本是否为空。 + * + * @param value 待判断文本 + * @return 为 null 或空白时返回 true + */ private static boolean blank(String value) { return value == null || value.isBlank(); 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 index be9052d8..719bfeef 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PayPostcodeMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PayPostcodeMapper.xml @@ -100,6 +100,20 @@ select group_id from pay_postcode_group where file_md5 = #{fileMd5} and sheet_name = #{sheetName} + +