增加失败原因和优化进度条

This commit is contained in:
wuweihua
2026-11-05 15:38:45 +08:00
parent 1727386811
commit 891fdea7d3
12 changed files with 220 additions and 38 deletions
@@ -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} 条,请缩小筛选范围
@@ -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 -> "未命中价格";
@@ -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;
}
@@ -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,
@@ -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;
@@ -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 "未命中价格";
@@ -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<String, AtomicBoolean> cancelFlags = new ConcurrentHashMap<String, AtomicBoolean>();
@@ -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<Map<String, Object>> failDetails = Collections.synchronizedList(new ArrayList<Map<String, Object>>());
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<Callable<Boolean>> jobs) throws InterruptedException
{
List<Future<Boolean>> futures = pool.invokeAll(jobs);
@@ -519,6 +519,37 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
limit 1
</select>
<!-- Gap between bands (e.g. 1.001 between 0-1 and 1.01-2): next higher weight_min. -->
<select id="selectFixedNextBandUp" resultMap="PrcPriceSetDetailResult">
<include refid="selectPrcPriceSetDetailVo"/>
where psv_id = #{psvId}
and ifnull(is_del, '0') = '0'
and product_code = #{productCode}
and weight_min is not null
and weight_min &gt; #{weight}
<choose>
<when test="countryCode != null and countryCode != ''">
and upper(ifnull(country_code,'')) = upper(#{countryCode})
</when>
<when test="countryName != null and countryName != ''">
and country_name = #{countryName}
</when>
<otherwise>
and 1 = 0
</otherwise>
</choose>
<choose>
<when test="zoneCode != null and zoneCode != ''">
and upper(ifnull(zone_code,'')) = upper(#{zoneCode})
</when>
<otherwise>
and (zone_code is null or zone_code = '')
</otherwise>
</choose>
order by weight_min asc, psd_id asc
limit 1
</select>
<select id="countZonedByPsvProductCountry" resultType="int">
select count(1)
from prc_price_set_detail
+41 -12
View File
@@ -109,6 +109,9 @@ export function cancelLocalRebill(taskId) {
})
}
/** One in-flight poll per taskId — avoids stacked loops that flood XHR and freeze the page. */
const localRebillPollControllers = new Map()
/**
* Poll local rebill task until terminal status.
* @param {string} taskId
@@ -117,22 +120,48 @@ export function cancelLocalRebill(taskId) {
export async function pollLocalRebillTask(taskId, onProgress) {
const id = String(taskId || '')
if (!id) throw new Error('missing local rebill taskId')
const prev = localRebillPollControllers.get(id)
if (prev) prev.aborted = true
const ctrl = { aborted: false }
localRebillPollControllers.set(id, ctrl)
const started = Date.now()
const maxMs = 30 * 60 * 1000
while (Date.now() - started < maxMs) {
const res = await getLocalRebillTask(id)
const task = res.data || {}
if (typeof onProgress === 'function') onProgress(task)
if (task.status === 'SUCCESS') return { task }
if (task.status === 'CANCELLED') return { task, cancelled: true }
if (task.status === 'FAIL') {
const err = new Error(task.error || task.message || 'local rebill failed')
err.task = task
throw err
let lastSig = ''
try {
while (Date.now() - started < maxMs) {
if (ctrl.aborted) {
const err = new Error('local rebill poll superseded')
err.superseded = true
throw err
}
// Hidden tab: slow down; no need to hammer Redis while user is away.
const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'
const res = await getLocalRebillTask(id)
const task = res.data || {}
const st = String(task.status || '').toUpperCase()
const sig = [st, task.percent, task.success, task.fail, task.total].join('|')
if (typeof onProgress === 'function' && sig !== lastSig) {
lastSig = sig
onProgress(task)
}
if (st === 'SUCCESS') return { task }
if (st === 'CANCELLED') return { task, cancelled: true }
if (st === 'FAIL') {
const err = new Error(task.error || task.message || 'local rebill failed')
err.task = task
throw err
}
// 1s visible / 2.5s hidden — dense 400ms polls were freezing the list page.
await new Promise(resolve => setTimeout(resolve, hidden ? 2500 : 1000))
}
throw new Error('local rebill timeout')
} finally {
if (localRebillPollControllers.get(id) === ctrl) {
localRebillPollControllers.delete(id)
}
await new Promise(resolve => setTimeout(resolve, 800))
}
throw new Error('local rebill timeout')
}
/**
@@ -345,7 +345,7 @@ const billingFailStatusOptions = [
{ label: '产品价未匹配国家', value: 24 },
{ label: '产品价未匹配重量段', value: 25 },
{ label: '未绑定套餐', value: 4 },
{ label: '无生效套餐版本', value: 5 },
{ label: '套餐未启用/无生效版本', value: 5 },
{ label: '已生成应收账单', value: 6 },
{ label: '产品档案缺失', value: 7 },
{ label: '未命中价格', value: 8 },
+14 -3
View File
@@ -363,7 +363,7 @@ const billingFailStatusOptions = [
{ label: '产品价未匹配国家', value: 24 },
{ label: '产品价未匹配重量段', value: 25 },
{ label: '未绑定套餐', value: 4 },
{ label: '无生效套餐版本', value: 5 },
{ label: '套餐未启用/无生效版本', value: 5 },
{ label: '已生成应收账单', value: 6 },
{ label: '产品档案缺失', value: 7 },
{ label: '未命中价格', value: 8 },
@@ -638,10 +638,15 @@ async function resumeRebillTask(taskId) {
rebillProgressTotal.value = 0
rebillProgressSuccess.value = 0
rebillProgressFail.value = 0
let superseded = false
try {
const result = await pollLocalRebillTask(taskId, applyRebillProgress)
finishRebillFromTask(result && result.task, !!(result && result.cancelled))
} catch (e) {
if (e && e.superseded) {
superseded = true
return
}
const task = e && e.task
rebillProgressStatus.value = 'exception'
rebillProgressMessage.value = (e && e.message) || '重新计费失败'
@@ -657,7 +662,8 @@ async function resumeRebillTask(taskId) {
rebillProgressVisible.value = true
scheduleHideRebillFloat()
} finally {
batchLoading.value = false
// Keep loading if a newer poll replaced this one.
if (!superseded) batchLoading.value = false
}
}
@@ -711,6 +717,7 @@ async function runLocalRebill(payload, confirmText) {
window.setTimeout(() => {
if (batchLoading.value) rebillProgressVisible.value = false
}, 600)
let superseded = false
try {
const result = await localRebillAsync(
payload,
@@ -719,6 +726,10 @@ async function runLocalRebill(payload, confirmText) {
)
finishRebillFromTask(result && result.task, !!(result && result.cancelled))
} catch (e) {
if (e && e.superseded) {
superseded = true
return
}
const task = e && e.task
rebillProgressStatus.value = 'exception'
rebillProgressMessage.value = (e && e.message) || '重新计费失败'
@@ -734,7 +745,7 @@ async function runLocalRebill(payload, confirmText) {
rebillProgressVisible.value = true
scheduleHideRebillFloat()
} finally {
batchLoading.value = false
if (!superseded) batchLoading.value = false
}
}
@@ -44,11 +44,11 @@
</div>
</template>
<div class="build-choice">
<!-- Exclusive quote: only Excel fresh build; hide the mode picker and default IMPORT_FRESH -->
<div v-if="!onlyImportFresh" class="build-choice">
<p class="build-choice__tip">创建价格方式</p>
<div class="build-choice__cards">
<button
v-if="!onlyImportFresh"
type="button"
class="build-card build-card--pull"
:class="{ selected: isPullBuildMode(priceBuildMode) }"
@@ -59,7 +59,7 @@
<div class="build-card__desc">按产品库基础价生成套餐产品列表,可再导入 Excel 修改价格</div>
</button>
<button
v-if="showImportInherit && !onlyImportFresh"
v-if="showImportInherit"
type="button"
class="build-card build-card--import"
:class="{ selected: isImportBuildMode(priceBuildMode) }"
@@ -626,7 +626,7 @@ watch(
() => [onlyImportFresh.value, props.priceBuildMode, props.buildModeReady],
() => {
if (!onlyImportFresh.value || !props.buildModeReady) return
if (props.priceBuildMode) return
if (isImportFreshBuildMode(props.priceBuildMode)) return
if (buildModeLocked.value) return
emit('update:priceBuildMode', 'IMPORT_FRESH')
},