修改打单计费逻辑

This commit is contained in:
wuweihua
2026-11-09 23:38:07 +08:00
parent 16afa0d590
commit de5784cbac
7 changed files with 377 additions and 3 deletions
@@ -21,6 +21,11 @@ public class TmsPriceQuoteRequest implements Serializable
@NotBlank(message = "productCode is required")
private String productCode;
/**
* 打单套餐(LABEL)试算/计费用渠道ID;计费也可从订单扩展 productChannelId 解析。
*/
private String channelId;
/** ISO2; countryName alternative */
private String countryCode;
/** country display name; countryCode alternative */
@@ -22,6 +22,10 @@ public interface PayProductTermMapper
List<PayProductTerm> selectByPriceSetIdAndVersion(@Param("priceSetId") Long priceSetId,
@Param("versionNo") Integer versionNo);
/** 打单计费:套餐+产品+计费日命中 term(时间窗口优先) */
Long selectTermIdBySetProductAt(@Param("priceSetId") Long priceSetId, @Param("productId") Long productId,
@Param("bizDate") Date bizDate);
/** 打单套餐:按 version_no 聚合版本(含已失效) */
List<LabelPackageVersion> selectLabelVersionGroups(@Param("priceSetId") Long priceSetId);
@@ -90,6 +90,11 @@ public interface IPayProductService
/** 按订单时间取这一产品当时有效的价格版本 */
PayProduct selectPriceForDate(String channelId, Date bizDate);
/**
* 打单套餐计费取价:渠道 AR 映射 → 稳定 product_id,再按套餐+计费日命中 term。
*/
PayProduct selectLabelPriceForDate(Long priceSetId, String channelId, Date bizDate);
/** 对照两个版本的重量段和附加费 */
PayPriceCompare compare(Long leftId, Long rightId);
@@ -1537,6 +1537,36 @@ public class PayProductServiceImpl implements IPayProductService
return version;
}
@Override
public PayProduct selectLabelPriceForDate(Long priceSetId, String channelId, Date bizDate)
{
if (priceSetId == null)
{
throw new ServiceException("打单套餐不能为空");
}
if (isBlank(channelId))
{
throw new ServiceException("打单计费需要渠道");
}
Long root = channelProductRoot(channelId.trim(), PAY_TYPE_AR);
if (root == null)
{
throw new ServiceException("渠道还没有绑定打单应收产品");
}
Date day = bizDate == null ? today() : bizDate;
Long termId = payProductTermMapper.selectTermIdBySetProductAt(priceSetId, root, day);
if (termId == null)
{
throw new ServiceException("订单时间没有落在打单价卡有效期内");
}
PayProduct product = selectPayProductDetailByTerm(termId);
if (product == null)
{
throw new ServiceException("打单价卡版本不存在");
}
return product;
}
@Override
public PayPriceCompare compare(Long leftId, Long rightId)
{
@@ -28,7 +28,11 @@ import com.bms.customer.customerinfo.mapper.BmsCustomerMapper;
import com.bms.price.domain.BmsProduct;
import com.bms.price.domain.BmsProductCountry;
import com.bms.price.domain.BmsRedlineProductCountry;
import com.bms.price.domain.PayCostLine;
import com.bms.price.domain.PayOrderCostResult;
import com.bms.price.domain.PayProduct;
import com.bms.price.domain.PrcCustomerPriceSet;
import com.bms.price.domain.PrcOrderBillExt;
import com.bms.price.domain.PrcPriceSet;
import com.bms.price.domain.PrcPriceSetDetail;
import com.bms.price.domain.PrcPriceSetProduct;
@@ -37,6 +41,7 @@ import com.bms.price.domain.PrcQuote;
import com.bms.price.domain.PrcQuoteFee;
import com.bms.price.domain.PrcSurchargeRel;
import com.bms.price.domain.PrcZone;
import com.bms.price.pay.PayCostCalculator;
import com.bms.price.mapper.PrcZoneDetailMapper;
import com.bms.price.domain.vo.TmsPriceBillRequest;
import com.bms.price.domain.vo.TmsPriceQuoteFeeLine;
@@ -48,6 +53,7 @@ import com.bms.price.mapper.BmsProductCountryMapper;
import com.bms.price.mapper.BmsProductMapper;
import com.bms.price.mapper.BmsRedlineProductCountryMapper;
import com.bms.price.mapper.PrcCustomerPriceSetMapper;
import com.bms.price.mapper.PrcOrderBillExtMapper;
import com.bms.price.mapper.PrcPriceSetDetailMapper;
import com.bms.price.mapper.PrcPriceSetMapper;
import com.bms.price.mapper.PrcPriceSetProductMapper;
@@ -58,12 +64,14 @@ import com.bms.price.mapper.PrcSurchargeRelMapper;
import com.bms.price.search.EsBizLogSupport;
import com.bms.price.search.EsLogType;
import com.bms.price.search.IBillSearchStore;
import com.bms.price.service.IPayProductService;
import com.bms.price.service.ITmsPriceQuoteService;
import com.bms.system.service.IBmsProductCodeMappingService;
/**
* 共用的试算/计费计算:按 bizDate 取客户绑定和套餐版本(版本 effective_time)。
* 绑定的 pr_begindate/pr_enddate 不作为计费时间门槛。
* 先按 prc_price_set.set_category 分流:LABEL 走打单 pay_*;PACKAGE/EXCLUSIVE 走销售快照。
* 销售价只来自套餐快照(FIXED 明细 / 按目录底价 DISCOUNT)。
* 无绑定或无生效版本:试算和计费都直接失败(不用 PRODUCT_DEFAULT)。
* 账单同步时,产品/国家/重量段未命中为软失败(billing_fail_status 1/2/3,金额为 0)。
@@ -78,6 +86,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
private static final String TYPE_DISCOUNT = "DISCOUNT";
private static final String SRC_FIXED = "PACKAGE_FIXED";
private static final String SRC_DISCOUNT = "PACKAGE_DISCOUNT";
private static final String SRC_LABEL = "LABEL_PAY";
private static final String FEE_BASIC = "BASIC_FREIGHT";
private static final String FEE_REG = "REGISTRATION_FEE";
@@ -128,6 +137,12 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
@Autowired
private BmsCustomerMapper bmsCustomerMapper;
@Autowired
private IPayProductService payProductService;
@Autowired
private PrcOrderBillExtMapper orderBillExtMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public TmsPriceQuoteResult quote(TmsPriceQuoteRequest request)
@@ -169,7 +184,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
return toResult(cached, fees, true);
}
TmsPriceQuoteResult refreshed = doQuote(request, bizDate);
applySurchargesAndFees(refreshed);
applySalesSurchargesIfNeeded(refreshed);
replaceQuoteFees(cached.getQuoteId(), refreshed);
refreshed.setQuoteId(cached.getQuoteId());
refreshed.setFromCache(false);
@@ -179,11 +194,21 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
}
TmsPriceQuoteResult result = doQuote(request, bizDate);
applySurchargesAndFees(result);
applySalesSurchargesIfNeeded(result);
persistQuote(quoteKey, request, weight, bizDate, result);
return result;
}
/** LABEL 费用已由 PayCostCalculator 算完,不再叠加销售附加费。 */
private void applySalesSurchargesIfNeeded(TmsPriceQuoteResult result)
{
if (result == null || SRC_LABEL.equals(result.getMatchSource()))
{
return;
}
applySurchargesAndFees(result);
}
/**
* Cached quotes from pre-package-snapshot logic (PRODUCT_DEFAULT / missing package id)
* must not be reused after billing rules changed.
@@ -211,7 +236,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
TmsPriceQuoteResult result = doQuote(request, bizDate);
if (!BillBillingFailStatus.isFail(result.getBillingFailStatus()))
{
applySurchargesAndFees(result);
applySalesSurchargesIfNeeded(result);
result.setBillingFailStatus(BillBillingFailStatus.SUCCESS.getCode());
}
result.setCustomerActiveStatus(customerActiveStatus);
@@ -442,6 +467,13 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
continue;
}
anyEnabledSet = true;
// 打单套餐:独立 pay_* 链路,不走销售版本明细
if (PrcPriceSet.CATEGORY_LABEL.equals(
PrcPriceSetServiceImpl.normalizeSetCategory(set.getSetCategory())))
{
return doLabelQuote(request, bind, set, productCode, productName, countryCode, countryName,
weight, bizDate);
}
// 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)
@@ -622,6 +654,171 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
throw new ServiceException(MessageUtils.message(missKey));
}
/**
* 打单套餐计费:渠道 AR → product_id,套餐+时间 → term,PayCostCalculator 算费。
*/
private TmsPriceQuoteResult doLabelQuote(TmsPriceQuoteRequest request, PrcCustomerPriceSet bind,
PrcPriceSet set, String productCode, String productName, String countryCode, String countryName,
BigDecimal weight, Date bizDate)
{
String channelId = resolveLabelChannelId(request);
if (StringUtils.isEmpty(channelId))
{
if (request instanceof TmsPriceBillRequest)
{
return buildBillingMissResult(request, productCode, productName, countryCode, countryName,
weight, bizDate, BillBillingFailStatus.PRICE_NOT_HIT.getCode());
}
throw new ServiceException("打单计费需要渠道");
}
PayProduct product;
try
{
product = payProductService.selectLabelPriceForDate(set.getPriceSetId(), channelId, bizDate);
}
catch (ServiceException ex)
{
if (request instanceof TmsPriceBillRequest)
{
return buildBillingMissResult(request, productCode, productName, countryCode, countryName,
weight, bizDate, BillBillingFailStatus.NO_ACTIVE_PACKAGE_VERSION.getCode());
}
throw ex;
}
BigDecimal length = null;
BigDecimal width = null;
BigDecimal height = null;
Integer orderStatus = null;
if (request instanceof TmsPriceBillRequest)
{
TmsPriceBillRequest billReq = (TmsPriceBillRequest) request;
length = billReq.getPkgLength();
width = billReq.getPkgWidth();
height = billReq.getPkgHeight();
orderStatus = billReq.getOrderStatus();
}
PayOrderCostResult cost = PayCostCalculator.calculate(product, weight, length, width, height,
request.getDestPostcode(), bizDate, orderStatus);
if (cost == null || cost.getFees() == null || cost.getFees().isEmpty())
{
if (request instanceof TmsPriceBillRequest)
{
return buildBillingMissResult(request, productCode, productName, countryCode, countryName,
weight, bizDate, BillBillingFailStatus.PRICE_NOT_HIT.getCode());
}
throw new ServiceException(cost != null && StringUtils.isNotEmpty(cost.getMessage())
? cost.getMessage() : "打单价卡未算出费用");
}
TmsPriceQuoteResult r = mapLabelCostToQuote(cost, product, request, bind, set,
productCode, productName, countryCode, countryName, weight, bizDate);
stampRequestMeta(request, r);
return r;
}
/** 试算用 request.channelId;计费优先订单扩展 productChannelId。 */
private String resolveLabelChannelId(TmsPriceQuoteRequest request)
{
if (request != null && StringUtils.isNotEmpty(request.getChannelId()))
{
return request.getChannelId().trim();
}
if (!(request instanceof TmsPriceBillRequest))
{
return null;
}
TmsPriceBillRequest billReq = (TmsPriceBillRequest) request;
PrcOrderBillExt ext = null;
if (StringUtils.isNotEmpty(billReq.getOrderNo()))
{
ext = orderBillExtMapper.selectByOrderNo(billReq.getOrderNo().trim());
}
if (ext == null)
{
return null;
}
if (StringUtils.isNotEmpty(ext.getProductChannelId()))
{
return ext.getProductChannelId().trim();
}
if (StringUtils.isNotEmpty(ext.getProductFloorCode()))
{
return ext.getProductFloorCode().trim();
}
return null;
}
private TmsPriceQuoteResult mapLabelCostToQuote(PayOrderCostResult cost, PayProduct product,
TmsPriceQuoteRequest request, PrcCustomerPriceSet bind, PrcPriceSet set,
String productCode, String productName, String countryCode, String countryName,
BigDecimal weight, Date bizDate)
{
RoundingMode rm = RoundingMode.HALF_UP;
String currency = normalizeCurrency(cost.getCurrency());
List<TmsPriceQuoteFeeLine> lines = new ArrayList<TmsPriceQuoteFeeLine>();
BigDecimal freightAmount = BigDecimal.ZERO;
BigDecimal surchargeAmount = BigDecimal.ZERO;
BigDecimal registrationFee = BigDecimal.ZERO;
for (PayCostLine fee : cost.getFees())
{
if (fee == null || fee.getAmount() == null)
{
continue;
}
String feeType = StringUtils.isEmpty(fee.getFeeType()) ? FEE_SUR : fee.getFeeType().trim();
String feeName = StringUtils.isEmpty(fee.getFeeName()) ? feeNameOf(feeType) : fee.getFeeName();
BigDecimal amount = fee.getAmount().setScale(2, rm);
Long scId = fee.getScId() == null ? 0L : fee.getScId();
lines.add(feeLine(feeType, feeName, scId, amount, currency, "HALF_UP", fee.getRemark()));
if (FEE_BASIC.equals(feeType))
{
freightAmount = freightAmount.add(amount);
}
else if (FEE_REG.equals(feeType))
{
registrationFee = registrationFee.add(amount);
}
else
{
surchargeAmount = surchargeAmount.add(amount);
}
}
BigDecimal incomeTotal = nvl(cost.getPayTotal());
if (incomeTotal.compareTo(BigDecimal.ZERO) <= 0)
{
incomeTotal = freightAmount.add(registrationFee).add(surchargeAmount).setScale(2, rm);
}
String labelProductName = StringUtils.isNotEmpty(product.getProductName())
? product.getProductName() : productName;
TmsPriceQuoteResult r = new TmsPriceQuoteResult();
r.setMatchSource(SRC_LABEL);
r.setMatchMessage("hit label package pay card V"
+ (product.getVersionNo() == null ? "?" : product.getVersionNo()));
fillPackage(r, bind, set);
r.setVersionNo(product.getVersionNo() == null ? null : ("V" + product.getVersionNo()));
r.setCustomerId(bind.getCustomerId());
r.setProductCode(productCode);
r.setProductName(labelProductName);
r.setCountryCode(countryCode);
r.setCountryName(countryName);
r.setWeight(weight);
r.setChargeWeight(weight);
r.setBizDate(bizDate);
r.setEffectiveDate(product.getTermStart() != null ? product.getTermStart() : product.getEffectiveDate());
r.setFreightRate(weight != null && weight.compareTo(BigDecimal.ZERO) > 0
? freightAmount.divide(weight, 2, rm) : freightAmount);
r.setFreightAmount(freightAmount.setScale(2, rm));
r.setRegistrationFee(registrationFee.setScale(2, rm));
r.setSurchargeAmount(surchargeAmount.setScale(2, rm));
r.setPayableTotal(incomeTotal);
r.setIncomeTotal(incomeTotal);
r.setCurrency(currency);
r.setRoundMode("HALF_UP");
r.setFromCache(false);
r.setFeeDetails(lines);
r.setPayFeeDetails(Collections.<TmsPriceQuoteFeeLine>emptyList());
return r;
}
/**
* Diagnose FIXED miss: product rows absent -> 1; country/zone absent -> 2; else weight -> 3.
*/
@@ -70,6 +70,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by case when t.status = '1' then 0 when t.status = '0' then 1 else 2 end, p.product_name, t.term_id desc
</select>
<select id="selectTermIdBySetProductAt" resultType="long">
select t.term_id
from pay_product_term t
where t.price_set_id = #{priceSetId}
and t.product_id = #{productId}
and t.effective_start is not null
and t.effective_start &lt;= date(#{bizDate})
and (t.effective_end is null or t.effective_end &gt;= date(#{bizDate}))
order by ifnull(t.version_no, 1) desc, t.term_id desc
limit 1
</select>
<select id="selectLabelVersionGroups" resultType="com.bms.price.domain.LabelPackageVersion">
select #{priceSetId} as priceSetId,
t.version_no as versionNo,
+121
View File
@@ -325,3 +325,124 @@ SELECT 1, m.menu_id FROM (
UNION SELECT 2627 UNION SELECT 2628
) m
WHERE NOT EXISTS (SELECT 1 FROM sys_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.menu_id);
-- ========== 五、打单计费热路径索引(可重复执行) ==========
-- 命中顺序:客户绑套餐 → 渠道 AR 映产品 → term 按套餐+产品+日期 → 明细按 term_id
-- 5.1 客户按 customer_code 取绑定(selectEffectiveByCustomerCode)
SET @idx := (
SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'prc_customer_price_set'
AND INDEX_NAME = 'idx_cps_customer_code'
);
SET @sql := IF(@idx = 0,
'ALTER TABLE prc_customer_price_set ADD KEY idx_cps_customer_code (customer_code)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 5.2 套餐壳按分类+状态(列表/启停过滤;计费按 PK 取,顺带加速 LABEL 筛)
SET @idx := (
SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'prc_price_set'
AND INDEX_NAME = 'idx_ps_category_status'
);
SET @sql := IF(@idx = 0,
'ALTER TABLE prc_price_set ADD KEY idx_ps_category_status (set_category, status)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 5.3 渠道 AR 映射:uk_channel_pay_type(channel_id, pay_type) 已存在,补 product_id 侧已有;确保 channel_code 索引
SET @idx := (
SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'srv_channel_product_map'
AND INDEX_NAME = 'idx_channel_product_map_channel_code'
);
SET @sql := IF(@idx = 0,
'ALTER TABLE srv_channel_product_map ADD KEY idx_channel_product_map_channel_code (channel_code)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 5.4 term 计费命中:套餐+产品+生效窗(selectTermIdBySetProductAt)— 已有则跳过
SET @idx := (
SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'pay_product_term'
AND INDEX_NAME = 'idx_pay_term_set_product_time'
);
SET @sql := IF(@idx = 0,
'ALTER TABLE pay_product_term ADD KEY idx_pay_term_set_product_time (price_set_id, product_id, effective_start, effective_end)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 5.5 term 按产品查是否挂套餐(selectLabelProductIdByDict 的 exists)
SET @idx := (
SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'pay_product_term'
AND INDEX_NAME = 'idx_pay_term_product_set'
);
SET @sql := IF(@idx = 0,
'ALTER TABLE pay_product_term ADD KEY idx_pay_term_product_set (product_id, price_set_id)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 5.6 打单产品字典命中(dict_value + AR)
SET @idx := (
SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'pay_product'
AND INDEX_NAME = 'idx_pay_product_dict_type'
);
SET @sql := IF(@idx = 0,
'ALTER TABLE pay_product ADD KEY idx_pay_product_dict_type (dict_value, product_type)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 5.7 明细按 term_id 装载(select*ByTerm);原索引以 product_id 领先,补 term 单列
SET @idx := (
SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'pay_weight_band'
AND INDEX_NAME = 'idx_pay_weight_term'
);
SET @sql := IF(@idx = 0,
'ALTER TABLE pay_weight_band ADD KEY idx_pay_weight_term (term_id)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx := (
SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'pay_surcharge'
AND INDEX_NAME = 'idx_pay_surcharge_term'
);
SET @sql := IF(@idx = 0,
'ALTER TABLE pay_surcharge ADD KEY idx_pay_surcharge_term (term_id)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @idx := (
SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'pay_extra_line'
AND INDEX_NAME = 'idx_pay_extra_term'
);
SET @sql := IF(@idx = 0,
'ALTER TABLE pay_extra_line ADD KEY idx_pay_extra_term (term_id)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 5.8 订单扩展按渠道反查(可选批量重计时);bill_id / order_no 已有唯一键
SET @idx := (
SELECT COUNT(1) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'prc_order_bill_ext'
AND INDEX_NAME = 'idx_order_bill_ext_channel'
);
SET @sql := IF(@idx = 0,
'ALTER TABLE prc_order_bill_ext ADD KEY idx_order_bill_ext_channel (product_channel_id)',
'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;