Merge branch 'test'

This commit is contained in:
caojia
2026-11-02 09:17:44 +08:00
28 changed files with 615 additions and 100 deletions
@@ -27,6 +27,7 @@ import com.bms.common.enums.BusinessType;
import com.bms.common.utils.MessageUtils;
import com.bms.common.utils.StringUtils;
import com.bms.price.domain.PrcCustomerPriceSet;
import com.bms.price.domain.PrcCustomerPriceSetBatch;
import com.bms.price.domain.PrcPriceExcelFile;
import com.bms.price.domain.PrcPriceSet;
import com.bms.price.domain.PrcPriceSetDetail;
@@ -378,6 +379,16 @@ public class PriceSetController extends BaseController
return toAjax(priceSetService.insertCustomer(customer));
}
@PreAuthorize("@ss.hasPermi('price:set:edit')")
@Log(title = "套餐客户批量绑定", businessType = BusinessType.INSERT)
@PostMapping("/customer/batch")
public AjaxResult batchAddCustomer(@RequestBody PrcCustomerPriceSetBatch batch)
{
batch.setCreateBy(getUsername());
batch.setUpdateBy(getUsername());
return success(priceSetService.batchInsertCustomers(batch));
}
@PreAuthorize("@ss.hasPermi('price:set:edit')")
@Log(title = "套餐客户绑定", businessType = BusinessType.UPDATE)
@PutMapping("/customer")
@@ -230,3 +230,5 @@ price.bill.export.tooMany=匹配订单超过上限 {0} 条,请缩小筛选范
price.bill.export.noMatch=未查询到匹配订单,无法导出
price.bill.export.notReady=导出文件尚未生成或已失败
price.bill.export.fileMissing=导出文件不存在或路径非法
price.set.customer.batch.empty=请选择要绑定的客户
price.set.customer.batch.done=批量绑定完成:共 {0} 个客户,新绑定 {1} 个,更换 {2} 个,跳过 {3} 个
@@ -47,6 +47,10 @@ public class BmsCustomer extends BaseEntity
@Excel(name = "业务类型", readConverterExp = "0=直客,1=同行,2=集运")
private String customerType;
/** 区域类型(字典 data_area_type) */
@Excel(name = "区域类型", dictType = "data_area_type")
private String areaType;
/** 启用状态(0正常 1停用) */
@Excel(name = "启用状态", readConverterExp = "0=正常,1=停用")
private String isEnable;
@@ -13,7 +13,9 @@ import java.util.function.Function;
import com.alibaba.fastjson2.JSON;
import com.bms.common.core.domain.entity.SysUser;
import com.bms.common.utils.DateUtils;
import com.bms.common.utils.DictUtils;
import com.bms.common.utils.SecurityUtils;
import com.bms.common.utils.StringUtils;
import com.bms.customer.customerinfo.domain.BmsCustomer;
import com.bms.customer.customerinfo.domain.BmsCustomerAttachment;
import com.bms.customer.customerinfo.domain.BmsCustomerCompany;
@@ -43,6 +45,13 @@ public class BmsCustomerLogServiceImpl implements IBmsCustomerLogService
private static final ThreadLocal<SimpleDateFormat> DATE_FMT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
/** 区域类型走字典 data_area_type;缓存未命中时保留原编码。 */
private static String areaTypeLabel(String value)
{
String label = DictUtils.getDictLabel("data_area_type", value);
return StringUtils.isEmpty(label) ? value : label;
}
/** 业务类型字典 */
private static final Map<String, String> CUSTOMER_TYPE_MAP = Map.of(
"0", "直客", "1", "同行", "2", "集运");
@@ -68,7 +77,7 @@ public class BmsCustomerLogServiceImpl implements IBmsCustomerLogService
/**
* 需要记录日志的主表字段(顺序即展示顺序)。
* requireNonBlank 为 true 的字段对应 update SQL 的 &lt;if test="x!=null and x!=''"&gt;,
* 其余动态字段仅在新值非 null 时落库;业务员/结算员/客服为无条件写入。
* 其余动态字段仅在新值非 null 时落库;业务员/结算员/客服、区域类型为无条件写入。
*/
private static final List<FieldDef> FIELD_DEFS = List.of(
new FieldDef("customerCode", "客户编码", BmsCustomer::getCustomerCode, true, Function.identity()),
@@ -76,6 +85,7 @@ public class BmsCustomerLogServiceImpl implements IBmsCustomerLogService
new FieldDef("customerCodeDw", "对外代码", BmsCustomer::getCustomerCodeDw, false, Function.identity()),
new FieldDef("creditCode", "统一社会信用代码", BmsCustomer::getCreditCode, false, Function.identity()),
new FieldDef("customerType", "业务类型", BmsCustomer::getCustomerType, false, CUSTOMER_TYPE_MAP::get),
new FieldDef("areaType", "区域类型", BmsCustomer::getAreaType, false, BmsCustomerLogServiceImpl::areaTypeLabel),
new FieldDef("isEnable", "启用状态", BmsCustomer::getIsEnable, false, ENABLE_MAP::get),
new FieldDef("salesUserId", "业务员", c -> userDisplay(c.getSalesUserId(), c.getSalesUser()), false, Function.identity()),
new FieldDef("ed", "资金额度(元)", BmsCustomer::getEd, false, BmsCustomerLogServiceImpl::numText),
@@ -198,8 +208,8 @@ public class BmsCustomerLogServiceImpl implements IBmsCustomerLogService
{
// 与 update SQL 的动态条件保持一致:未提交(null/空串)的字段本次不落库,不参与比对
Object raw = def.rawGetter.apply(newCustomer);
// 合同起止允许清空;其余动态字段未提交时不落库,也不记日志
if (raw == null && !isContractDateField(def.field))
// 合同起止、区域类型允许清空;其余动态字段未提交时不落库,也不记日志
if (raw == null && !isContractDateField(def.field) && !"areaType".equals(def.field))
{
continue;
}
@@ -69,10 +69,6 @@ public class BmsCustomerPriceSetServiceImpl implements IBmsCustomerPriceSetServi
{
throw new ServiceException("所选套餐不存在");
}
if (!"E".equals(set.getStatus()))
{
throw new ServiceException("仅生效中的套餐允许绑定,当前套餐状态不可用");
}
if (bind.getBindPriority() == null || bind.getBindPriority() < 1)
{
bind.setBindPriority(1);
@@ -141,10 +137,6 @@ public class BmsCustomerPriceSetServiceImpl implements IBmsCustomerPriceSetServi
{
throw new ServiceException("所选套餐不存在");
}
if (!"E".equals(set.getStatus()))
{
throw new ServiceException("仅生效中的套餐允许绑定,当前套餐状态不可用");
}
Integer priority = (req.getBindPriority() == null || req.getBindPriority() < 1) ? 1 : req.getBindPriority();
boolean overwrite = Boolean.TRUE.equals(req.getOverwriteExisting());
@@ -0,0 +1,30 @@
package com.bms.price.domain;
import java.util.Date;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.bms.common.core.domain.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* Price-set detail batch bind customers.
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class PrcCustomerPriceSetBatch extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long priceSetId;
private Integer bindPriority;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date prBegindate;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date prEnddate;
private String priceSetNote;
/** Customers to bind (need customerCode; id/name optional). */
private List<PrcCustomerPriceSet> customers;
/** true = move already-bound customers onto this set; false = skip them. */
private Boolean overwriteExisting;
}
@@ -116,6 +116,17 @@ public interface BmsProductCountryMapper
@Param("weight") BigDecimal weight,
@Param("bizDate") Date bizDate);
/**
* Lowest PRODUCT catalog weight band for product/country/zone (ignore weight).
* Used when charge weight is below the minimum band.
*/
public BmsProductCountry selectMinBandByProductCountry(
@Param("productCode") String productCode,
@Param("countryCode") String countryCode,
@Param("countryName") String countryName,
@Param("zoneCode") String zoneCode,
@Param("bizDate") Date bizDate);
/** Count product-country rows that have a zone for this product+country+bizDate */
public int countZonedByProductCountry(
@Param("productCode") String productCode,
@@ -71,6 +71,17 @@ public interface PrcPriceSetDetailMapper
@Param("zoneCode") String zoneCode,
@Param("weight") BigDecimal weight);
/**
* Lowest FIXED weight band for product/country/zone (ignore weight).
* Used when charge weight is below the minimum band.
*/
public PrcPriceSetDetail selectFixedMinBand(
@Param("psvId") Long psvId,
@Param("productCode") String productCode,
@Param("countryCode") String countryCode,
@Param("countryName") String countryName,
@Param("zoneCode") String zoneCode);
public int countZonedByPsvProductCountry(
@Param("psvId") Long psvId,
@Param("productCode") String productCode,
@@ -5,6 +5,7 @@ import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
import com.bms.price.domain.PrcCustomerPriceSet;
import com.bms.price.domain.PrcCustomerPriceSetBatch;
import com.bms.price.domain.PrcPriceSet;
import com.bms.price.domain.PrcPriceSetCheckItem;
import com.bms.price.domain.PrcPriceSetDetail;
@@ -92,6 +93,9 @@ public interface IPrcPriceSetService
public int insertCustomer(PrcCustomerPriceSet customer);
/** Batch bind customers to a price set; returns summary message. */
public String batchInsertCustomers(PrcCustomerPriceSetBatch batch);
public int updateCustomer(PrcCustomerPriceSet customer);
public int deleteCustomerByIds(Long[] customerPricesetIds);
@@ -2432,6 +2432,128 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
}
@Override
@org.springframework.transaction.annotation.Transactional(rollbackFor = Exception.class)
public String batchInsertCustomers(com.bms.price.domain.PrcCustomerPriceSetBatch batch)
{
if (batch == null || batch.getCustomers() == null || batch.getCustomers().isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.customer.batch.empty"));
}
if (batch.getPriceSetId() == null)
{
throw new ServiceException(MessageUtils.message("price.set.customer.customerId.empty"));
}
PrcPriceSet db = requireSet(batch.getPriceSetId());
assertChildEditable(db);
Integer priority = (batch.getBindPriority() == null || batch.getBindPriority().intValue() < 1)
? Integer.valueOf(1) : batch.getBindPriority();
boolean overwrite = Boolean.TRUE.equals(batch.getOverwriteExisting());
java.util.LinkedHashMap<String, PrcCustomerPriceSet> uniq = new java.util.LinkedHashMap<String, PrcCustomerPriceSet>();
for (PrcCustomerPriceSet item : batch.getCustomers())
{
if (item == null || StringUtils.isEmpty(item.getCustomerCode()))
{
continue;
}
String code = item.getCustomerCode().trim();
if (!uniq.containsKey(code))
{
uniq.put(code, item);
}
}
if (uniq.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.set.customer.batch.empty"));
}
int inserted = 0;
int replaced = 0;
int skipped = 0;
java.util.List<String> skippedNames = new java.util.ArrayList<String>();
for (PrcCustomerPriceSet item : uniq.values())
{
PrcCustomerPriceSet query = new PrcCustomerPriceSet();
query.setCustomerCode(item.getCustomerCode().trim());
java.util.List<PrcCustomerPriceSet> found = customerMapper.selectPrcCustomerPriceSetList(query);
PrcCustomerPriceSet existing = (found == null || found.isEmpty()) ? null : found.get(0);
PrcCustomerPriceSet row = new PrcCustomerPriceSet();
row.setPriceSetId(batch.getPriceSetId());
row.setCustomerId(item.getCustomerId());
row.setCustomerCode(item.getCustomerCode().trim());
row.setCustomerName(item.getCustomerName());
row.setBindPriority(priority);
row.setPrBegindate(batch.getPrBegindate());
row.setPrEnddate(batch.getPrEnddate());
row.setPriceSetNote(batch.getPriceSetNote());
row.setCreateBy(batch.getCreateBy());
row.setUpdateBy(batch.getUpdateBy());
if (existing != null)
{
boolean sameSet = batch.getPriceSetId().equals(existing.getPriceSetId());
if (sameSet)
{
skipped++;
skippedNames.add(StringUtils.isNotEmpty(item.getCustomerName())
? item.getCustomerName() : item.getCustomerCode());
continue;
}
if (!overwrite)
{
skipped++;
skippedNames.add(StringUtils.isNotEmpty(item.getCustomerName())
? item.getCustomerName() : item.getCustomerCode());
continue;
}
row.setCustomerPricesetId(existing.getCustomerPricesetId());
if (row.getPrBegindate() == null)
{
row.setPrBegindate(existing.getPrBegindate());
}
if (row.getPrEnddate() == null)
{
row.setPrEnddate(existing.getPrEnddate());
}
validateCustomer(row, db);
customerMapper.updatePrcCustomerPriceSet(row);
replaced++;
}
else
{
validateCustomer(row, db);
try
{
customerMapper.insertPrcCustomerPriceSet(row);
}
catch (Exception e)
{
if (isCustomerUniqueViolation(e))
{
skipped++;
skippedNames.add(StringUtils.isNotEmpty(item.getCustomerName())
? item.getCustomerName() : item.getCustomerCode());
continue;
}
throw e;
}
inserted++;
}
}
String msg = MessageUtils.message("price.set.customer.batch.done",
Integer.valueOf(uniq.size()), Integer.valueOf(inserted),
Integer.valueOf(replaced), Integer.valueOf(skipped));
if (skipped > 0 && !overwrite)
{
String names = skippedNames.stream().limit(10).collect(java.util.stream.Collectors.joining(", "));
msg = msg + " [" + names + (skippedNames.size() > 10 ? "..." : "") + "]";
}
return msg;
}
@Override
public int updateCustomer(PrcCustomerPriceSet customer)
{
@@ -2962,6 +3084,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{
throw new ServiceException(MessageUtils.message("price.set.customer.customerId.empty"));
}
// UI no longer collects bind dates: fall back to set / version / today.
// Do not reject empty begin date — always fill a value.
if (customer.getPrBegindate() == null)
{
customer.setPrBegindate(set.getEffectiveDate());
@@ -2972,11 +3096,17 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
if (customer.getPrBegindate() == null)
{
throw new ServiceException(MessageUtils.message("price.set.customer.beginDate.empty"));
customer.setPrBegindate(DateUtils.getNowDate());
}
if (customer.getPrEnddate() != null && customer.getPrBegindate().after(customer.getPrEnddate()))
if (customer.getPrEnddate() == null)
{
throw new ServiceException(MessageUtils.message("price.set.customer.date.invalid"));
customer.setPrEnddate(set.getExpireDate());
}
// Auto-filled dates may conflict (e.g. set expire already past); keep bind, clear end.
if (customer.getPrEnddate() != null && customer.getPrBegindate() != null
&& customer.getPrBegindate().after(customer.getPrEnddate()))
{
customer.setPrEnddate(null);
}
if (customer.getBindPriority() != null && customer.getBindPriority().intValue() < 1)
{
@@ -520,15 +520,36 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
else if (TYPE_DISCOUNT.equals(setType))
{
// DISCOUNT still needs catalog as base rate; do not soft-fail as 产品价未匹配 for now.
if (!hasProductCatalogPrice(catalog))
BmsProductCountry discCatalog = catalog;
BigDecimal discWeight = weight;
if (!hasProductCatalogPrice(discCatalog))
{
// Below min catalog band: snap charge weight to lowest band min.
BigDecimal snapped = snapWeightToCatalogMinBand(
productCode, cc, cn, zone.zoneCode, weight, bizDate);
if (snapped != null)
{
discWeight = snapped;
discCatalog = softMatchCatalog(
productCode, cc, cn, zone.zoneCode, discWeight, bizDate, zone.zone);
}
}
if (!hasProductCatalogPrice(discCatalog))
{
// catalogMissCode = Math.max(catalogMissCode,
// diagnoseCatalogMiss(productCode, cc, cn, zone.zoneCode, bizDate));
continue;
}
TmsPriceQuoteResult hit = tryDiscount(bind, set, version, catalog, productCode, productName, weight, bizDate);
TmsPriceQuoteResult hit = tryDiscount(bind, set, version, discCatalog,
productCode, productName, discWeight, bizDate);
if (hit != null)
{
// Calc-only snap: do not persist raised min-band weight as charge_weight.
if (discWeight != null && weight != null && discWeight.compareTo(weight) != 0)
{
hit.setWeight(weight);
hit.setChargeWeight(weight);
}
stampRequestMeta(request, hit);
return hit;
}
@@ -971,6 +992,21 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
PrcPriceSetDetail detail = detailMapper.selectFixedMatch(
psvId, productCode,
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.
if (detail == null)
{
PrcPriceSetDetail minBand = detailMapper.selectFixedMinBand(
psvId, productCode, countryCode, countryName, zoneCode);
if (minBand != null && minBand.getWeightMin() != null
&& weight.compareTo(minBand.getWeightMin()) < 0)
{
detail = minBand;
billWeight = minBand.getWeightMin().setScale(3, RoundingMode.HALF_UP);
snappedToMinBand = true;
}
}
if (detail == null)
{
return null;
@@ -979,16 +1015,59 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
{
return null;
}
TmsPriceQuoteResult r = buildFromPackageDetail(SRC_FIXED, detail, catalog, resolvedZone,
productCode, productName, weight, detail.getFvValue(), null, null, detail.getFvRegistrationFee(), bizDate);
BmsProductCountry useCatalog = catalog;
if (snappedToMinBand)
{
BmsProductCountry rematched = softMatchCatalog(
productCode, countryCode, countryName, zoneCode, billWeight, bizDate, resolvedZone);
if (hasProductCatalogPrice(rematched))
{
useCatalog = rematched;
}
}
TmsPriceQuoteResult r = buildFromPackageDetail(SRC_FIXED, detail, useCatalog, resolvedZone,
productCode, productName, billWeight, detail.getFvValue(), null, null, detail.getFvRegistrationFee(), bizDate);
fillPackage(r, bind, set);
stampVersion(r, version, detail.getPsdId());
r.setMatchMessage(MessageUtils.message("price.quote.hitCustomerFixedPackageDetail")
+ " [" + (version.getVersionNo() == null ? ("psv=" + psvId) : version.getVersionNo()) + "]");
String verLabel = version.getVersionNo() == null ? ("psv=" + psvId) : version.getVersionNo();
String msg = MessageUtils.message("price.quote.hitCustomerFixedPackageDetail") + " [" + verLabel + "]";
if (snappedToMinBand)
{
// Calc-only snap: keep original weight/chargeWeight for persist so upstream re-sync
// can replace them when the new weight is already in-band.
r.setWeight(weight);
r.setChargeWeight(weight);
msg = msg + "; billed at min band " + billWeight.toPlainString() + " (persist original weight)";
}
r.setMatchMessage(msg);
r.setCustomerId(bind.getCustomerId());
return r;
}
/**
* When weight misses catalog bands but is below the lowest band, return that band's weight_min.
* Otherwise null (caller keeps original miss).
*/
private BigDecimal snapWeightToCatalogMinBand(String productCode, String countryCode, String countryName,
String zoneCode, BigDecimal weight, Date bizDate)
{
if (weight == null || StringUtils.isEmpty(productCode))
{
return null;
}
BmsProductCountry minBand = productCountryMapper.selectMinBandByProductCountry(
productCode, countryCode, countryName, zoneCode, bizDate);
if (minBand == null || minBand.getWeightMin() == null)
{
return null;
}
if (weight.compareTo(minBand.getWeightMin()) < 0)
{
return minBand.getWeightMin().setScale(3, RoundingMode.HALF_UP);
}
return null;
}
private TmsPriceQuoteResult tryDiscount(PrcCustomerPriceSet bind, PrcPriceSet set, PrcPriceSetVersion version,
BmsProductCountry base, String productCode, String productName, BigDecimal weight, Date bizDate)
{
@@ -446,8 +446,7 @@ public class CatalogVersionSupport
}
/**
* Catalog version that can accept preview apply / Excel attach / edit.
* Same as sales force-edit: draft, pending, and effect are writable.
* Catalog force-edit: draft, pending, effect, and expired are writable.
*/
public PrcPriceSetVersion requireEditableCatalogVersion(Long psvId, String priceType)
{
@@ -467,9 +466,10 @@ public class CatalogVersionSupport
}
if (!STATUS_DRAFT.equals(ver.getStatus())
&& !STATUS_PENDING.equals(ver.getStatus())
&& !STATUS_EFFECT.equals(ver.getStatus()))
&& !STATUS_EFFECT.equals(ver.getStatus())
&& !STATUS_EXPIRED.equals(ver.getStatus()))
{
throw new ServiceException("仅草稿、待生效或已生效版本可以编辑");
throw new ServiceException("仅草稿、待生效、已生效或已失效版本可以编辑");
}
return ver;
}
@@ -11,6 +11,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="customerCodeDw" column="customer_code_dw" />
<result property="creditCode" column="credit_code" />
<result property="customerType" column="customer_type" />
<result property="areaType" column="area_type" />
<result property="isEnable" column="is_enable" />
<result property="salesUserId" column="sales_user_id" />
<result property="salesUser" column="sales_user" />
@@ -44,7 +45,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<!-- 账户余额/外币余额统一取 ctr_customer_account 余额表(CNY 为本币,其余为外币),不取 ctr_customer 冗余列 -->
<!-- 信用额度=总额度+额外额度-已用额度-->
<sql id="selectBmsCustomerVo">
select c.customer_id, c.customer_code, c.customer_name, c.customer_code_dw, c.credit_code, c.customer_type,
select c.customer_id, c.customer_code, c.customer_name, c.customer_code_dw, c.credit_code, c.customer_type, c.area_type,
c.is_enable,
case when su.user_id is not null then cast(su.user_id as char) else null end as sales_user_id,
su.nick_name as sales_user, c.ed, c.zq, c.settlement_user_id, c.settlement_user,
@@ -91,6 +92,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</if>
<if test="creditCode != null and creditCode != ''"> and c.credit_code = #{creditCode}</if>
<if test="customerType != null and customerType != ''"> and c.customer_type = #{customerType}</if>
<if test="areaType != null and areaType != ''"> and c.area_type = #{areaType}</if>
<if test="isEnable != null and isEnable != ''"> and c.is_enable = #{isEnable}</if>
<if test="salesUserId != null and salesUserId != ''"> and c.sales_user_id = #{salesUserId}</if>
<if test="salesUser != null and salesUser != ''"> and su.nick_name = #{salesUser}</if>
@@ -315,6 +317,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="customerCodeDw != null">customer_code_dw,</if>
<if test="creditCode != null">credit_code,</if>
<if test="customerType != null">customer_type,</if>
<if test="areaType != null">area_type,</if>
<if test="isEnable != null">is_enable,</if>
<if test="salesUserId != null">sales_user_id,</if>
<if test="salesUser != null">sales_user,</if>
@@ -347,6 +350,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="customerCodeDw != null">#{customerCodeDw},</if>
<if test="creditCode != null">#{creditCode},</if>
<if test="customerType != null">#{customerType},</if>
<if test="areaType != null">#{areaType},</if>
<if test="isEnable != null">#{isEnable},</if>
<if test="salesUserId != null">#{salesUserId},</if>
<if test="salesUser != null">#{salesUser},</if>
@@ -383,6 +387,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="customerCodeDw != null">customer_code_dw = #{customerCodeDw},</if>
<if test="creditCode != null">credit_code = #{creditCode},</if>
<if test="customerType != null">customer_type = #{customerType},</if>
area_type = #{areaType},
<if test="isEnable != null">is_enable = #{isEnable},</if>
sales_user_id = #{salesUserId},
sales_user = #{salesUser},
@@ -357,6 +357,36 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
limit 1
</select>
<select id="selectMinBandByProductCountry" resultMap="BmsProductCountryResult">
<include refid="selectVo"/>
where c.price_type = 'PRODUCT'
and ifnull(c.is_del, '0') = '0'
and c.product_code = #{productCode}
and c.weight_min is not null
<include refid="catalogBizWindow"/>
<choose>
<when test="countryCode != null and countryCode != ''">
and upper(c.country_code) = upper(#{countryCode})
</when>
<when test="countryName != null and countryName != ''">
and c.country_name = #{countryName}
</when>
<otherwise>
and 1 = 0
</otherwise>
</choose>
<choose>
<when test="zoneCode != null and zoneCode != ''">
and upper(ifnull(c.zone_code,'')) = upper(#{zoneCode})
</when>
<otherwise>
and (c.zone_code is null or c.zone_code = '')
</otherwise>
</choose>
order by v.effective_time desc, c.weight_min asc, c.psd_id asc
limit 1
</select>
<select id="countZonedByProductCountry" resultType="int">
select count(1)
from prc_price_set_detail c
@@ -141,6 +141,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<update id="updatePrcCustomerPriceSet" parameterType="PrcCustomerPriceSet">
update prc_customer_price_set
<set>
<if test="priceSetId != null">price_set_id = #{priceSetId},</if>
<if test="customerId != null">customer_id = #{customerId},</if>
<if test="customerCode != null">customer_code = #{customerCode},</if>
<if test="customerName != null">customer_name = #{customerName},</if>
@@ -490,6 +490,35 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
limit 1
</select>
<select id="selectFixedMinBand" 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
<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
+1
View File
@@ -355,6 +355,7 @@ CREATE TABLE `ctr_customer` (
`customer_code_dw` varchar(50) DEFAULT '' COMMENT '对外代码',
`credit_code` varchar(18) DEFAULT '' COMMENT '统一社会信用代码',
`customer_type` char(1) DEFAULT '0' COMMENT '业务类型(0直客 1同行 2集运)',
`area_type` varchar(32) DEFAULT NULL COMMENT '区域类型(字典 data_area_type)',
`is_enable` char(1) DEFAULT '0' COMMENT '启用状态(0正常 1停用)',
`sales_user_id` varchar(64) DEFAULT NULL COMMENT '业务员系统用户ID(sys_user.user_id)',
`source_sales_code` varchar(255) DEFAULT NULL COMMENT '客户同步源业务员工号(内部字段)',
@@ -0,0 +1,11 @@
-- ctr_customer.area_type:客户区域类型,取值来自字典 data_area_type(可重复执行)
SET @col_exists := (
SELECT COUNT(1) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ctr_customer' AND COLUMN_NAME = 'area_type'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `ctr_customer` ADD COLUMN `area_type` varchar(32) DEFAULT NULL COMMENT ''区域类型(字典 data_area_type)'' AFTER `customer_type`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
+8
View File
@@ -407,6 +407,14 @@ export function addSetCustomer(data) {
})
}
export function batchAddSetCustomer(data) {
return request({
url: '/price/set/customer/batch',
method: 'post',
data: data
})
}
export function updateSetCustomer(data) {
return request({
url: '/price/set/customer',
@@ -62,6 +62,18 @@
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="区域类型" prop="areaType">
<el-select v-model="form.areaType" placeholder="请选择区域类型" clearable style="width: 100%;">
<el-option
v-for="dict in data_area_type"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="启用状态" prop="isEnable">
@@ -727,6 +739,7 @@ const router = useRouter()
const { settlement_type } = useDict('settlement_type')
const { customer_type } = useDict('customer_type')
const { data_area_type } = useDict('data_area_type')
const { ensureProducts, findProduct } = useProducts()
@@ -752,6 +765,7 @@ function buildEmptyForm() {
customerCodeDw: null,
creditCode: null,
customerType: null,
areaType: null,
isEnable: null,
salesUserId: null,
salesUser: null,
@@ -30,6 +30,22 @@
/>
</el-select>
</el-form-item>
<el-form-item label="区域类型" prop="areaType">
<el-select
v-model="queryParams.areaType"
placeholder="请选择区域类型"
clearable
style="width: 160px"
@change="handleQuery"
>
<el-option
v-for="dict in data_area_type"
:key="dict.value"
:label="dict.label"
:value="dict.value"
/>
</el-select>
</el-form-item>
<el-form-item label="业务员" prop="salesUserId">
<user-select
@@ -151,6 +167,11 @@
<dict-tag :options="customer_type" :value="scope.row.customerType" />
</template>
</el-table-column>
<el-table-column v-if="isColVisible('areaType')" label="区域类型" align="center" prop="areaType" width="110">
<template #default="scope">
<dict-tag :options="data_area_type" :value="scope.row.areaType" />
</template>
</el-table-column>
<el-table-column v-if="isColVisible('isEnable')" label="启用状态" align="center" prop="isEnable">
<template #default="scope">
<dict-tag :options="sys_normal_disable" :value="scope.row.isEnable" />
@@ -328,6 +349,7 @@ const columnDefs = [
{ key: 'customerCodeDw', label: '对外代码' },
{ key: 'creditCode', label: '统一社会信用代码' },
{ key: 'customerType', label: '业务类型' },
{ key: 'areaType', label: '区域类型' },
{ key: 'isEnable', label: '启用状态' },
{ key: 'salesUserId', label: '业务员' },
// { key: 'ed', label: '资金额度' },
@@ -356,7 +378,7 @@ const single = ref(true)
const multiple = ref(true)
const total = ref(0)
const { sys_normal_disable, settlement_type, customer_type } = useDict("sys_normal_disable", "settlement_type", "customer_type")
const { sys_normal_disable, settlement_type, customer_type, data_area_type } = useDict("sys_normal_disable", "settlement_type", "customer_type", "data_area_type")
/** 绑定产品代码串 -> 产品名称展示(产品代码+名称;查不到的产品回显原代码) */
const { ensureProducts, getProductLabel } = useProducts()
@@ -390,6 +412,7 @@ const copyColumns = computed(() => [
{ key: 'customerCodeDw', label: '对外代码', get: r => r.customerCodeDw || '' },
{ key: 'creditCode', label: '统一社会信用代码', get: r => r.creditCode || '' },
{ key: 'customerType', label: '业务类型', get: r => proxy.selectDictLabel(customer_type.value, r.customerType) },
{ key: 'areaType', label: '区域类型', get: r => proxy.selectDictLabel(data_area_type.value, r.areaType) },
{ key: 'isEnable', label: '启用状态', get: r => proxy.selectDictLabel(sys_normal_disable.value, r.isEnable) },
{ key: 'salesUserId', label: '业务员', get: r => r.salesUserNickName || '' },
{ key: 'zq', label: '回款账期', get: r => (r.zq === null || r.zq === undefined ? '' : r.zq) },
@@ -422,6 +445,7 @@ const data = reactive({
keywords: undefined,
creditCode: undefined,
customerType: undefined,
areaType: undefined,
isEnable: undefined,
salesUserId: undefined,
salesUser: undefined,
@@ -81,6 +81,7 @@ import {
isDraftVersion,
isPendingVersion,
isEffectVersion,
isExpiredVersion,
isPriceEditableVersion
} from '@/views/price/set/utils/priceSet'
import { checkPermi } from '@/utils/permission'
@@ -152,8 +153,8 @@ const filteredVersions = computed(() => {
function showEditButton(row) {
if (!checkPermi([props.editPerm])) return false
// Align with sales force-edit: draft / pending / effect can edit
return isPriceEditableVersion(row) || isEffectVersion(row)
// draft / pending / effect / expired can edit
return isPriceEditableVersion(row) || isEffectVersion(row) || isExpiredVersion(row)
}
function isRemoving(row) {
@@ -51,7 +51,7 @@ import { listProduct, listProductVersions, activateProductVersion, deleteProduct
const { proxy } = getCurrentInstance()
const alertTitle = "按版本管理产品价(与套餐版本一致)。点击版本可查看产品与明细;待生效(含草稿)可编辑、启动或硬删;已生效可软删(上一版自动恢复生效并清空失效时间)。建价请点「新建产品价格」。"
const alertTitle = "按版本管理产品价(与套餐版本一致)。点击版本可查看产品与明细;待生效(含草稿)可编辑、启动或硬删;已生效、已失效可编辑;已生效可软删(上一版自动恢复生效并清空失效时间)。建价请点「新建产品价格」。"
const verTitle = '产品价版本'
const createLabel = '新建产品价格'
@@ -51,7 +51,7 @@ import { listRedline, listRedlineVersions, activateRedlineVersion, deleteRedline
const { proxy } = getCurrentInstance()
const alertTitle = "按版本管理红线价(与套餐版本一致)。点击版本可查看产品与明细;待生效(含草稿)可编辑、启动或硬删;已生效可软删(上一版自动恢复生效并清空失效时间)。建价请点「新建红线价」。"
const alertTitle = "按版本管理红线价(与套餐版本一致)。点击版本可查看产品与明细;待生效(含草稿)可编辑、启动或硬删;已生效、已失效可编辑;已生效可软删(上一版自动恢复生效并清空失效时间)。建价请点「新建红线价」。"
const verTitle = '红线价版本'
const createLabel = '新建红线价'
@@ -7,22 +7,53 @@
class="ps-dialog"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="96px">
<el-form-item label="客户" prop="customerCode">
<el-form-item :label="isEdit ? '客户' : '客户'" :prop="isEdit ? 'customerCode' : 'customerCodes'">
<customer-select
v-if="isEdit"
v-model="form.customerCode"
value-key="customerCode"
placeholder="请选择客户"
remote-search
option-select
:page-size="20"
placeholder="客户名称 / 编号 / 对外代码"
@change="onCustomerSelectChange"
/>
<customer-select
v-else
v-model="form.customerCodes"
value-key="customerCode"
multiple
remote-search
option-select
:page-size="20"
placeholder="客户名称 / 编号 / 对外代码(可多选)"
@change="onBatchCustomerChange"
/>
</el-form-item>
<el-form-item label="客户信息">
<el-form-item v-if="isEdit" label="客户信息">
<div class="ps-readonly" :class="{ 'ps-readonly--empty': !form.customerCode }">
<span class="ps-readonly__code">{{ form.customerCode || '未选择' }}</span>
<span class="ps-readonly__name">{{ form.customerName || '选择客户后自动带出' }}</span>
</div>
</el-form-item>
<el-form-item v-else label="已选">
<div v-if="!selectedCustomers.length" class="ps-readonly ps-readonly--empty">
<span class="ps-readonly__name">请从上方下拉多选客户</span>
</div>
<div v-else class="batch-tags">
<el-tag
v-for="c in selectedCustomers"
:key="c.customerCode"
size="small"
closable
class="batch-tag"
@close="removeSelected(c.customerCode)"
>{{ c.customerName || c.customerCode }}</el-tag>
</div>
</el-form-item>
<el-form-item label="优先级" prop="bindPriority">
<el-input-number
v-model="form.bindPriority"
@@ -33,32 +64,11 @@
<div class="ps-hint">数字越小优先级越高,1 为最高;同一客户命中多个套餐时按此排序</div>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="生效日期" prop="prBegindate">
<el-date-picker
v-model="form.prBegindate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="失效日期" prop="prEnddate">
<el-date-picker
v-model="form.prEnddate"
type="date"
value-format="YYYY-MM-DD"
placeholder="长期有效"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<div class="ps-hint ps-hint--block">{{ bindRangeHint }}</div>
<el-form-item v-if="!isEdit">
<el-checkbox v-model="form.overwriteExisting">
覆盖已绑定其他套餐的客户(不勾选则跳过)
</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
@@ -70,25 +80,20 @@
<script setup name="SetCustomerDialog">
/**
* 套餐绑定客户 · 新增 / 编辑弹窗
* 由表格组件通过 ref.open(row) 唤起,保存成功后 emit('success') 通知页面刷新。
* 外观统一走 styles/dialog.scss 的 .ps-dialog(见该文件顶部说明)。
* 套餐绑定客户 · 新增(支持批量) / 编辑弹窗
*/
import { createEmptyCustomerRow } from '../../utils/priceSet'
import { addSetCustomer, updateSetCustomer } from '@/api/price/set'
import { batchAddSetCustomer, updateSetCustomer } from '@/api/price/set'
const props = defineProps({
/** 所属套餐ID */
priceSetId: {
type: [Number, String],
default: undefined
},
/** 新行默认生效日期(套餐生效日期) */
defaultBegindate: {
type: String,
default: undefined
},
/** 新行默认失效日期(套餐失效日期) */
defaultEnddate: {
type: String,
default: undefined
@@ -102,64 +107,112 @@ const { proxy } = getCurrentInstance()
const visible = ref(false)
const title = ref('')
const submitting = ref(false)
const isEdit = ref(false)
const formRef = ref()
const form = ref({})
const selectedCustomers = ref([])
const rules = {
customerCode: [{ required: true, message: '请选择客户', trigger: 'change' }],
prBegindate: [{ required: true, message: '生效日期不能为空', trigger: 'change' }]
}
/** 有效期一句话说明,替掉原来两行零散提示 */
const bindRangeHint = computed(() => {
const begin = form.value.prBegindate
const end = form.value.prEnddate
if (!begin) return '生效日期为必填,失效日期留空表示长期有效。'
return end
? `该客户在 ${begin} 至 ${end} 期间的询价将命中本套餐;失效日期留空表示长期有效。`
: `该客户自 ${begin} 起长期命中本套餐;如需限制期限,请填写失效日期。`
const rules = computed(() => {
if (isEdit.value) {
return {
customerCode: [{ required: true, message: '请选择客户', trigger: 'change' }]
}
}
return {
customerCodes: [{
type: 'array',
required: true,
min: 1,
message: '请至少选择一个客户',
trigger: 'change'
}]
}
})
/** 打开弹窗:row 为空表示新增 */
function open(row) {
form.value = row
? { ...row }
: {
...createEmptyCustomerRow({
effectiveDate: props.defaultBegindate,
expireDate: props.defaultEnddate
}),
priceSetId: props.priceSetId
}
title.value = row ? '编辑客户绑定' : '添加客户'
isEdit.value = !!row
selectedCustomers.value = []
if (row) {
form.value = { ...row }
title.value = '编辑客户绑定'
} else {
form.value = {
...createEmptyCustomerRow({
effectiveDate: props.defaultBegindate,
expireDate: props.defaultEnddate
}),
priceSetId: props.priceSetId,
customerCodes: [],
overwriteExisting: false
}
title.value = '添加客户'
}
visible.value = true
nextTick(() => formRef.value && formRef.value.clearValidate())
}
/** 下拉选中客户后回填代码 / 名称 / ID */
function onCustomerSelectChange(code, item) {
form.value.customerCode = code || undefined
form.value.customerName = item ? item.customerName : ''
form.value.customerId = item ? item.customerId : undefined
}
function onBatchCustomerChange(codes, items) {
form.value.customerCodes = Array.isArray(codes) ? codes : []
selectedCustomers.value = Array.isArray(items) ? items.slice() : []
}
function removeSelected(code) {
form.value.customerCodes = (form.value.customerCodes || []).filter(c => String(c) !== String(code))
selectedCustomers.value = selectedCustomers.value.filter(c => String(c.customerCode) !== String(code))
}
async function submit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
const payload = { ...form.value, priceSetId: props.priceSetId }
submitting.value = true
const req = payload.customerPricesetId ? updateSetCustomer(payload) : addSetCustomer(payload)
req.then(() => {
proxy.$modal.msgSuccess('保存成功')
try {
if (isEdit.value) {
const payload = { ...form.value, priceSetId: props.priceSetId }
await updateSetCustomer(payload)
proxy.$modal.msgSuccess('保存成功')
} else {
const customers = selectedCustomers.value.map(c => ({
customerId: c.customerId,
customerCode: c.customerCode,
customerName: c.customerName
}))
const res = await batchAddSetCustomer({
priceSetId: props.priceSetId,
bindPriority: form.value.bindPriority,
prBegindate: form.value.prBegindate,
prEnddate: form.value.prEnddate,
overwriteExisting: !!form.value.overwriteExisting,
customers
})
proxy.$modal.msgSuccess(res.msg || '批量绑定成功')
}
visible.value = false
emit('success')
}).finally(() => {
} finally {
submitting.value = false
})
}
}
defineExpose({ open })
</script>
<!-- 非 scoped:弹窗被 append-to-body 传送到 body,样式需全局生效,命名空间见 styles/dialog.scss -->
<style lang="scss" src="../../styles/dialog.scss"></style>
<style scoped>
.batch-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
width: 100%;
max-height: 140px;
overflow-y: auto;
}
.batch-tag {
max-width: 100%;
}
</style>
@@ -5,6 +5,17 @@
:count="customers.length"
sub="一个客户只能绑定一个套餐"
>
<template #action>
<el-button
v-if="editable"
type="primary"
size="small"
icon="Plus"
@click="dialogRef.open()"
v-hasPermi="['price:set:edit']"
>添加客户</el-button>
</template>
<el-table :data="customers" border class="customer-table" empty-text="暂未绑定客户">
<el-table-column label="客户代码" prop="customerCode" min-width="140" show-overflow-tooltip>
<template #default="{ row }">
@@ -18,18 +29,57 @@
<span v-else class="priority">{{ row.bindPriority }}</span>
</template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="dialogRef.open(row)" v-hasPermi="['price:set:edit']">编辑</el-button>
<el-button link type="danger" @click="remove(row)" v-hasPermi="['price:set:edit']">解绑</el-button>
</template>
</el-table-column>
</el-table>
<set-customer-dialog
ref="dialogRef"
:price-set-id="set.priceSetId"
:default-begindate="set.effectiveDate"
:default-enddate="set.expireDate"
@success="emit('refresh')"
/>
</set-section-card>
</template>
<script setup name="SetCustomerTable">
/**
* 套餐详情 · 绑定客户表格
* 自带新增 / 编辑弹窗与解绑,数据变更后 emit('refresh') 由页面重新拉取详情。
*/
import SetSectionCard from './SetSectionCard.vue'
import SetCustomerDialog from './SetCustomerDialog.vue'
import { isChildEditable } from '../../utils/priceSet'
import { delSetCustomer } from '@/api/price/set'
const props = defineProps({
set: { type: Object, required: true }
/** 套餐详情对象 */
set: {
type: Object,
required: true
}
})
const emit = defineEmits(['refresh'])
const { proxy } = getCurrentInstance()
const dialogRef = ref()
const customers = computed(() => props.set.customers || [])
const editable = computed(() => isChildEditable(props.set.status))
function remove(row) {
proxy.$modal.confirm('确认解绑该客户?').then(() => delSetCustomer(row.customerPricesetId)).then(() => {
proxy.$modal.msgSuccess('已解绑')
emit('refresh')
}).catch(() => {})
}
</script>
<style scoped>
+1 -1
View File
@@ -18,7 +18,7 @@
<set-basic-info-card :set="set" />
<set-customer-table :set="set" />
<set-customer-table :set="set" @refresh="load" />
<set-version-table
v-model="currentPsvId"
@@ -105,6 +105,10 @@ export function isEffectVersion(row) {
return !!row && String(row.status) === VER_STATUS.EFFECT
}
export function isExpiredVersion(row) {
return !!row && String(row.status) === VER_STATUS.EXPIRED
}
/** Draft or pending: may continue editing price details */
export function isPriceEditableVersion(row) {
return isDraftVersion(row) || isPendingVersion(row)