diff --git a/bms-back/bms-admin/src/main/resources/i18n/messages.properties b/bms-back/bms-admin/src/main/resources/i18n/messages.properties index 5f3e8d29..8d1b9ed0 100644 --- a/bms-back/bms-admin/src/main/resources/i18n/messages.properties +++ b/bms-back/bms-admin/src/main/resources/i18n/messages.properties @@ -224,6 +224,11 @@ price.bill.billingFail.weight=计费失败:销售价未匹配重量段 price.bill.billingFail.catalogProduct=计费失败:产品价未匹配产品 price.bill.billingFail.catalogCountry=计费失败:产品价未匹配国家 price.bill.billingFail.catalogWeight=计费失败:产品价未匹配重量段 +price.bill.billingFail.packageNotEnabled=计费失败:套餐未启用 +price.bill.billingFail.noActivePackageVersion=计费失败:计费日无生效套餐版本 +price.bill.billingFail.noCustomerPackage=计费失败:客户未绑定套餐 +price.bill.packageNotEnabled=客户已绑定套餐但套餐未启用,无法计费 +price.quote.packageNotEnabled=客户已绑定套餐但套餐未启用,无法试算 price.bill.export.filterRequired=请先设置列表查询条件后再导出 price.bill.export.taskNotExists=导出任务不存在或已过期 price.bill.export.tooMany=匹配订单超过上限 {0} 条,请缩小筛选范围 diff --git a/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java index dc1acbb4..87499e98 100644 --- a/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/ar/service/impl/ArBillServiceImpl.java @@ -1733,7 +1733,7 @@ public class ArBillServiceImpl implements IArBillService { case CATALOG_COUNTRY -> "产品价未匹配国家"; case CATALOG_WEIGHT -> "产品价未匹配重量段"; case NO_CUSTOMER_PACKAGE -> "未绑定套餐"; - case NO_ACTIVE_PACKAGE_VERSION -> "无生效套餐版本"; + case NO_ACTIVE_PACKAGE_VERSION -> "套餐未启用/无生效版本"; case ALREADY_IN_AR_BILL -> "已生成应收账单"; case PRODUCT_ARCHIVE_MISSING -> "产品档案缺失"; case PRICE_NOT_HIT -> "未命中价格"; diff --git a/bms-back/bms-system/src/main/java/com/bms/price/enums/BillTaskFailCode.java b/bms-back/bms-system/src/main/java/com/bms/price/enums/BillTaskFailCode.java index bd673244..d3471987 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/enums/BillTaskFailCode.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/enums/BillTaskFailCode.java @@ -66,7 +66,7 @@ public enum BillTaskFailCode { return NO_CUSTOMER_PACKAGE.code; } - if (containsAny(msg, "无生效套餐版本", "noActivePackageVersion", "NO_ACTIVE_PACKAGE_VERSION")) + if (containsAny(msg, "无生效套餐版本", "套餐未启用", "noActivePackageVersion", "NO_ACTIVE_PACKAGE_VERSION", "packageNotEnabled")) { return NO_ACTIVE_PACKAGE_VERSION.code; } diff --git a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetDetailMapper.java b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetDetailMapper.java index 78e0b458..38b8bfb0 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetDetailMapper.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcPriceSetDetailMapper.java @@ -82,6 +82,18 @@ public interface PrcPriceSetDetailMapper @Param("countryName") String countryName, @Param("zoneCode") String zoneCode); + /** + * Next FIXED weight band whose weight_min is strictly above {@code weight} + * (ceil into the gap between bands, e.g. 1.001 -> 1.01~2). + */ + public PrcPriceSetDetail selectFixedNextBandUp( + @Param("psvId") Long psvId, + @Param("productCode") String productCode, + @Param("countryCode") String countryCode, + @Param("countryName") String countryName, + @Param("zoneCode") String zoneCode, + @Param("weight") BigDecimal weight); + public int countZonedByPsvProductCountry( @Param("psvId") Long psvId, @Param("productCode") String productCode, diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java index 08afe8e7..5f996c1b 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/impl/TmsPriceQuoteServiceImpl.java @@ -410,12 +410,16 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService } if (binds == null || binds.isEmpty()) { - String msgKey = request instanceof TmsPriceBillRequest - ? "price.bill.noCustomerPackage" - : "price.quote.noCustomerPackage"; - throw new ServiceException(MessageUtils.message(msgKey)); + if (request instanceof TmsPriceBillRequest) + { + // Soft-fail: persist billing_fail_status=4 on the order bill. + return buildBillingMissResult(request, productCode, productName, countryCode, countryName, + weight, bizDate, BillBillingFailStatus.NO_CUSTOMER_PACKAGE.getCode()); + } + throw new ServiceException(MessageUtils.message("price.quote.noCustomerPackage")); } + boolean anyEnabledSet = false; boolean anyActiveVersion = false; boolean productExcludedBySetProducts = false; boolean attemptedPriceMatch = false; @@ -428,10 +432,12 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService for (PrcCustomerPriceSet bind : binds) { PrcPriceSet set = priceSetMapper.selectPrcPriceSetById(bind.getPriceSetId()); + // Only enabled (E) sets participate; draft/stop are skipped and recorded as fail below. if (set == null || !"E".equals(set.getStatus())) { continue; } + anyEnabledSet = true; // Temporal gate: prc_price_set_version.effective_time / expire_time (not bind pr_begindate) PrcPriceSetVersion version = versionMapper.selectActiveByPriceSetAt(set.getPriceSetId(), asOf); if (version == null || version.getPsvId() == null) @@ -560,9 +566,22 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService if (!anyActiveVersion) { - String msgKey = request instanceof TmsPriceBillRequest - ? "price.bill.noActivePackageVersion" - : "price.quote.noActivePackageVersion"; + if (request instanceof TmsPriceBillRequest) + { + // Soft-fail: package bound but not enabled, or enabled with no version at bizDate. + // Persists billing_fail_status=5 so local rebill / MQ bill both record the miss. + TmsPriceQuoteResult miss = buildBillingMissResult(request, productCode, productName, + countryCode, countryName, weight, bizDate, + BillBillingFailStatus.NO_ACTIVE_PACKAGE_VERSION.getCode()); + if (!anyEnabledSet) + { + miss.setMatchMessage(MessageUtils.message("price.bill.billingFail.packageNotEnabled")); + } + return miss; + } + String msgKey = anyEnabledSet + ? "price.quote.noActivePackageVersion" + : "price.quote.packageNotEnabled"; throw new ServiceException(MessageUtils.message(msgKey)); } if (!attemptedPriceMatch && productExcludedBySetProducts) @@ -720,6 +739,14 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService { return MessageUtils.message("price.bill.billingFail.catalogWeight"); } + if (missCode == BillBillingFailStatus.NO_CUSTOMER_PACKAGE.getCode()) + { + return MessageUtils.message("price.bill.billingFail.noCustomerPackage"); + } + if (missCode == BillBillingFailStatus.NO_ACTIVE_PACKAGE_VERSION.getCode()) + { + return MessageUtils.message("price.bill.billingFail.noActivePackageVersion"); + } return MessageUtils.message("price.bill.packagePriceNotHit"); } @@ -994,7 +1021,8 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService countryCode, countryName, zoneCode, weight); BigDecimal billWeight = weight; boolean snappedToMinBand = false; - // Miss: if charge weight is below the lowest band, bill at that band's weight_min. + boolean snappedUpToNextBand = false; + // Miss: below lowest band -> bill at that band's weight_min. if (detail == null) { PrcPriceSetDetail minBand = detailMapper.selectFixedMinBand( @@ -1007,6 +1035,19 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService snappedToMinBand = true; } } + // Miss in a gap (e.g. 1.001 between 0-1 and 1.01-2): ceil to next band's weight_min. + if (detail == null) + { + PrcPriceSetDetail nextBand = detailMapper.selectFixedNextBandUp( + psvId, productCode, countryCode, countryName, zoneCode, weight); + if (nextBand != null && nextBand.getWeightMin() != null) + { + detail = nextBand; + billWeight = nextBand.getWeightMin().setScale(3, RoundingMode.HALF_UP); + snappedUpToNextBand = true; + } + } + // Still no band (above last max, or no details): caller records WEIGHT miss. if (detail == null) { return null; @@ -1016,7 +1057,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService return null; } BmsProductCountry useCatalog = catalog; - if (snappedToMinBand) + if (snappedToMinBand || snappedUpToNextBand) { BmsProductCountry rematched = softMatchCatalog( productCode, countryCode, countryName, zoneCode, billWeight, bizDate, resolvedZone); @@ -1039,6 +1080,13 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService r.setChargeWeight(weight); msg = msg + "; billed at min band " + billWeight.toPlainString() + " (persist original weight)"; } + else if (snappedUpToNextBand) + { + // Gap ceil: bill at next band min (1.001 -> 1.01); persist snapped charge_weight. + r.setWeight(weight); + r.setChargeWeight(billWeight); + msg = msg + "; snapped up " + weight.toPlainString() + " -> " + billWeight.toPlainString(); + } r.setMatchMessage(msg); r.setCustomerId(bind.getCustomerId()); return r; diff --git a/bms-back/bms-system/src/main/java/com/bms/price/service/support/OrderBillExportSupport.java b/bms-back/bms-system/src/main/java/com/bms/price/service/support/OrderBillExportSupport.java index 4ff075f8..f299467b 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/service/support/OrderBillExportSupport.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/service/support/OrderBillExportSupport.java @@ -247,7 +247,7 @@ public final class OrderBillExportSupport case 24: return "产品价未匹配国家"; case 25: return "产品价未匹配重量段"; case 4: return "未绑定套餐"; - case 5: return "无生效套餐版本"; + case 5: return "套餐未启用/无生效版本"; case 6: return "已生成应收账单"; case 7: return "产品档案缺失"; case 8: return "未命中价格"; diff --git a/bms-back/bms-system/src/main/java/com/bms/price/task/PrcOrderBillLocalRebillAsyncRunner.java b/bms-back/bms-system/src/main/java/com/bms/price/task/PrcOrderBillLocalRebillAsyncRunner.java index eabb1923..4ca52106 100644 --- a/bms-back/bms-system/src/main/java/com/bms/price/task/PrcOrderBillLocalRebillAsyncRunner.java +++ b/bms-back/bms-system/src/main/java/com/bms/price/task/PrcOrderBillLocalRebillAsyncRunner.java @@ -15,6 +15,7 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -50,6 +51,9 @@ public class PrcOrderBillLocalRebillAsyncRunner public static final int MIN_THREADS = 2; public static final int MAX_THREADS = 16; private static final int FAIL_DETAIL_MAX = 200; + /** Publish Redis progress at most this often while a page is running. */ + private static final int PROGRESS_EVERY_N = 20; + private static final long PROGRESS_MIN_INTERVAL_MS = 500L; private final ConcurrentHashMap cancelFlags = new ConcurrentHashMap(); @@ -81,6 +85,8 @@ public class PrcOrderBillLocalRebillAsyncRunner int total = billIds == null ? 0 : billIds.size(); AtomicInteger success = new AtomicInteger(0); AtomicInteger fail = new AtomicInteger(0); + AtomicInteger done = new AtomicInteger(0); + AtomicLong lastProgressAt = new AtomicLong(0L); StringBuilder failTips = new StringBuilder(); List> failDetails = Collections.synchronizedList(new ArrayList>()); int threads = clampThreads(billingProperties == null ? null : billingProperties.getOrderSourceSyncThreads()); @@ -89,6 +95,7 @@ public class PrcOrderBillLocalRebillAsyncRunner try { put(taskId, BatchAddIncomeBillFeeTask.STATUS_RUNNING, 1, "running", null, total, 0, 0); + lastProgressAt.set(System.currentTimeMillis()); if (total == 0) { put(taskId, BatchAddIncomeBillFeeTask.STATUS_FAIL, 100, "failed", "no bills", 0, 0, 0); @@ -100,7 +107,6 @@ public class PrcOrderBillLocalRebillAsyncRunner finishCancelled(taskId, request, total, success.get(), fail.get(), threads, failDetails); return; } - int processed = 0; boolean cancelled = false; for (int from = 0; from < total; from += PAGE_SIZE) { @@ -135,19 +141,22 @@ public class PrcOrderBillLocalRebillAsyncRunner taskId, one, e.getMessage()); return Boolean.FALSE; } + finally + { + int finished = done.incrementAndGet(); + maybePublishProgress(taskId, total, finished, success.get(), fail.get(), lastProgressAt); + } }); } invokeAllQuiet(pool, jobs); - processed = to; if (isCancelRequested(taskId)) { cancelled = true; break; } - int percent = Math.min(99, (int) Math.floor(100.0 * processed / total)); - put(taskId, BatchAddIncomeBillFeeTask.STATUS_RUNNING, percent, - "processing " + processed + "/" + total + " page=" + ((from / PAGE_SIZE) + 1), - null, total, success.get(), fail.get()); + // Page boundary: always flush latest counts (even if throttle skipped mid-page). + publishProgress(taskId, total, done.get(), success.get(), fail.get()); + lastProgressAt.set(System.currentTimeMillis()); } if (cancelled || isCancelRequested(taskId)) { @@ -332,6 +341,43 @@ public class PrcOrderBillLocalRebillAsyncRunner return redisCache.getCacheObject(CACHE_KEY_PREFIX + taskId); } + /** + * Throttled Redis progress so the UI moves during a large page (not only after 500). + * Does not change worker throughput; only how often status is published. + */ + private void maybePublishProgress(String taskId, int total, int finished, int ok, int fail, + AtomicLong lastProgressAt) + { + if (total <= 0 || finished <= 0) + { + return; + } + boolean byCount = finished % PROGRESS_EVERY_N == 0 || finished >= total; + long now = System.currentTimeMillis(); + long prev = lastProgressAt.get(); + boolean byTime = now - prev >= PROGRESS_MIN_INTERVAL_MS; + if (!byCount && !byTime) + { + return; + } + if (!lastProgressAt.compareAndSet(prev, now) && !byCount) + { + return; + } + publishProgress(taskId, total, finished, ok, fail); + } + + private void publishProgress(String taskId, int total, int finished, int ok, int fail) + { + int percent = total <= 0 ? 0 : Math.min(99, (int) Math.floor(100.0 * finished / total)); + if (finished >= total) + { + percent = Math.min(99, percent); + } + put(taskId, BatchAddIncomeBillFeeTask.STATUS_RUNNING, percent, + "processing " + finished + "/" + total, null, total, ok, fail); + } + private static void invokeAllQuiet(ExecutorService pool, List> jobs) throws InterruptedException { List> futures = pool.invokeAll(jobs); diff --git a/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetDetailMapper.xml b/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetDetailMapper.xml index 2ae1ed96..ca58274a 100644 --- a/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetDetailMapper.xml +++ b/bms-back/bms-system/src/main/resources/mapper/price/PrcPriceSetDetailMapper.xml @@ -519,6 +519,37 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" limit 1 + + +