Enhance product import validation by adding checks for duplicate country and weight band entries. Update related error messages in properties file. Introduce new method to delete auto fees while retaining manual entries during rebill operations. Update front-end components to manage build mode changes and ensure proper handling of product lists during price set creation.

This commit is contained in:
wuweihua
2026-10-09 15:51:21 +08:00
parent 94e7a18e0b
commit 853187ad58
11 changed files with 519 additions and 54 deletions
@@ -128,6 +128,8 @@ price.product.import.success.detail2=范围:产品目录 + 重量段 + 运费/
price.product.import.noSheet=未找到产品明细sheet(需与产品名称一致):{0}
price.product.import.noCountry=产品明细sheet无有效国家/重量段数据:{0}
price.product.import.euEmpty=国家表无欧盟成员(is_eu=1),无法展开「欧盟」
price.product.import.dupCountry.item=产品{0}({1})重复国家+重量段:{2}
price.product.import.dupCountry=导入失败:以下产品明细中存在重复的国家+重量段,请合并后重试:{0}
price.product.import.success.unmatched=未匹配国家表(country_code 为空):{0}
price.product.import.countryRequired=请选择目的国家
price.product.import.countryNotFound=国家不存在或已停用:{0}
@@ -47,4 +47,7 @@ public interface PrcIncomeBillFeeMapper
public int deleteByFeeId(String feeId);
public int deleteByBillId(String billId);
/** Rebill: delete auto fees only; keep manual_flag=1 rows. */
public int deleteAutoFeesByBillId(String billId);
}
@@ -309,6 +309,9 @@ public class BmsProductServiceImpl implements IBmsProductService
}
List<BmsRegion> euRegions = loadEuRegions(regionByNameCn);
Map<String, PrcZone> zoneByCountryAndCode = loadZoneByCountryAndCode();
// Pre-check: same product cannot have duplicate country + weight band in Excel
assertImportProductsNoDupCountryWeight(workbook, products, regionByNameCn, euRegions,
zoneByCountryAndCode);
int successNum = 0;
int skipNum = 0;
@@ -460,6 +463,7 @@ public class BmsProductServiceImpl implements IBmsProductService
product.setTaxMode(resolveTaxMode(product.getProductName()));
}
List<BmsProductCountry> countries = stampSelectedCountry(block.rows, region, forcedEff);
assertNoDuplicateCountryWeightRows(product, countries);
int[] nums = persistImportedProduct(product, countries, operName);
transactionManager.commit(tx);
successNum += nums[0];
@@ -597,6 +601,7 @@ public class BmsProductServiceImpl implements IBmsProductService
{
continue;
}
assertNoDuplicateCountryWeightRows(product, rows);
String code = product.getProductCode().trim().toUpperCase(Locale.ROOT);
out.put(code, rows);
}
@@ -632,6 +637,7 @@ public class BmsProductServiceImpl implements IBmsProductService
throw new ServiceException(MessageUtils.message("price.product.import.noCountry",
productSheet.getSheetName()));
}
assertNoDuplicateCountryWeightRows(product, countries);
// UI-selected effective date wins over Excel; previous open product/country rows expire at this date
Date newEff = requireImportEffectiveDate(forcedEffectiveDate != null ? forcedEffectiveDate
: product.getEffectiveDate());
@@ -1988,6 +1994,134 @@ public class BmsProductServiceImpl implements IBmsProductService
}
}
/**
* Workbook pre-check for full import: reject before any write when a product sheet
* has duplicate country + weight band (same zone) rows.
*/
private void assertImportProductsNoDupCountryWeight(Workbook workbook, List<BmsProduct> products,
Map<String, BmsRegion> regionByNameCn, List<BmsRegion> euRegions,
Map<String, PrcZone> zoneByCountryAndCode)
{
if (workbook == null || products == null || products.isEmpty())
{
return;
}
List<String> tips = new ArrayList<String>();
Set<String> unmatchedCountries = new HashSet<String>();
for (BmsProduct product : products)
{
if (product == null || StringUtils.isEmpty(product.getProductCode()))
{
continue;
}
Sheet productSheet = findProductSheet(workbook, product);
if (productSheet == null)
{
continue;
}
List<BmsProductCountry> rows = parseProductSheet(productSheet, product,
regionByNameCn, euRegions, zoneByCountryAndCode, unmatchedCountries);
String tip = buildDuplicateCountryWeightTip(product, rows);
if (StringUtils.isNotEmpty(tip))
{
tips.add(tip);
}
}
if (!tips.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.product.import.dupCountry",
String.join("<br/>", tips)));
}
}
/** Fail when Excel rows for one product contain duplicate country + weight band. */
private void assertNoDuplicateCountryWeightRows(BmsProduct product, List<BmsProductCountry> rows)
{
String tip = buildDuplicateCountryWeightTip(product, rows);
if (StringUtils.isNotEmpty(tip))
{
throw new ServiceException(tip);
}
}
private String buildDuplicateCountryWeightTip(BmsProduct product, List<BmsProductCountry> rows)
{
List<String> dups = findDuplicateCountryWeightLabels(rows);
if (dups.isEmpty())
{
return null;
}
String code = product == null || StringUtils.isEmpty(product.getProductCode())
? "-" : product.getProductCode();
String name = product == null || StringUtils.isEmpty(product.getProductName())
? "-" : product.getProductName();
return MessageUtils.message("price.product.import.dupCountry.item",
code, name, String.join("\u3001", dups));
}
private List<String> findDuplicateCountryWeightLabels(List<BmsProductCountry> rows)
{
List<String> dups = new ArrayList<String>();
if (rows == null || rows.isEmpty())
{
return dups;
}
Map<String, Integer> counts = new LinkedHashMap<String, Integer>();
Map<String, String> labels = new LinkedHashMap<String, String>();
for (BmsProductCountry c : rows)
{
if (c == null)
{
continue;
}
String key = countryWeightDupKey(c);
Integer n = counts.get(key);
counts.put(key, n == null ? 1 : n.intValue() + 1);
if (!labels.containsKey(key))
{
labels.put(key, formatCountryWeightTip(c));
}
}
for (Map.Entry<String, Integer> e : counts.entrySet())
{
if (e.getValue() != null && e.getValue().intValue() > 1)
{
dups.add(labels.get(e.getKey()));
}
}
return dups;
}
/** Align with uk_product_country_zone_weight_eff (country + zone + weight min/max). */
private String countryWeightDupKey(BmsProductCountry c)
{
String country = StringUtils.isNotEmpty(c.getCountryCode())
? c.getCountryCode().trim().toUpperCase(Locale.ROOT)
: (c.getCountryName() == null ? "" : c.getCountryName().trim());
String zone = c.getZoneCode() == null ? "" : c.getZoneCode().trim().toUpperCase(Locale.ROOT);
return country + "|" + zone + "|" + decimalKey(c.getWeightMin()) + "|" + decimalKey(c.getWeightMax());
}
private String formatCountryWeightTip(BmsProductCountry c)
{
String name = StringUtils.isNotEmpty(c.getCountryName()) ? c.getCountryName()
: (StringUtils.isNotEmpty(c.getCountryCode()) ? c.getCountryCode() : "-");
if (StringUtils.isNotEmpty(c.getZoneCode()))
{
name = name + "(" + c.getZoneCode() + ")";
}
return name + " " + decimalKey(c.getWeightMin()) + "~" + decimalKey(c.getWeightMax());
}
private String decimalKey(BigDecimal v)
{
if (v == null)
{
return "";
}
return v.stripTrailingZeros().toPlainString();
}
/** Stamp product + all country detail rows with the same effective date. */
private void applyForcedEffectiveDate(BmsProduct product, List<BmsProductCountry> countries, Date newEff)
{
@@ -8,8 +8,10 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.Arrays;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
@@ -1115,18 +1117,39 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
else
{
orderBillMapper.updatePrcOrderBill(bill);
incomeBillFeeMapper.deleteByBillId(bill.getBillId());
// Keep manually added fees (manual_flag=1) across rebill
incomeBillFeeMapper.deleteAutoFeesByBillId(bill.getBillId());
// Payable not in scope yet: skip prc_pay_bill_fee cleanup/write.
// payBillFeeMapper.deleteByBillId(bill.getBillId());
}
PrcIncomeBill incomeBill = persistIncomeBill(bill, result);
List<PrcIncomeBillFee> fees = toFeeRows(bill.getBillId(),
incomeBill == null ? null : incomeBill.getIncomeBillId(), result);
if (existed != null)
{
fees = filterAutoFeesAgainstManual(bill.getBillId(), fees);
}
if (!fees.isEmpty())
{
incomeBillFeeMapper.insertBatch(fees);
}
orderBillMapper.refreshArBillStatusByBillIds(Arrays.asList(bill.getBillId()));
if (existed != null)
{
// Quote totals were written above; recalc so retained manual fees join income/credit
TmsPriceQuoteResult refreshed = refreshBillIncomeTotals(bill);
if (refreshed != null)
{
result.setSurchargeAmount(refreshed.getSurchargeAmount());
result.setIncomeTotal(refreshed.getIncomeTotal());
result.setPayableTotal(refreshed.getPayableTotal());
result.setCreditArrearsStatus(refreshed.getCreditArrearsStatus());
result.setFeeDetails(refreshed.getFeeDetails());
}
}
else
{
orderBillMapper.refreshArBillStatusByBillIds(Arrays.asList(bill.getBillId()));
}
// Payable not in scope yet: do not write prc_pay_bill / prc_pay_bill_fee.
// PrcPayBill payBill = persistPayBill(bill, result);
// List<PrcPayBillFee> payFees = toPayFeeRows(bill.getBillId(),
@@ -1141,6 +1164,53 @@ public class PrcOrderBillServiceImpl implements IPrcOrderBillService
billSearchStore.save(EsLogType.BILL, EsBizLogSupport.billBizId(request), requestJson, toResponseJson(result));
}
/**
* Drop auto fee lines that collide with retained manual rows on uk_bill_fee_item
* (bill_id, fee_type, sc_id). Manual fees win on rebill.
*/
private List<PrcIncomeBillFee> filterAutoFeesAgainstManual(String billId, List<PrcIncomeBillFee> autoFees)
{
if (autoFees == null || autoFees.isEmpty() || StringUtils.isEmpty(billId))
{
return autoFees == null ? new ArrayList<PrcIncomeBillFee>() : autoFees;
}
List<PrcIncomeBillFee> existing = incomeBillFeeMapper.selectByBillId(billId);
if (existing == null || existing.isEmpty())
{
return autoFees;
}
Set<String> manualKeys = new HashSet<String>();
for (PrcIncomeBillFee row : existing)
{
if (row != null && "1".equals(row.getManualFlag()))
{
manualKeys.add(feeUniqueKey(row.getFeeType(), row.getScId()));
}
}
if (manualKeys.isEmpty())
{
return autoFees;
}
List<PrcIncomeBillFee> out = new ArrayList<PrcIncomeBillFee>();
for (PrcIncomeBillFee fee : autoFees)
{
if (fee == null)
{
continue;
}
if (!manualKeys.contains(feeUniqueKey(fee.getFeeType(), fee.getScId())))
{
out.add(fee);
}
}
return out;
}
private String feeUniqueKey(String feeType, Long scId)
{
return StringUtils.nvl(feeType, "") + "|" + (scId == null ? "0" : String.valueOf(scId));
}
/**
* Occupy/release credit by income total delta (bizType=ORDER).
* Missing/disabled customer: skip occupy, return NORMAL.
@@ -2733,6 +2733,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
{
version.setExpireTime(db.getExpireTime());
}
// Draft + build mode changed: wipe old product/detail/surcharge before mode switch
clearDraftVersionChildrenIfBuildModeChanged(db, version);
versionMapper.updatePrcPriceSetVersion(version);
}
set.setVersion(version);
@@ -2749,6 +2751,36 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
refreshVersionStatus(set.getPriceSetId());
}
/**
* Draft only: when priceBuildMode changes (PULL/IMPORT), delete this version's
* product / detail / surcharge rows. Same mode or non-draft: no-op.
*/
private void clearDraftVersionChildrenIfBuildModeChanged(PrcPriceSetVersion db, PrcPriceSetVersion incoming)
{
if (db == null || incoming == null || db.getPsvId() == null)
{
return;
}
if (!VER_DRAFT.equals(db.getStatus()))
{
return;
}
String newMode = StringUtils.trim(incoming.getPriceBuildMode());
if (StringUtils.isEmpty(newMode))
{
return;
}
String oldMode = StringUtils.trim(db.getPriceBuildMode());
if (newMode.equalsIgnoreCase(StringUtils.nvl(oldMode, "")))
{
return;
}
Long psvId = db.getPsvId();
versionSurchargeMapper.deleteByPsvId(psvId);
detailMapper.deletePrcPriceSetDetailByPsvId(psvId);
setProductMapper.deleteByPsvId(psvId);
}
/** Prefer same effective_time, else draft, else first version of the set. */
private PrcPriceSetVersion findReusableVersion(Long priceSetId, Date effectiveTime)
{
@@ -180,4 +180,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<delete id="deleteByBillId" parameterType="String">
delete from prc_income_bill_fee where bill_id = #{billId}
</delete>
<delete id="deleteAutoFeesByBillId" parameterType="String">
delete from prc_income_bill_fee
where bill_id = #{billId}
and (manual_flag is null or manual_flag &lt;&gt; '1')
</delete>
</mapper>
@@ -0,0 +1,37 @@
-- ------------------------------------------------------------
-- 导入价表 Excel 附件记录(仅建表/字段,待确认后再接代码)
-- 用途:产品价/红线价/销售价 Excel 导入时落库文件元数据
-- 关联:product.pef_id、prc_price_set_version.pef_id -> prc_price_excel_file.pef_id
-- ------------------------------------------------------------
-- 1) 主表:导入价表 Excel 附件
CREATE TABLE IF NOT EXISTS prc_price_excel_file (
pef_id bigint(20) NOT NULL AUTO_INCREMENT COMMENT '1 主键',
excel_type varchar(20) NOT NULL COMMENT '2 价表类型 PRODUCT=产品价 REDLINE=红线价 SALES=销售价',
effective_time datetime NOT NULL COMMENT '3 生效时间(导入时选择/解析)',
file_name varchar(255) NOT NULL COMMENT '4 文件原始名称',
file_path varchar(500) NOT NULL COMMENT '5 文件存储路径(相对路径或完整URL)',
file_size bigint(20) DEFAULT 0 COMMENT '6 文件大小(字节)',
content_type varchar(100) DEFAULT '' COMMENT '7 MIME类型,如 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
file_extension varchar(20) DEFAULT '' COMMENT '8 扩展名,如 xlsx/xls',
oss_id varchar(64) DEFAULT NULL COMMENT '9 OSS/对象存储ID(可空)',
sheet_names varchar(1000) DEFAULT NULL COMMENT '10 导入涉及的 sheet 名(多个逗号分隔,可空)',
remark varchar(500) DEFAULT NULL COMMENT '11 备注',
created_by varchar(64) DEFAULT '' COMMENT '12 创建人',
create_time datetime DEFAULT NULL COMMENT '13 创建时间',
updated_by varchar(64) DEFAULT '' COMMENT '14 更新人',
update_time datetime DEFAULT NULL COMMENT '15 更新时间',
PRIMARY KEY (pef_id),
KEY idx_pef_type_eff (excel_type, effective_time),
KEY idx_pef_create_time (create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导入价表Excel附件';
-- 2) 产品主表:关联导入附件
ALTER TABLE product
ADD COLUMN pef_id bigint(20) DEFAULT NULL COMMENT '关联导入价表Excel附件 prc_price_excel_file.pef_id' AFTER source_sheet,
ADD KEY idx_product_pef_id (pef_id);
-- 3) 套餐版本表:关联导入附件(销售价 Excel 建价)
ALTER TABLE prc_price_set_version
ADD COLUMN pef_id bigint(20) DEFAULT NULL COMMENT '关联导入价表Excel附件 prc_price_excel_file.pef_id' AFTER price_build_mode,
ADD KEY idx_psv_pef_id (pef_id);