增加试算功能

This commit is contained in:
wuweihua
2026-09-07 14:42:27 +08:00
parent a083a0dece
commit ea2bc6f33e
17 changed files with 1060 additions and 0 deletions
@@ -0,0 +1,60 @@
package com.ruoyi.price.domain.vo;
import java.io.Serializable;
import java.math.BigDecimal;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* TMS freight quote request.
*
* @author ruoyi
*/
public class TmsPriceQuoteRequest implements Serializable
{
private static final long serialVersionUID = 1L;
@NotNull(message = "customerId is required")
private Long customerId;
@NotBlank(message = "productCode is required")
private String productCode;
/** ISO2 country code; provide countryCode or countryName */
private String countryCode;
/** Country CN name; provide countryCode or countryName */
private String countryName;
@NotNull(message = "weight is required")
private BigDecimal weight;
/** Optional business date yyyy-MM-dd; default today */
private String bizDate;
/** Optional TMS / tenant id */
private Integer tmsId;
/**
* Agreed API identification code (same as header X-Api-Code).
* Prefer header; body is fallback.
*/
private String apiCode;
public Long getCustomerId() { return customerId; }
public void setCustomerId(Long customerId) { this.customerId = customerId; }
public String getProductCode() { return productCode; }
public void setProductCode(String productCode) { this.productCode = productCode; }
public String getCountryCode() { return countryCode; }
public void setCountryCode(String countryCode) { this.countryCode = countryCode; }
public String getCountryName() { return countryName; }
public void setCountryName(String countryName) { this.countryName = countryName; }
public BigDecimal getWeight() { return weight; }
public void setWeight(BigDecimal weight) { this.weight = weight; }
public String getBizDate() { return bizDate; }
public void setBizDate(String bizDate) { this.bizDate = bizDate; }
public Integer getTmsId() { return tmsId; }
public void setTmsId(Integer tmsId) { this.tmsId = tmsId; }
public String getApiCode() { return apiCode; }
public void setApiCode(String apiCode) { this.apiCode = apiCode; }
}
@@ -0,0 +1,105 @@
package com.ruoyi.price.domain.vo;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* TMS freight quote response.
*
* matchSource: PACKAGE_FIXED / PACKAGE_DISCOUNT / PRODUCT_DEFAULT
*
* @author ruoyi
*/
public class TmsPriceQuoteResult implements Serializable
{
private static final long serialVersionUID = 1L;
private String matchSource;
private String matchMessage;
private Long customerId;
private Long priceSetId;
private String priceSetCode;
private String priceSetName;
private String setType;
private Integer bindPriority;
private Long customerPricesetId;
private String productCode;
private String productName;
private String countryCode;
private String countryName;
private Long productCountryId;
private BigDecimal weightMin;
private BigDecimal weightMax;
private String weightLabel;
private BigDecimal weight;
private BigDecimal chargeWeight;
/** Freight unit price RMB/KG (after discount if any) */
private BigDecimal freightRate;
/** Base freight before discount (DISCOUNT only) */
private BigDecimal baseFreightRate;
/** Discount percent e.g. 88 */
private BigDecimal discountPercent;
/** Registration fee RMB/pcs */
private BigDecimal registrationFee;
/** freightRate * chargeWeight */
private BigDecimal freightAmount;
/** freightAmount + registrationFee */
private BigDecimal payableTotal;
private String currency = "RMB";
public String getMatchSource() { return matchSource; }
public void setMatchSource(String matchSource) { this.matchSource = matchSource; }
public String getMatchMessage() { return matchMessage; }
public void setMatchMessage(String matchMessage) { this.matchMessage = matchMessage; }
public Long getCustomerId() { return customerId; }
public void setCustomerId(Long customerId) { this.customerId = customerId; }
public Long getPriceSetId() { return priceSetId; }
public void setPriceSetId(Long priceSetId) { this.priceSetId = priceSetId; }
public String getPriceSetCode() { return priceSetCode; }
public void setPriceSetCode(String priceSetCode) { this.priceSetCode = priceSetCode; }
public String getPriceSetName() { return priceSetName; }
public void setPriceSetName(String priceSetName) { this.priceSetName = priceSetName; }
public String getSetType() { return setType; }
public void setSetType(String setType) { this.setType = setType; }
public Integer getBindPriority() { return bindPriority; }
public void setBindPriority(Integer bindPriority) { this.bindPriority = bindPriority; }
public Long getCustomerPricesetId() { return customerPricesetId; }
public void setCustomerPricesetId(Long customerPricesetId) { this.customerPricesetId = customerPricesetId; }
public String getProductCode() { return productCode; }
public void setProductCode(String productCode) { this.productCode = productCode; }
public String getProductName() { return productName; }
public void setProductName(String productName) { this.productName = productName; }
public String getCountryCode() { return countryCode; }
public void setCountryCode(String countryCode) { this.countryCode = countryCode; }
public String getCountryName() { return countryName; }
public void setCountryName(String countryName) { this.countryName = countryName; }
public Long getProductCountryId() { return productCountryId; }
public void setProductCountryId(Long productCountryId) { this.productCountryId = productCountryId; }
public BigDecimal getWeightMin() { return weightMin; }
public void setWeightMin(BigDecimal weightMin) { this.weightMin = weightMin; }
public BigDecimal getWeightMax() { return weightMax; }
public void setWeightMax(BigDecimal weightMax) { this.weightMax = weightMax; }
public String getWeightLabel() { return weightLabel; }
public void setWeightLabel(String weightLabel) { this.weightLabel = weightLabel; }
public BigDecimal getWeight() { return weight; }
public void setWeight(BigDecimal weight) { this.weight = weight; }
public BigDecimal getChargeWeight() { return chargeWeight; }
public void setChargeWeight(BigDecimal chargeWeight) { this.chargeWeight = chargeWeight; }
public BigDecimal getFreightRate() { return freightRate; }
public void setFreightRate(BigDecimal freightRate) { this.freightRate = freightRate; }
public BigDecimal getBaseFreightRate() { return baseFreightRate; }
public void setBaseFreightRate(BigDecimal baseFreightRate) { this.baseFreightRate = baseFreightRate; }
public BigDecimal getDiscountPercent() { return discountPercent; }
public void setDiscountPercent(BigDecimal discountPercent) { this.discountPercent = discountPercent; }
public BigDecimal getRegistrationFee() { return registrationFee; }
public void setRegistrationFee(BigDecimal registrationFee) { this.registrationFee = registrationFee; }
public BigDecimal getFreightAmount() { return freightAmount; }
public void setFreightAmount(BigDecimal freightAmount) { this.freightAmount = freightAmount; }
public BigDecimal getPayableTotal() { return payableTotal; }
public void setPayableTotal(BigDecimal payableTotal) { this.payableTotal = payableTotal; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
}
@@ -1,6 +1,8 @@
package com.ruoyi.price.mapper;
import java.math.BigDecimal;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.price.domain.BmsProductCountry;
/**
@@ -17,4 +19,13 @@ public interface BmsProductCountryMapper
public int deleteByProductCode(String productCode);
public int deleteByProductCodes(String[] productCodes);
/**
* 按产品 + 国家 + 重量落入重量段匹配一条默认价
*/
public BmsProductCountry selectMatchByProductCountryWeight(
@Param("productCode") String productCode,
@Param("countryCode") String countryCode,
@Param("countryName") String countryName,
@Param("weight") BigDecimal weight);
}
@@ -29,4 +29,11 @@ public interface PrcCustomerPriceSetMapper
@Param("priceSetId") Long priceSetId,
@Param("beginDate") java.util.Date beginDate,
@Param("endDate") java.util.Date endDate);
/**
* 客户在业务日有效、且套餐状态为生效(E) 的绑定,按优先级升序
*/
public java.util.List<PrcCustomerPriceSet> selectEffectiveByCustomer(
@Param("customerId") Long customerId,
@Param("bizDate") java.util.Date bizDate);
}
@@ -1,6 +1,7 @@
package com.ruoyi.price.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.price.domain.PrcPriceSetDetail;
/**
@@ -25,4 +26,18 @@ public interface PrcPriceSetDetailMapper
public int countProductByPriceSetId(Long priceSetId);
public int countByPriceSetId(Long priceSetId);
/** FIXED:按产品+国家+关联产品国家明细/重量段命中 */
public PrcPriceSetDetail selectFixedMatch(
@Param("priceSetId") Long priceSetId,
@Param("productCode") String productCode,
@Param("countryCode") String countryCode,
@Param("countryName") String countryName,
@Param("productCountryId") Long productCountryId,
@Param("weightLabel") String weightLabel);
/** DISCOUNT:按产品命中明细折扣(可空) */
public PrcPriceSetDetail selectDiscountMatch(
@Param("priceSetId") Long priceSetId,
@Param("productCode") String productCode);
}
@@ -0,0 +1,17 @@
package com.ruoyi.price.service;
import com.ruoyi.price.domain.vo.TmsPriceQuoteRequest;
import com.ruoyi.price.domain.vo.TmsPriceQuoteResult;
/**
* TMS freight quote service.
*
* Match order: effective customer package (bind_priority ASC) FIXED override
* then DISCOUNT, else bms_product_country default freight/registration.
*
* @author ruoyi
*/
public interface ITmsPriceQuoteService
{
TmsPriceQuoteResult quote(TmsPriceQuoteRequest request);
}
@@ -0,0 +1,324 @@
package com.ruoyi.price.service.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.price.domain.BmsProduct;
import com.ruoyi.price.domain.BmsProductCountry;
import com.ruoyi.price.domain.PrcCustomerPriceSet;
import com.ruoyi.price.domain.PrcPriceSet;
import com.ruoyi.price.domain.PrcPriceSetDetail;
import com.ruoyi.price.domain.vo.TmsPriceQuoteRequest;
import com.ruoyi.price.domain.vo.TmsPriceQuoteResult;
import com.ruoyi.price.mapper.BmsProductCountryMapper;
import com.ruoyi.price.mapper.BmsProductMapper;
import com.ruoyi.price.mapper.PrcCustomerPriceSetMapper;
import com.ruoyi.price.mapper.PrcPriceSetDetailMapper;
import com.ruoyi.price.mapper.PrcPriceSetMapper;
import com.ruoyi.price.service.ITmsPriceQuoteService;
/**
* TMS freight quote: customer package first, then product default.
*
* @author ruoyi
*/
@Service
public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
{
private static final String TYPE_FIXED = "FIXED";
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_DEFAULT = "PRODUCT_DEFAULT";
@Autowired
private PrcCustomerPriceSetMapper customerPriceSetMapper;
@Autowired
private PrcPriceSetMapper priceSetMapper;
@Autowired
private PrcPriceSetDetailMapper detailMapper;
@Autowired
private BmsProductCountryMapper productCountryMapper;
@Autowired
private BmsProductMapper productMapper;
@Override
public TmsPriceQuoteResult quote(TmsPriceQuoteRequest request)
{
validateRequest(request);
String productCode = request.getProductCode().trim();
String countryCode = emptyToNull(request.getCountryCode());
String countryName = emptyToNull(request.getCountryName());
if (countryCode != null)
{
countryCode = countryCode.toUpperCase();
}
BigDecimal weight = request.getWeight();
Date bizDate = resolveBizDate(request.getBizDate());
BmsProductCountry base = productCountryMapper.selectMatchByProductCountryWeight(
productCode, countryCode, countryName, weight);
if (base == null)
{
throw new ServiceException("PRICE_NOT_HIT: no bms_product_country band for product/country/weight");
}
String productName = resolveProductName(productCode);
List<PrcCustomerPriceSet> binds = customerPriceSetMapper.selectEffectiveByCustomer(
request.getCustomerId(), bizDate);
if (binds != null)
{
for (PrcCustomerPriceSet bind : binds)
{
PrcPriceSet set = priceSetMapper.selectPrcPriceSetById(bind.getPriceSetId());
if (set == null || !"E".equals(set.getStatus()))
{
continue;
}
String setType = StringUtils.isEmpty(set.getSetType()) ? TYPE_FIXED : set.getSetType();
if (TYPE_FIXED.equals(setType))
{
TmsPriceQuoteResult hit = tryFixed(bind, set, base, productCode, productName, weight);
if (hit != null)
{
return hit;
}
}
else if (TYPE_DISCOUNT.equals(setType))
{
TmsPriceQuoteResult hit = tryDiscount(bind, set, base, productCode, productName, weight);
if (hit != null)
{
return hit;
}
}
}
}
return buildDefault(request.getCustomerId(), base, productCode, productName, weight,
"no customer package override; use product default freight/registration");
}
private TmsPriceQuoteResult tryFixed(PrcCustomerPriceSet bind, PrcPriceSet set,
BmsProductCountry base, String productCode, String productName, BigDecimal weight)
{
int detailCount = detailMapper.countByPriceSetId(set.getPriceSetId());
if (detailCount == 0)
{
TmsPriceQuoteResult r = buildFromRates(SRC_DEFAULT, base.getFreightPrice(), null, null,
base.getRegistrationFee(), base, productCode, productName, weight);
fillPackage(r, bind, set);
r.setMatchMessage("FIXED package has no details; use bms_product_country default");
r.setCustomerId(bind.getCustomerId());
return r;
}
PrcPriceSetDetail detail = detailMapper.selectFixedMatch(
set.getPriceSetId(), productCode,
base.getCountryCode(), base.getCountryName(), base.getDetailId(),
formatWeightLabel(base.getWeightMin(), base.getWeightMax()));
if (detail == null)
{
return null;
}
if (detail.getFvValue() == null || detail.getFvRegistrationFee() == null)
{
return null;
}
TmsPriceQuoteResult r = buildFromRates(SRC_FIXED, detail.getFvValue(), null, null,
detail.getFvRegistrationFee(), base, productCode, productName, weight);
fillPackage(r, bind, set);
if (StringUtils.isNotEmpty(detail.getProductName()))
{
r.setProductName(detail.getProductName());
}
if (StringUtils.isNotEmpty(detail.getWeightLabel()))
{
r.setWeightLabel(detail.getWeightLabel());
}
if (detail.getProductCountryId() != null)
{
r.setProductCountryId(detail.getProductCountryId());
}
r.setMatchMessage("hit customer FIXED package detail");
r.setCustomerId(bind.getCustomerId());
return r;
}
private TmsPriceQuoteResult tryDiscount(PrcCustomerPriceSet bind, PrcPriceSet set,
BmsProductCountry base, String productCode, String productName, BigDecimal weight)
{
int detailCount = detailMapper.countByPriceSetId(set.getPriceSetId());
BigDecimal discount = set.getDefaultDiscount();
if (detailCount > 0)
{
PrcPriceSetDetail detail = detailMapper.selectDiscountMatch(set.getPriceSetId(), productCode);
if (detail == null)
{
return null;
}
if (detail.getDiscountValue() != null)
{
discount = detail.getDiscountValue();
}
}
if (discount == null
|| discount.compareTo(BigDecimal.ONE) < 0
|| discount.compareTo(new BigDecimal("99")) > 0)
{
return null;
}
if (base.getFreightPrice() == null)
{
throw new ServiceException("FREIGHT_VALUE_MISSING: product default freight rate is null");
}
BigDecimal baseRate = base.getFreightPrice();
BigDecimal rate = baseRate.multiply(discount)
.divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
TmsPriceQuoteResult r = buildFromRates(SRC_DISCOUNT, rate, baseRate, discount,
base.getRegistrationFee(), base, productCode, productName, weight);
fillPackage(r, bind, set);
r.setMatchMessage("hit customer DISCOUNT package; freight = default * discount%");
r.setCustomerId(bind.getCustomerId());
return r;
}
private TmsPriceQuoteResult buildDefault(Long customerId, BmsProductCountry base,
String productCode, String productName, BigDecimal weight, String message)
{
if (base.getFreightPrice() == null || base.getRegistrationFee() == null)
{
throw new ServiceException("FREIGHT_VALUE_MISSING: product default freight or registration is null");
}
TmsPriceQuoteResult r = buildFromRates(SRC_DEFAULT, base.getFreightPrice(), null, null,
base.getRegistrationFee(), base, productCode, productName, weight);
r.setCustomerId(customerId);
r.setMatchMessage(message);
return r;
}
private TmsPriceQuoteResult buildFromRates(String source, BigDecimal freightRate,
BigDecimal baseFreightRate, BigDecimal discountPercent, BigDecimal registrationFee,
BmsProductCountry base, String productCode, String productName, BigDecimal weight)
{
if (freightRate == null)
{
throw new ServiceException("FREIGHT_VALUE_MISSING: freight rate is null");
}
if (registrationFee == null)
{
registrationFee = BigDecimal.ZERO;
}
BigDecimal freightAmount = freightRate.multiply(weight).setScale(2, RoundingMode.HALF_UP);
BigDecimal payable = freightAmount.add(registrationFee).setScale(2, RoundingMode.HALF_UP);
TmsPriceQuoteResult r = new TmsPriceQuoteResult();
r.setMatchSource(source);
r.setProductCode(productCode);
r.setProductName(productName);
r.setCountryCode(base.getCountryCode());
r.setCountryName(base.getCountryName());
r.setProductCountryId(base.getDetailId());
r.setWeightMin(base.getWeightMin());
r.setWeightMax(base.getWeightMax());
r.setWeightLabel(formatWeightLabel(base.getWeightMin(), base.getWeightMax()));
r.setWeight(weight);
r.setChargeWeight(weight);
r.setFreightRate(freightRate);
r.setBaseFreightRate(baseFreightRate);
r.setDiscountPercent(discountPercent);
r.setRegistrationFee(registrationFee);
r.setFreightAmount(freightAmount);
r.setPayableTotal(payable);
r.setCurrency("RMB");
return r;
}
private void fillPackage(TmsPriceQuoteResult r, PrcCustomerPriceSet bind, PrcPriceSet set)
{
r.setPriceSetId(set.getPriceSetId());
r.setPriceSetCode(set.getPriceSetCode());
r.setPriceSetName(set.getPriceSetName());
r.setSetType(set.getSetType());
r.setBindPriority(bind.getBindPriority());
r.setCustomerPricesetId(bind.getCustomerPricesetId());
}
private void validateRequest(TmsPriceQuoteRequest request)
{
if (request == null)
{
throw new ServiceException("request body is required");
}
if (request.getCustomerId() == null)
{
throw new ServiceException("customerId is required");
}
if (StringUtils.isEmpty(request.getProductCode()))
{
throw new ServiceException("productCode is required");
}
if (StringUtils.isEmpty(request.getCountryCode()) && StringUtils.isEmpty(request.getCountryName()))
{
throw new ServiceException("countryCode or countryName is required");
}
if (request.getWeight() == null || request.getWeight().compareTo(BigDecimal.ZERO) <= 0)
{
throw new ServiceException("weight must be > 0");
}
}
private Date resolveBizDate(String bizDate)
{
if (StringUtils.isEmpty(bizDate))
{
return new Date();
}
try
{
return new SimpleDateFormat("yyyy-MM-dd").parse(bizDate.trim());
}
catch (ParseException e)
{
throw new ServiceException("bizDate format must be yyyy-MM-dd");
}
}
private String resolveProductName(String productCode)
{
BmsProduct p = productMapper.selectBmsProductByCode(productCode);
return p == null ? null : p.getProductName();
}
private static String emptyToNull(String s)
{
return StringUtils.isEmpty(s) ? null : s.trim();
}
private static String formatWeightLabel(BigDecimal min, BigDecimal max)
{
return strip(min) + "\u2264W\u2264" + strip(max);
}
private static String strip(BigDecimal v)
{
if (v == null)
{
return "";
}
return v.stripTrailingZeros().toPlainString();
}
}
@@ -69,4 +69,27 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</foreach>
</delete>
<select id="selectMatchByProductCountryWeight" resultMap="BmsProductCountryResult">
select detail_id, product_code, country_name, country_code, weight_min, weight_max,
freight_price, registration_fee, size_limit, last_mile, lead_time,
effective_date, created_at, updated_at
from bms_product_country
where product_code = #{productCode}
and weight_min &lt;= #{weight}
and weight_max &gt;= #{weight}
<choose>
<when test="countryCode != null and countryCode != ''">
and upper(country_code) = upper(#{countryCode})
</when>
<when test="countryName != null and countryName != ''">
and country_name = #{countryName}
</when>
<otherwise>
and 1 = 0
</otherwise>
</choose>
order by weight_min asc, detail_id asc
limit 1
</select>
</mapper>
@@ -64,6 +64,19 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
and ifnull(c.pr_enddate, '9999-12-31') &gt;= #{beginDate}
</select>
<select id="selectEffectiveByCustomer" resultMap="PrcCustomerPriceSetResult">
select c.customer_priceset_id, c.price_set_id, c.customer_id, c.customer_name, c.bind_priority,
c.pr_begindate, c.pr_enddate, c.price_set_note,
c.created_by, c.created_at, c.updated_by, c.updated_at
from prc_customer_price_set c
inner join prc_price_set s on s.price_set_id = c.price_set_id
where c.customer_id = #{customerId}
and s.status = 'E'
and c.pr_begindate &lt;= #{bizDate}
and ifnull(c.pr_enddate, '9999-12-31') &gt;= #{bizDate}
order by c.bind_priority asc, c.pr_begindate desc, c.customer_priceset_id asc
</select>
<insert id="insertPrcCustomerPriceSet" parameterType="PrcCustomerPriceSet" useGeneratedKeys="true" keyProperty="customerPricesetId">
insert into prc_customer_price_set (
<if test="priceSetId != null">price_set_id,</if>
@@ -124,4 +124,44 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
delete from prc_price_set_detail where price_set_id = #{priceSetId}
</delete>
<select id="selectFixedMatch" resultMap="PrcPriceSetDetailResult">
<include refid="selectPrcPriceSetDetailVo"/>
where price_set_id = #{priceSetId}
and product_code = #{productCode}
and (
<if test="productCountryId != null">
product_country_id = #{productCountryId}
or
</if>
(
<choose>
<when test="countryCode != null and countryCode != ''">
upper(ifnull(country_code,'')) = upper(#{countryCode})
</when>
<when test="countryName != null and countryName != ''">
country_name = #{countryName}
</when>
<otherwise>
1 = 0
</otherwise>
</choose>
<if test="weightLabel != null and weightLabel != ''">
and (weight_label is null or weight_label = #{weightLabel} or weight_label like concat('%', #{weightLabel}, '%'))
</if>
)
)
order by
case when #{productCountryId} is not null and product_country_id = #{productCountryId} then 0 else 1 end,
psd_id asc
limit 1
</select>
<select id="selectDiscountMatch" resultMap="PrcPriceSetDetailResult">
<include refid="selectPrcPriceSetDetailVo"/>
where price_set_id = #{priceSetId}
and product_code = #{productCode}
order by psd_id asc
limit 1
</select>
</mapper>