提交价格功能绑定

This commit is contained in:
wuweihua
2026-09-07 10:41:21 +08:00
parent 7204497a65
commit 10352fca6d
12 changed files with 578 additions and 105 deletions
@@ -24,6 +24,10 @@ public class BmsProductCountry implements Serializable
private BigDecimal weightMin;
/** 重量段最大值 KG */
private BigDecimal weightMax;
/** 运费 RMB/KG */
private BigDecimal freightPrice;
/** 挂号费 RMB/票 */
private BigDecimal registrationFee;
private String sizeLimit;
private String lastMile;
private String leadTime;
@@ -46,6 +50,10 @@ public class BmsProductCountry implements Serializable
public void setWeightMin(BigDecimal weightMin) { this.weightMin = weightMin; }
public BigDecimal getWeightMax() { return weightMax; }
public void setWeightMax(BigDecimal weightMax) { this.weightMax = weightMax; }
public BigDecimal getFreightPrice() { return freightPrice; }
public void setFreightPrice(BigDecimal freightPrice) { this.freightPrice = freightPrice; }
public BigDecimal getRegistrationFee() { return registrationFee; }
public void setRegistrationFee(BigDecimal registrationFee) { this.registrationFee = registrationFee; }
public String getSizeLimit() { return sizeLimit; }
public void setSizeLimit(String sizeLimit) { this.sizeLimit = sizeLimit; }
public String getLastMile() { return lastMile; }
@@ -69,6 +77,8 @@ public class BmsProductCountry implements Serializable
.append("countryCode", getCountryCode())
.append("weightMin", getWeightMin())
.append("weightMax", getWeightMax())
.append("freightPrice", getFreightPrice())
.append("registrationFee", getRegistrationFee())
.toString();
}
}
@@ -25,7 +25,7 @@ public interface IBmsProductService
public List<BmsProductCountry> selectCountryListByProductCode(String productCode);
/**
* 导入威狮B2C小包价表:产品目录 + 各产品 sheet 国家服务属性(不含运费/挂号费)
* 导入威狮B2C小包价表:产品目录 + 各产品 sheet 国家×重量段(含运费/挂号费/尺寸/尾程/时效)
*
* @param file 上传的 xlsx/xls
* @param updateSupport 是否更新已存在产品
@@ -198,6 +198,14 @@ public class BmsProductServiceImpl implements IBmsProductService
updateNum++;
countryNum += countries.size();
}
else if (!countries.isEmpty())
{
// Product already exists and "update" unchecked: still refresh country/fee rows from Excel.
countryMapper.deleteByProductCode(product.getProductCode());
saveCountries(product.getProductCode(), countries);
updateNum++;
countryNum += countries.size();
}
else
{
skipNum++;
@@ -216,7 +224,7 @@ public class BmsProductServiceImpl implements IBmsProductService
}
String msg = "\u5bfc\u5165\u6210\u529f\uff1a\u65b0\u589e " + successNum + " \u4e2a\u4ea7\u54c1\uff0c\u66f4\u65b0 " + updateNum + " \u4e2a\uff0c\u8df3\u8fc7 "
+ skipNum + " \u4e2a\uff1b\u5199\u5165\u56fd\u5bb6/\u91cd\u91cf\u6bb5\u660e\u7ec6 " + countryNum + " \u884c\u3002"
+ "<br/>\u8303\u56f4\uff1a\u4ea7\u54c1\u76ee\u5f55 + \u91cd\u91cf\u6bb5min/max + \u5c3a\u5bf8/\u5c3e\u7a0b/\u65f6\u6548\uff1b\u56fd\u5bb6\u7801\u6765\u81ea bms_region.country_name_cn / country_code_2\u3002";
+ "<br/>\u8303\u56f4\uff1a\u4ea7\u54c1\u76ee\u5f55 + \u91cd\u91cf\u6bb5 + \u8fd0\u8d39/\u6302\u53f7\u8d39 + \u5c3a\u5bf8/\u5c3e\u7a0b/\u65f6\u6548\uff1b\u56fd\u5bb6\u7801\u6765\u81ea bms_region\u3002";
if (!unmatchedCountries.isEmpty())
{
msg += "<br/>\u672a\u5339\u914d\u56fd\u5bb6\u8868\uff08country_code \u4e3a\u7a7a\uff09\uff1a" + String.join("\u3001", unmatchedCountries);
@@ -230,7 +238,20 @@ public class BmsProductServiceImpl implements IBmsProductService
for (BmsProductCountry c : countries)
{
c.setProductCode(productCode);
countryMapper.insertBmsProductCountry(c);
try
{
countryMapper.insertBmsProductCountry(c);
}
catch (Exception e)
{
String tip = e.getMessage() == null ? "" : e.getMessage();
if (tip.contains("freight_price") || tip.contains("registration_fee") || tip.contains("Unknown column"))
{
throw new ServiceException(
"\u8bf7\u5148\u6267\u884c SQL\uff1abms-back/sql/price/product_country_fee_alter.sql\uff08\u7f3a\u5c11\u8fd0\u8d39/\u6302\u53f7\u8d39\u5b57\u6bb5\uff09");
}
throw e;
}
}
}
@@ -308,24 +329,60 @@ public class BmsProductServiceImpl implements IBmsProductService
Date sheetEffective = parseSheetEffectiveDate(sheet);
List<BmsProductCountry> list = new ArrayList<>();
// Product sheets also often start at column B.
int[] header = findHeaderCell(sheet, "\u56fd\u5bb6");
int baseCol = header != null ? header[1] : 0;
int startRow = header != null ? header[0] + 1 : 0;
int[] countryHeader = findHeaderCell(sheet, "\u56fd\u5bb6");
if (countryHeader == null)
{
return list;
}
int headerRowIdx = countryHeader[0];
Row headerRow = sheet.getRow(headerRowIdx);
int colCountry = countryHeader[1];
int colWeight = findColByHeader(headerRow, "\u91cd\u91cf", colCountry + 1);
int colFreight = findColByHeader(headerRow, "\u8fd0\u8d39", colCountry + 1);
int colRegister = findColByHeader(headerRow, "\u6302\u53f7", colCountry + 1);
int colSize = findColByHeader(headerRow, "\u5c3a\u5bf8", colCountry + 1);
int colLastMile = findColByHeader(headerRow, "\u5c3e\u7a0b", colCountry + 1);
int colLeadTime = findColByHeader(headerRow, "\u65f6\u6548", colCountry + 1);
// Fallback offsets when header text missing (Weishi layout: B=country ... H=lead time)
if (colWeight < 0)
{
colWeight = colCountry + 1;
}
if (colFreight < 0)
{
colFreight = colCountry + 2;
}
if (colRegister < 0)
{
colRegister = colCountry + 3;
}
if (colSize < 0)
{
colSize = colCountry + 4;
}
if (colLastMile < 0)
{
colLastMile = colCountry + 5;
}
if (colLeadTime < 0)
{
colLeadTime = colCountry + 6;
}
String currentCountry = null;
String currentSize = null;
String currentLastMile = null;
String currentLeadTime = null;
int lastRow = sheet.getLastRowNum();
for (int i = startRow; i <= lastRow; i++)
for (int i = headerRowIdx + 1; i <= lastRow; i++)
{
Row row = sheet.getRow(i);
if (row == null)
{
continue;
}
String countryCell = cellString(row.getCell(baseCol));
String weightLabel = cellString(row.getCell(baseCol + 1));
String countryCell = cellString(row.getCell(colCountry));
String weightLabel = cellString(row.getCell(colWeight));
if ("\u56fd\u5bb6".equals(countryCell) || weightLabel.contains("\u91cd\u91cf\u6bb5"))
{
continue;
@@ -333,9 +390,9 @@ public class BmsProductServiceImpl implements IBmsProductService
if (StringUtils.isNotEmpty(countryCell))
{
currentCountry = countryCell.trim();
currentSize = emptyToNull(cellString(row.getCell(baseCol + 4)));
currentLastMile = emptyToNull(cellString(row.getCell(baseCol + 5)));
currentLeadTime = emptyToNull(cellString(row.getCell(baseCol + 6)));
currentSize = emptyToNull(cellString(row.getCell(colSize)));
currentLastMile = emptyToNull(cellString(row.getCell(colLastMile)));
currentLeadTime = emptyToNull(cellString(row.getCell(colLeadTime)));
}
if (StringUtils.isEmpty(currentCountry))
{
@@ -361,6 +418,8 @@ public class BmsProductServiceImpl implements IBmsProductService
}
c.setWeightMin(range[0]);
c.setWeightMax(range[1]);
c.setFreightPrice(cellDecimal(row.getCell(colFreight)));
c.setRegistrationFee(cellDecimal(row.getCell(colRegister)));
c.setSizeLimit(currentSize);
c.setLastMile(currentLastMile);
c.setLeadTime(currentLeadTime);
@@ -371,7 +430,7 @@ public class BmsProductServiceImpl implements IBmsProductService
}
/**
* ???? bms_region??country_name_cn -> ????? country_code_2??
* Load bms_region map: country_name_cn -> region (for country_code_2).
*/
private Map<String, BmsRegion> loadRegionByNameCn()
{
@@ -395,7 +454,7 @@ public class BmsProductServiceImpl implements IBmsProductService
}
/**
* ????? country_name_cn ?????????????????????????? ??? / ?????????
* Exact country_name_cn match first; single contains match as fallback (e.g. Czech / Czech Republic).
*/
private BmsRegion resolveRegion(String excelName, Map<String, BmsRegion> regionByNameCn)
{
@@ -507,6 +566,28 @@ public class BmsProductServiceImpl implements IBmsProductService
return "TAX_INCLUDED";
}
/**
* Find column index on header row whose text contains keyword; -1 if not found.
*/
private static int findColByHeader(Row headerRow, String keyword, int fromCol)
{
if (headerRow == null || StringUtils.isEmpty(keyword))
{
return -1;
}
int last = Math.max(headerRow.getLastCellNum(), fromCol + 12);
last = Math.min(last, 30);
for (int c = Math.max(fromCol, 0); c < last; c++)
{
String v = cellString(headerRow.getCell(c));
if (StringUtils.isNotEmpty(v) && v.contains(keyword))
{
return c;
}
}
return -1;
}
private static String emptyToNull(String s)
{
return StringUtils.isEmpty(s) ? null : s;
@@ -595,6 +676,57 @@ public class BmsProductServiceImpl implements IBmsProductService
}
}
private static BigDecimal cellDecimal(Cell cell)
{
if (cell == null)
{
return null;
}
CellType type = cell.getCellType();
if (type == CellType.FORMULA)
{
type = cell.getCachedFormulaResultType();
}
try
{
if (type == CellType.NUMERIC)
{
return BigDecimal.valueOf(cell.getNumericCellValue());
}
String s = cellString(cell);
if (StringUtils.isEmpty(s))
{
return null;
}
// strip currency / unit suffixes: 56、56元、56 RMB
s = s.replace(",", "").replace("\uffe5", "").replace("\u5143", "")
.replace("RMB", "").replace("rmb", "").trim();
if (s.isEmpty())
{
return null;
}
int cut = -1;
for (int i = 0; i < s.length(); i++)
{
char ch = s.charAt(i);
if (!(ch == '.' || ch == '-' || (ch >= '0' && ch <= '9')))
{
cut = i;
break;
}
}
if (cut > 0)
{
s = s.substring(0, cut).trim();
}
return new BigDecimal(s);
}
catch (Exception ignored)
{
return null;
}
}
private static Date cellDate(Cell cell)
{
if (cell == null)
@@ -332,7 +332,8 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
int incomplete = 0;
for (PrcPriceSetDetail d : details)
{
if (StringUtils.isEmpty(d.getProductCode()) || StringUtils.isEmpty(d.getCountryCode())
boolean noCountry = StringUtils.isEmpty(d.getCountryCode()) && StringUtils.isEmpty(d.getCountryName());
if (StringUtils.isEmpty(d.getProductCode()) || noCountry
|| (d.getWsdId() == null && StringUtils.isEmpty(d.getWeightLabel()))
|| d.getFvValue() == null || d.getFvRegistrationFee() == null)
{
@@ -341,15 +342,16 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
if (details.isEmpty())
{
items.add(new PrcPriceSetCheckItem("FIXED 明细完整性", "FAIL", "固定价模式至少需要 1 条明细"));
items.add(new PrcPriceSetCheckItem("FIXED 明细完整性", "PASS",
"\u65e0\u5957\u9910\u660e\u7ec6\uff1a\u8ba1\u8d39\u53d6 bms_product / bms_product_country \u9ed8\u8ba4\u8fd0\u8d39\u4e0e\u6302\u53f7\u8d39"));
}
else if (incomplete > 0)
{
items.add(new PrcPriceSetCheckItem("FIXED 明细完整性", "FAIL", incomplete + " 条明细缺少产品/国家/重量段/运费/挂号费"));
items.add(new PrcPriceSetCheckItem("FIXED 明细完整性", "FAIL", incomplete + " \u6761\u660e\u7ec6\u7f3a\u5c11\u4ea7\u54c1/\u56fd\u5bb6/\u91cd\u91cf\u6bb5/\u8fd0\u8d39/\u6302\u53f7\u8d39"));
}
else
{
items.add(new PrcPriceSetCheckItem("FIXED 明细完整性", "PASS", details.size() + " 条明细,运费与挂号费均填写"));
items.add(new PrcPriceSetCheckItem("FIXED 明细完整性", "PASS", details.size() + " \u6761\u8986\u76d6\u660e\u7ec6\uff08\u672a\u8986\u76d6\u7684\u4ea7\u54c1\u56fd\u5bb6\u91cd\u91cf\u6bb5\u4ecd\u8d70\u9ed8\u8ba4\u4ef7\uff09"));
}
}
else
@@ -418,11 +420,15 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
if (TYPE_FIXED.equals(setType) && !details.isEmpty())
{
items.add(new PrcPriceSetCheckItem("运费单价 vs 默认等级价偏差", "INFO", "默认等级价对照将在计费试算上线后自动计算"));
items.add(new PrcPriceSetCheckItem("\u8fd0\u8d39\u5355\u4ef7 vs \u9ed8\u8ba4\u7b49\u7ea7\u4ef7\u504f\u5dee", "INFO", "\u6709\u8986\u76d6\u660e\u7ec6\u65f6\u4f18\u5148\u53d6\u5957\u9910\u56fa\u5b9a\u4ef7\uff1b\u672a\u547d\u4e2d\u5219\u56de\u9000 bms_product_country"));
}
else if (TYPE_FIXED.equals(setType))
{
items.add(new PrcPriceSetCheckItem("\u8fd0\u8d39\u5355\u4ef7 vs \u9ed8\u8ba4\u7b49\u7ea7\u4ef7\u504f\u5dee", "INFO", "\u65e0\u5957\u9910\u660e\u7ec6\uff0c\u5168\u90e8\u8d70 bms_product_country \u9ed8\u8ba4\u8fd0\u8d39/\u6302\u53f7\u8d39"));
}
else if (TYPE_DISCOUNT.equals(setType))
{
items.add(new PrcPriceSetCheckItem("运费单价 vs 默认等级价偏差", "INFO", "折扣模式按下单时默认等级价 × 折扣计费"));
items.add(new PrcPriceSetCheckItem("\u8fd0\u8d39\u5355\u4ef7 vs \u9ed8\u8ba4\u7b49\u7ea7\u4ef7\u504f\u5dee", "INFO", "\u6298\u6263\u6a21\u5f0f\u6309\u4e0b\u5355\u65f6\u9ed8\u8ba4\u7b49\u7ea7\u4ef7 \u00d7 \u6298\u6263\u8ba1\u8d39"));
}
return items;
@@ -599,9 +605,9 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
if (TYPE_FIXED.equals(set.getSetType()))
{
if (StringUtils.isEmpty(detail.getCountryCode()))
if (StringUtils.isEmpty(detail.getCountryCode()) && StringUtils.isEmpty(detail.getCountryName()))
{
throw new ServiceException("固定价明细必须填写国家");
throw new ServiceException("\u56fa\u5b9a\u4ef7\u660e\u7ec6\u5fc5\u987b\u586b\u5199\u56fd\u5bb6");
}
if (detail.getWsdId() == null && StringUtils.isEmpty(detail.getWeightLabel()))
{
@@ -5,23 +5,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<mapper namespace="com.ruoyi.price.mapper.BmsProductCountryMapper">
<resultMap type="BmsProductCountry" id="BmsProductCountryResult">
<id property="detailId" column="detail_id" />
<result property="productCode" column="product_code" />
<result property="countryName" column="country_name" />
<result property="countryCode" column="country_code" />
<result property="weightMin" column="weight_min" />
<result property="weightMax" column="weight_max" />
<result property="sizeLimit" column="size_limit" />
<result property="lastMile" column="last_mile" />
<result property="leadTime" column="lead_time" />
<result property="effectiveDate" column="effective_date" />
<result property="createdAt" column="created_at" />
<result property="updatedAt" column="updated_at" />
<id property="detailId" column="detail_id" />
<result property="productCode" column="product_code" />
<result property="countryName" column="country_name" />
<result property="countryCode" column="country_code" />
<result property="weightMin" column="weight_min" />
<result property="weightMax" column="weight_max" />
<result property="freightPrice" column="freight_price" />
<result property="registrationFee" column="registration_fee" />
<result property="sizeLimit" column="size_limit" />
<result property="lastMile" column="last_mile" />
<result property="leadTime" column="lead_time" />
<result property="effectiveDate" column="effective_date" />
<result property="createdAt" column="created_at" />
<result property="updatedAt" column="updated_at" />
</resultMap>
<select id="selectBmsProductCountryList" parameterType="BmsProductCountry" resultMap="BmsProductCountryResult">
select detail_id, product_code, country_name, country_code, weight_min, weight_max,
size_limit, last_mile, lead_time, effective_date, created_at, updated_at
freight_price, registration_fee, size_limit, last_mile, lead_time,
effective_date, created_at, updated_at
from bms_product_country
<where>
<if test="productCode != null and productCode != ''">
@@ -46,10 +49,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<insert id="insertBmsProductCountry" parameterType="BmsProductCountry" useGeneratedKeys="true" keyProperty="detailId">
insert into bms_product_country (
product_code, country_name, country_code, weight_min, weight_max,
size_limit, last_mile, lead_time, effective_date, created_at
freight_price, registration_fee, size_limit, last_mile, lead_time,
effective_date, created_at
) values (
#{productCode}, #{countryName}, #{countryCode}, #{weightMin}, #{weightMax},
#{sizeLimit}, #{lastMile}, #{leadTime}, #{effectiveDate}, sysdate()
#{freightPrice}, #{registrationFee}, #{sizeLimit}, #{lastMile}, #{leadTime},
#{effectiveDate}, sysdate()
)
</insert>
+5 -3
View File
@@ -1,6 +1,6 @@
-- 产品主数据(来源:威狮B2C小包价表.xlsx)
-- 导入范围:产品目录 + 各产品 sheet 国家服务明细(尺寸/尾程/时效)
-- 不导入:运费/挂号费矩阵、退件重派、申报说明、赔偿标准
-- 导入范围:产品目录 + 各产品 sheet 国家×重量段(运费/挂号费/尺寸/尾程/时效)
-- 不导入:退件重派、申报说明、赔偿标准
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS bms_product (
@@ -33,6 +33,8 @@ CREATE TABLE IF NOT EXISTS bms_product_country (
country_code char(2) DEFAULT NULL COMMENT 'ISO2,可空待补齐',
weight_min decimal(10,3) DEFAULT NULL COMMENT '重量段最小值KG(含)',
weight_max decimal(10,3) DEFAULT NULL COMMENT '重量段最大值KG(含)',
freight_price decimal(10,4) DEFAULT NULL COMMENT '运费 RMB/KG',
registration_fee decimal(10,2) DEFAULT NULL COMMENT '挂号费 RMB/票',
size_limit varchar(1000) DEFAULT NULL COMMENT '尺寸限制',
last_mile varchar(500) DEFAULT NULL COMMENT '尾程派送说明',
lead_time varchar(100) DEFAULT NULL COMMENT '参考时效',
@@ -44,7 +46,7 @@ CREATE TABLE IF NOT EXISTS bms_product_country (
KEY idx_country_code (country_code),
KEY idx_weight_match (product_code, country_code, weight_min, weight_max),
CONSTRAINT fk_product_country_code FOREIGN KEY (product_code) REFERENCES bms_product (product_code) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产品国家服务明细-重量段+尺寸/尾程/时效(不含运费)';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产品国家明细-重量段+运费挂号费+尺寸/尾程/时效';
-- 已建表升级(幂等:列已存在时跳过报错可忽略)
-- ALTER TABLE bms_product_country ADD COLUMN weight_min decimal(10,3) DEFAULT NULL COMMENT '重量段最小值KG(含)' AFTER country_code;
@@ -0,0 +1,7 @@
-- 产品国家明细增加运费/挂号费(与 Excel 重量段同行)
-- 执行后请在「产品列表」重新导入威狮B2C小包价表.xlsx
SET NAMES utf8mb4;
ALTER TABLE bms_product_country
ADD COLUMN freight_price decimal(10,4) DEFAULT NULL COMMENT '运费 RMB/KG' AFTER weight_max,
ADD COLUMN registration_fee decimal(10,2) DEFAULT NULL COMMENT '挂号费 RMB/票' AFTER freight_price;
@@ -0,0 +1,6 @@
-- 若之前 ALTER 将 COMMENT 写成乱码,执行此脚本修复
SET NAMES utf8mb4;
ALTER TABLE bms_product_country
MODIFY COLUMN freight_price decimal(10,4) DEFAULT NULL COMMENT '运费 RMB/KG',
MODIFY COLUMN registration_fee decimal(10,2) DEFAULT NULL COMMENT '挂号费 RMB/票';
@@ -57,6 +57,11 @@ const props = defineProps({
updateSupportLabel: {
type: String,
default: '是否更新已经存在的数据'
},
// 打开时是否默认勾选“更新已存在”
defaultUpdateSupport: {
type: Boolean,
default: false
}
})
@@ -77,7 +82,7 @@ const templateUrl = computed(() => !!props.templateAction)
// 打开对话框(供父组件通过 ref 调用)
function open() {
updateSupport.value = false
updateSupport.value = props.defaultUpdateSupport
isUploading.value = false
visible.value = true
nextTick(() => {
@@ -5,7 +5,7 @@
:closable="false"
show-icon
class="mb16"
title="Excel 导入:上传威狮B2C小包价表.xlsx。仅入库产品目录 + 各产品 sheet 的国家尺寸/尾程/时效;不导入运费、挂号费、退件重派、申报说明、赔偿标准。"
title="Excel 导入:上传威狮B2C小包价表.xlsx。入库产品目录 + 各产品 sheet 的国家×重量段(运费/挂号费/尺寸/尾程/时效);不导入退件重派、申报说明、赔偿标准。"
/>
<basic-table
@@ -47,7 +47,7 @@
</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="openCountries(row)" v-hasPermi="['price:product:query']">国家明细</el-button>
<el-button link type="primary" icon="View" @click="openCountries(row)" v-hasPermi="['price:product:query']">产品明细</el-button>
<el-button link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['price:product:edit']">编辑</el-button>
<el-button link type="primary" icon="Delete" @click="handleDelete(row.productId)" v-hasPermi="['price:product:remove']">删除</el-button>
</template>
@@ -122,7 +122,7 @@
</template>
</el-dialog>
<el-drawer v-model="countryOpen" size="720px" :title="countryTitle" append-to-body>
<el-drawer v-model="countryOpen" size="920px" :title="countryTitle" append-to-body>
<el-table v-loading="countryLoading" :data="countryList" border empty-text="暂无国家明细">
<el-table-column label="国家" prop="countryName" width="90" show-overflow-tooltip />
<el-table-column label="ISO2" prop="countryCode" width="60" align="center" />
@@ -132,6 +132,8 @@
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="运费(RMB/KG)" prop="freightPrice" width="110" align="right" />
<el-table-column label="挂号费(RMB/票)" prop="registrationFee" width="120" align="right" />
<el-table-column label="尺寸限制" prop="sizeLimit" min-width="140">
<template #default="{ row }">
<span class="pre-wrap">{{ row.sizeLimit }}</span>
@@ -150,7 +152,8 @@
ref="importRef"
title="产品 Excel 导入"
action="/price/product/importData"
update-support-label="是否更新已经存在的产品(含国家明细覆盖)"
:default-update-support="true"
update-support-label="是否更新已经存在的产品主数据(国家/运费明细每次导入都会覆盖)"
@success="() => tableRef.refresh()"
/>
</div>
+197 -45
View File
@@ -23,7 +23,7 @@
@click="form.setType = 'FIXED'"
>
<div class="t-title">FIXED 固定价</div>
<div class="t-desc">每个产品×国家×重量段独立配置运费单价 + 挂号费,覆盖默认价</div>
<div class="t-desc">可不填明细(走产品默认价);有明细则按产品×国家×重量段覆盖运费+挂号费</div>
</div>
<div
class="type-card"
@@ -77,7 +77,7 @@
:closable="false"
show-icon
class="mb12"
title="FIXED 模式:每个产品 × 国家 × 重量段独立配置运费单价(RMB/KG)+ 挂号费(RMB/票)。命中时直接取该固定价。"
title="FIXED 模式:可不填明细(计费取 bms_product_country 默认运费/挂号费);有明细时按产品×国家×重量段覆盖固定价。"
/>
<el-alert
v-else
@@ -88,24 +88,57 @@
title="DISCOUNT 模式:可不填明细(全部产品适用默认折扣),或按产品限定折扣范围。"
/>
<el-table :data="form.details" border empty-text="暂无明细,点击添加明细行">
<el-table-column label="产品" min-width="150">
<el-table-column label="产品" min-width="180">
<template #default="{ row }">
<el-select v-model="row.productCode" filterable allow-create placeholder="产品" @change="onProductChange(row)">
<el-select
v-model="row.productCode"
filterable
clearable
placeholder="选择产品"
style="width: 100%"
@change="onProductChange(row)"
>
<el-option v-for="p in productOptions" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
</template>
</el-table-column>
<el-table-column v-if="form.setType === 'FIXED'" label="国家" min-width="130">
<el-table-column v-if="form.setType === 'FIXED'" label="国家" min-width="140">
<template #default="{ row }">
<el-select v-model="row.countryCode" filterable allow-create placeholder="国家" @change="onCountryChange(row)">
<el-option v-for="c in countryOptions" :key="c.value" :label="c.label" :value="c.value" />
<el-select
v-model="row._countryKey"
filterable
clearable
placeholder="选择国家"
style="width: 100%"
:disabled="!row.productCode"
@change="onCountryChange(row)"
>
<el-option
v-for="c in countryOptionsOf(row)"
:key="c.key"
:label="c.label"
:value="c.key"
/>
</el-select>
</template>
</el-table-column>
<el-table-column v-if="form.setType === 'FIXED'" label="重量段" min-width="150">
<template #default="{ row }">
<el-select v-model="row.weightLabel" filterable allow-create placeholder="重量段">
<el-option v-for="w in weightOptions" :key="w.value" :label="w.label" :value="w.value" />
<el-select
v-model="row.weightLabel"
filterable
clearable
placeholder="选择重量段"
style="width: 100%"
:disabled="!row._countryKey"
@change="onWeightChange(row)"
>
<el-option
v-for="w in weightOptionsOf(row)"
:key="w.value"
:label="w.label"
:value="w.value"
/>
</el-select>
</template>
</el-table-column>
@@ -212,6 +245,7 @@
<script setup name="PriceSetCreate">
import { getPriceSet, savePriceSet, validatePriceSet } from '@/api/price/set'
import { listProduct, listProductCountries } from '@/api/price/product'
const { proxy } = getCurrentInstance()
const route = useRoute()
@@ -219,25 +253,9 @@ const active = ref(0)
const saving = ref(false)
const formRef = ref()
const checkItems = ref([])
const productOptions = [
{ value: 'WSP037', label: 'WSP037 球衣' },
{ value: 'WSP038', label: 'WSP038 普货' },
{ value: 'WSP034', label: 'WSP034 带电' },
{ value: 'WSP035', label: 'WSP035 敏感' },
{ value: 'WSP066', label: 'WSP066 鞋盒' }
]
const countryOptions = [
{ value: 'DE', label: '德国 DE' },
{ value: 'FR', label: '法国 FR' },
{ value: 'IT', label: '意大利 IT' },
{ value: 'ES', label: '西班牙 ES' }
]
const weightOptions = [
{ value: '0.01≤W≤0.3', label: '0.01≤W≤0.3' },
{ value: '0.3<W≤1', label: '0.3<W≤1' },
{ value: '1<W≤2', label: '1<W≤2' }
]
const productOptions = ref([])
/** productCode -> bms_product_country rows */
const countryCache = reactive({})
const form = reactive({
priceSetId: undefined,
@@ -257,13 +275,93 @@ const rules = {
effectiveDate: [{ required: true, message: '生效日期不能为空', trigger: 'change' }]
}
function trimNum(v) {
if (v == null || v === '') return ''
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return String(parseFloat(n.toFixed(6)))
}
function formatWeightLabel(min, max) {
return `${trimNum(min)}≤W≤${trimNum(max)}`
}
function countryKeyOf(row) {
if (!row) return ''
if (row.countryCode) return row.countryCode
if (row.countryName) return 'N:' + row.countryName
return ''
}
function matchCountry(detail, item) {
if (detail.countryCode && item.countryCode) {
return detail.countryCode === item.countryCode
}
return detail.countryName && item.countryName === detail.countryName
}
function loadProducts() {
return listProduct({ status: '0', pageNum: 1, pageSize: 1000 }).then(res => {
const rows = res.rows || []
productOptions.value = rows.map(p => ({
value: p.productCode,
label: `${p.productCode} ${p.productName || ''}`.trim(),
name: p.productName
}))
})
}
function ensureCountries(productCode) {
if (!productCode) return Promise.resolve([])
if (countryCache[productCode]) return Promise.resolve(countryCache[productCode])
return listProductCountries(productCode).then(res => {
const rows = res.data || []
countryCache[productCode] = rows
return rows
})
}
function countryOptionsOf(row) {
const rows = countryCache[row.productCode] || []
const map = new Map()
rows.forEach(item => {
const key = countryKeyOf(item)
if (!key || map.has(key)) return
map.set(key, {
key,
code: item.countryCode || undefined,
name: item.countryName,
label: item.countryCode ? `${item.countryName} ${item.countryCode}` : item.countryName
})
})
return [...map.values()]
}
function weightOptionsOf(row) {
const rows = (countryCache[row.productCode] || []).filter(item => matchCountry(row, item))
const map = new Map()
rows.forEach(item => {
if (item.weightMin == null && item.weightMax == null) return
const value = formatWeightLabel(item.weightMin, item.weightMax)
if (map.has(value)) return
map.set(value, {
value,
label: value,
freightPrice: item.freightPrice,
registrationFee: item.registrationFee
})
})
return [...map.values()]
}
function emptyDetail() {
return {
productCode: undefined,
productName: undefined,
countryCode: form.setType === 'FIXED' ? 'DE' : undefined,
countryName: form.setType === 'FIXED' ? '德国' : undefined,
weightLabel: form.setType === 'FIXED' ? '0.3<W≤1' : undefined,
countryCode: undefined,
countryName: undefined,
_countryKey: undefined,
weightLabel: undefined,
fvValue: undefined,
fvRegistrationFee: undefined,
discountValue: undefined
@@ -288,22 +386,74 @@ function addCustomerRow() {
form.customers.push(emptyCustomer())
}
function clearCascadeAfterProduct(row) {
row.countryCode = undefined
row.countryName = undefined
row._countryKey = undefined
row.weightLabel = undefined
row.fvValue = undefined
row.fvRegistrationFee = undefined
}
function onProductChange(row) {
const found = productOptions.find(p => p.value === row.productCode)
row.productName = found ? found.label.replace(row.productCode + ' ', '') : row.productCode
const found = productOptions.value.find(p => p.value === row.productCode)
row.productName = found ? found.name : row.productCode
clearCascadeAfterProduct(row)
if (!row.productCode || form.setType !== 'FIXED') return
ensureCountries(row.productCode)
}
function onCountryChange(row) {
const found = countryOptions.find(c => c.value === row.countryCode)
row.countryName = found ? found.label.replace(' ' + row.countryCode, '') : row.countryCode
const opt = countryOptionsOf(row).find(c => c.key === row._countryKey)
row.countryCode = opt ? opt.code : undefined
row.countryName = opt ? opt.name : undefined
row.weightLabel = undefined
row.fvValue = undefined
row.fvRegistrationFee = undefined
}
function onWeightChange(row) {
const opt = weightOptionsOf(row).find(w => w.value === row.weightLabel)
if (!opt) return
if (opt.freightPrice != null) row.fvValue = Number(opt.freightPrice)
if (opt.registrationFee != null) row.fvRegistrationFee = Number(opt.registrationFee)
}
function hydrateDetailRow(d) {
const row = {
...d,
_countryKey: countryKeyOf(d)
}
// normalize historical weight labels to min≤W≤max when possible
if (row.productCode && form.setType === 'FIXED') {
ensureCountries(row.productCode).then(rows => {
if (!row.weightLabel) return
const hit = rows.find(item => {
if (!matchCountry(row, item)) return false
return formatWeightLabel(item.weightMin, item.weightMax) === row.weightLabel
|| row.weightLabel.includes(String(item.weightMin)) && row.weightLabel.includes(String(item.weightMax))
})
if (hit) {
row.weightLabel = formatWeightLabel(hit.weightMin, hit.weightMax)
row._countryKey = countryKeyOf(hit)
if (!row.countryCode) row.countryCode = hit.countryCode
if (!row.countryName) row.countryName = hit.countryName
}
})
}
return row
}
function buildPayload(submitFlag) {
const details = (form.details || []).filter(d => d.productCode).map(d => ({
...d,
productCode: d.productCode,
productName: d.productName,
countryCode: d.countryCode || undefined,
countryName: d.countryName || undefined,
weightLabel: d.weightLabel || undefined,
fvValue: d.fvValue,
fvRegistrationFee: d.fvRegistrationFee
fvRegistrationFee: d.fvRegistrationFee,
discountValue: d.discountValue
}))
const customers = (form.customers || [])
.filter(c => c.customerId)
@@ -392,7 +542,7 @@ function checkTag(result) {
}
function checkLabel(result) {
const map = { PASS: '? 通过', WARN: '? 待确认', FAIL: '? 失败', INFO: '? 提示' }
const map = { PASS: '通过', WARN: '待确认', FAIL: '失败', INFO: '提示' }
return map[result] || result
}
@@ -406,18 +556,20 @@ function loadExisting(id) {
form.effectiveDate = data.effectiveDate
form.expireDate = data.expireDate
form.priceSetNote = data.priceSetNote
form.details = (data.details || []).map(d => ({ ...d }))
form.details = (data.details || []).map(d => hydrateDetailRow(d))
form.customers = (data.customers || []).map(c => ({ ...c }))
})
}
onMounted(() => {
const id = route.query.priceSetId
if (id) {
loadExisting(id)
} else {
addDetailRow()
}
loadProducts().finally(() => {
const id = route.query.priceSetId
if (id) {
loadExisting(id)
} else {
addDetailRow()
}
})
})
</script>
+159 -14
View File
@@ -126,21 +126,44 @@
<el-dialog :title="detailTitle" v-model="detailOpen" width="560px" append-to-body>
<el-form ref="detailFormRef" :model="detailForm" :rules="detailRules" label-width="120px">
<el-form-item label="产品代码" prop="productCode">
<el-input v-model="detailForm.productCode" maxlength="10" />
</el-form-item>
<el-form-item label="产品名称" prop="productName">
<el-input v-model="detailForm.productName" maxlength="50" />
<el-form-item label="产品" prop="productCode">
<el-select
v-model="detailForm.productCode"
filterable
clearable
placeholder="选择产品"
style="width: 100%"
@change="onDetailProductChange"
>
<el-option v-for="p in productOptions" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
</el-form-item>
<template v-if="set.setType === 'FIXED'">
<el-form-item label="国家代码" prop="countryCode">
<el-input v-model="detailForm.countryCode" maxlength="2" />
</el-form-item>
<el-form-item label="国家名称" prop="countryName">
<el-input v-model="detailForm.countryName" maxlength="50" />
<el-form-item label="国家" prop="countryCode">
<el-select
v-model="detailForm._countryKey"
filterable
clearable
placeholder="选择国家"
style="width: 100%"
:disabled="!detailForm.productCode"
@change="onDetailCountryChange"
>
<el-option v-for="c in detailCountryOptions" :key="c.key" :label="c.label" :value="c.key" />
</el-select>
</el-form-item>
<el-form-item label="重量段" prop="weightLabel">
<el-input v-model="detailForm.weightLabel" maxlength="50" />
<el-select
v-model="detailForm.weightLabel"
filterable
clearable
placeholder="选择重量段"
style="width: 100%"
:disabled="!detailForm._countryKey"
@change="onDetailWeightChange"
>
<el-option v-for="w in detailWeightOptions" :key="w.value" :label="w.label" :value="w.value" />
</el-select>
</el-form-item>
<el-form-item label="运费单价" prop="fvValue">
<el-input-number v-model="detailForm.fvValue" :min="0" :precision="4" controls-position="right" style="width: 100%" />
@@ -194,19 +217,22 @@ import {
addSetDetail, updateSetDetail, delSetDetail,
addSetCustomer, updateSetCustomer, delSetCustomer
} from '@/api/price/set'
import { listProduct, listProductCountries } from '@/api/price/product'
import { parseTime } from '@/utils/ruoyi'
const { proxy } = getCurrentInstance()
const route = useRoute()
const loading = ref(false)
const set = ref({})
const productOptions = ref([])
const countryCache = reactive({})
const detailOpen = ref(false)
const detailTitle = ref('')
const detailFormRef = ref()
const detailForm = ref({})
const detailRules = {
productCode: [{ required: true, message: '产品代码不能为空', trigger: 'blur' }]
productCode: [{ required: true, message: '请选择产品', trigger: 'change' }]
}
const customerOpen = ref(false)
@@ -218,6 +244,113 @@ const customerRules = {
prBegindate: [{ required: true, message: '生效日期不能为空', trigger: 'change' }]
}
function trimNum(v) {
if (v == null || v === '') return ''
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return String(parseFloat(n.toFixed(6)))
}
function formatWeightLabel(min, max) {
return `${trimNum(min)}≤W≤${trimNum(max)}`
}
function countryKeyOf(row) {
if (!row) return ''
if (row.countryCode) return row.countryCode
if (row.countryName) return 'N:' + row.countryName
return ''
}
function matchCountry(detail, item) {
if (detail.countryCode && item.countryCode) return detail.countryCode === item.countryCode
return detail.countryName && item.countryName === detail.countryName
}
function ensureCountries(productCode) {
if (!productCode) return Promise.resolve([])
if (countryCache[productCode]) return Promise.resolve(countryCache[productCode])
return listProductCountries(productCode).then(res => {
const rows = res.data || []
countryCache[productCode] = rows
return rows
})
}
const detailCountryOptions = computed(() => {
const rows = countryCache[detailForm.value.productCode] || []
const map = new Map()
rows.forEach(item => {
const key = countryKeyOf(item)
if (!key || map.has(key)) return
map.set(key, {
key,
code: item.countryCode || undefined,
name: item.countryName,
label: item.countryCode ? `${item.countryName} ${item.countryCode}` : item.countryName
})
})
return [...map.values()]
})
const detailWeightOptions = computed(() => {
const form = detailForm.value
const rows = (countryCache[form.productCode] || []).filter(item => matchCountry(form, item))
const map = new Map()
rows.forEach(item => {
if (item.weightMin == null && item.weightMax == null) return
const value = formatWeightLabel(item.weightMin, item.weightMax)
if (map.has(value)) return
map.set(value, {
value,
label: value,
freightPrice: item.freightPrice,
registrationFee: item.registrationFee
})
})
return [...map.values()]
})
function loadProducts() {
listProduct({ status: '0', pageNum: 1, pageSize: 1000 }).then(res => {
productOptions.value = (res.rows || []).map(p => ({
value: p.productCode,
label: `${p.productCode} ${p.productName || ''}`.trim(),
name: p.productName
}))
})
}
function onDetailProductChange() {
const found = productOptions.value.find(p => p.value === detailForm.value.productCode)
detailForm.value.productName = found ? found.name : detailForm.value.productCode
detailForm.value.countryCode = undefined
detailForm.value.countryName = undefined
detailForm.value._countryKey = undefined
detailForm.value.weightLabel = undefined
detailForm.value.fvValue = undefined
detailForm.value.fvRegistrationFee = undefined
if (detailForm.value.productCode && set.value.setType === 'FIXED') {
ensureCountries(detailForm.value.productCode)
}
}
function onDetailCountryChange() {
const opt = detailCountryOptions.value.find(c => c.key === detailForm.value._countryKey)
detailForm.value.countryCode = opt ? opt.code : undefined
detailForm.value.countryName = opt ? opt.name : undefined
detailForm.value.weightLabel = undefined
detailForm.value.fvValue = undefined
detailForm.value.fvRegistrationFee = undefined
}
function onDetailWeightChange() {
const opt = detailWeightOptions.value.find(w => w.value === detailForm.value.weightLabel)
if (!opt) return
if (opt.freightPrice != null) detailForm.value.fvValue = Number(opt.freightPrice)
if (opt.registrationFee != null) detailForm.value.fvRegistrationFee = Number(opt.registrationFee)
}
const childEditable = computed(() => ['D', 'R', 'E'].includes(set.value.status))
const statusLabel = computed(() => {
@@ -296,12 +429,16 @@ function onCopy() {
}
function openDetailDialog(row) {
detailForm.value = row ? { ...row } : {
detailForm.value = row ? {
...row,
_countryKey: countryKeyOf(row)
} : {
priceSetId: set.value.priceSetId,
productCode: undefined,
productName: undefined,
countryCode: undefined,
countryName: undefined,
_countryKey: undefined,
weightLabel: undefined,
fvValue: undefined,
fvRegistrationFee: undefined,
@@ -309,12 +446,19 @@ function openDetailDialog(row) {
}
detailTitle.value = row ? '编辑明细' : '添加明细'
detailOpen.value = true
if (detailForm.value.productCode && set.value.setType === 'FIXED') {
ensureCountries(detailForm.value.productCode)
}
}
function submitDetail() {
proxy.$refs['detailFormRef'].validate(valid => {
if (!valid) return
const payload = { ...detailForm.value, priceSetId: set.value.priceSetId }
const payload = {
...detailForm.value,
priceSetId: set.value.priceSetId
}
delete payload._countryKey
const req = payload.psdId ? updateSetDetail(payload) : addSetDetail(payload)
req.then(() => {
proxy.$modal.msgSuccess('保存成功')
@@ -369,6 +513,7 @@ function removeCustomer(row) {
}
onMounted(() => {
loadProducts()
load()
})
</script>