提交产品列表

This commit is contained in:
wuweihua
2026-09-07 18:09:59 +08:00
parent 3994bd7998
commit 9e98cd791e
14 changed files with 528 additions and 39 deletions
@@ -30,6 +30,8 @@ public class PrcPriceSetDetail implements Serializable
private BigDecimal fvValue;
private BigDecimal fvRegistrationFee;
private BigDecimal discountValue;
/** 取值方式:UP / DOWN / HALF_UP(字典 bms_round_mode) */
private String roundMode;
private Integer prId;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createdAt;
@@ -62,6 +64,8 @@ public class PrcPriceSetDetail implements Serializable
public void setFvRegistrationFee(BigDecimal fvRegistrationFee) { this.fvRegistrationFee = fvRegistrationFee; }
public BigDecimal getDiscountValue() { return discountValue; }
public void setDiscountValue(BigDecimal discountValue) { this.discountValue = discountValue; }
public String getRoundMode() { return roundMode; }
public void setRoundMode(String roundMode) { this.roundMode = roundMode; }
public Integer getPrId() { return prId; }
public void setPrId(Integer prId) { this.prId = prId; }
public Date getCreatedAt() { return createdAt; }
@@ -49,6 +49,8 @@ public class TmsPriceQuoteResult implements Serializable
/** freightAmount + registrationFee */
private BigDecimal payableTotal;
private String currency = "RMB";
/** ????? UP / DOWN / HALF_UP */
private String roundMode;
public String getMatchSource() { return matchSource; }
public void setMatchSource(String matchSource) { this.matchSource = matchSource; }
@@ -102,4 +104,6 @@ public class TmsPriceQuoteResult implements Serializable
public void setPayableTotal(BigDecimal payableTotal) { this.payableTotal = payableTotal; }
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public String getRoundMode() { return roundMode; }
public void setRoundMode(String roundMode) { this.roundMode = roundMode; }
}
@@ -248,9 +248,11 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
nd.setZdId(d.getZdId());
nd.setWsdId(d.getWsdId());
nd.setWeightLabel(d.getWeightLabel());
nd.setProductCountryId(d.getProductCountryId());
nd.setFvValue(d.getFvValue());
nd.setFvRegistrationFee(d.getFvRegistrationFee());
nd.setDiscountValue(d.getDiscountValue());
nd.setRoundMode(d.getRoundMode());
nd.setPrId(d.getPrId());
details.add(nd);
}
@@ -628,6 +630,19 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
}
fillProductCountryId(detail);
}
if (StringUtils.isEmpty(detail.getRoundMode()))
{
detail.setRoundMode("HALF_UP");
}
else
{
String mode = detail.getRoundMode().trim().toUpperCase();
if (!"UP".equals(mode) && !"DOWN".equals(mode) && !"HALF_UP".equals(mode))
{
throw new ServiceException("取值方式仅支持 UP / DOWN / HALF_UP");
}
detail.setRoundMode(mode);
}
if (StringUtils.isNotEmpty(detail.getCountryCode()))
{
detail.setCountryCode(detail.getCountryCode().toUpperCase());
@@ -119,7 +119,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
if (detailCount == 0)
{
TmsPriceQuoteResult r = buildFromRates(SRC_DEFAULT, base.getFreightPrice(), null, null,
base.getRegistrationFee(), base, productCode, productName, weight);
base.getRegistrationFee(), base, productCode, productName, weight, null);
fillPackage(r, bind, set);
r.setMatchMessage("FIXED package has no details; use bms_product_country default");
r.setCustomerId(bind.getCustomerId());
@@ -139,7 +139,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
return null;
}
TmsPriceQuoteResult r = buildFromRates(SRC_FIXED, detail.getFvValue(), null, null,
detail.getFvRegistrationFee(), base, productCode, productName, weight);
detail.getFvRegistrationFee(), base, productCode, productName, weight, detail.getRoundMode());
fillPackage(r, bind, set);
if (StringUtils.isNotEmpty(detail.getProductName()))
{
@@ -163,6 +163,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
{
int detailCount = detailMapper.countByPriceSetId(set.getPriceSetId());
BigDecimal discount = set.getDefaultDiscount();
String roundMode = null;
if (detailCount > 0)
{
PrcPriceSetDetail detail = detailMapper.selectDiscountMatch(set.getPriceSetId(), productCode);
@@ -174,6 +175,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
{
discount = detail.getDiscountValue();
}
roundMode = detail.getRoundMode();
}
if (discount == null
|| discount.compareTo(BigDecimal.ONE) < 0
@@ -186,10 +188,11 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
throw new ServiceException("FREIGHT_VALUE_MISSING: product default freight rate is null");
}
BigDecimal baseRate = base.getFreightPrice();
RoundingMode rm = resolveRoundingMode(roundMode);
BigDecimal rate = baseRate.multiply(discount)
.divide(new BigDecimal("100"), 4, RoundingMode.HALF_UP);
.divide(new BigDecimal("100"), 4, rm);
TmsPriceQuoteResult r = buildFromRates(SRC_DISCOUNT, rate, baseRate, discount,
base.getRegistrationFee(), base, productCode, productName, weight);
base.getRegistrationFee(), base, productCode, productName, weight, roundMode);
fillPackage(r, bind, set);
r.setMatchMessage("hit customer DISCOUNT package; freight = default * discount%");
r.setCustomerId(bind.getCustomerId());
@@ -204,7 +207,7 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
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);
base.getRegistrationFee(), base, productCode, productName, weight, null);
r.setCustomerId(customerId);
r.setMatchMessage(message);
return r;
@@ -212,7 +215,8 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
private TmsPriceQuoteResult buildFromRates(String source, BigDecimal freightRate,
BigDecimal baseFreightRate, BigDecimal discountPercent, BigDecimal registrationFee,
BmsProductCountry base, String productCode, String productName, BigDecimal weight)
BmsProductCountry base, String productCode, String productName, BigDecimal weight,
String roundMode)
{
if (freightRate == null)
{
@@ -222,8 +226,10 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
{
registrationFee = BigDecimal.ZERO;
}
BigDecimal freightAmount = freightRate.multiply(weight).setScale(2, RoundingMode.HALF_UP);
BigDecimal payable = freightAmount.add(registrationFee).setScale(2, RoundingMode.HALF_UP);
RoundingMode rm = resolveRoundingMode(roundMode);
String mode = normalizeRoundMode(roundMode);
BigDecimal freightAmount = freightRate.multiply(weight).setScale(2, rm);
BigDecimal payable = freightAmount.add(registrationFee).setScale(2, rm);
TmsPriceQuoteResult r = new TmsPriceQuoteResult();
r.setMatchSource(source);
@@ -244,9 +250,38 @@ public class TmsPriceQuoteServiceImpl implements ITmsPriceQuoteService
r.setFreightAmount(freightAmount);
r.setPayableTotal(payable);
r.setCurrency("RMB");
r.setRoundMode(mode);
return r;
}
private static String normalizeRoundMode(String roundMode)
{
if (StringUtils.isEmpty(roundMode))
{
return "HALF_UP";
}
String mode = roundMode.trim().toUpperCase();
if ("UP".equals(mode) || "DOWN".equals(mode) || "HALF_UP".equals(mode))
{
return mode;
}
return "HALF_UP";
}
private static RoundingMode resolveRoundingMode(String roundMode)
{
String mode = normalizeRoundMode(roundMode);
if ("UP".equals(mode))
{
return RoundingMode.UP;
}
if ("DOWN".equals(mode))
{
return RoundingMode.DOWN;
}
return RoundingMode.HALF_UP;
}
private void fillPackage(TmsPriceQuoteResult r, PrcCustomerPriceSet bind, PrcPriceSet set)
{
r.setPriceSetId(set.getPriceSetId());
@@ -18,6 +18,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="fvValue" column="fv_value" />
<result property="fvRegistrationFee" column="fv_registration_fee" />
<result property="discountValue" column="discount_value" />
<result property="roundMode" column="round_mode" />
<result property="prId" column="pr_id" />
<result property="createdAt" column="created_at" />
<result property="updatedAt" column="updated_at" />
@@ -26,7 +27,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<sql id="selectPrcPriceSetDetailVo">
select psd_id, price_set_id, product_code, product_name, country_code, country_name,
zd_id, wsd_id, weight_label, product_country_id, fv_value, fv_registration_fee,
discount_value, pr_id, created_at, updated_at
discount_value, round_mode, pr_id, created_at, updated_at
from prc_price_set_detail
</sql>
@@ -73,6 +74,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="fvValue != null">fv_value,</if>
<if test="fvRegistrationFee != null">fv_registration_fee,</if>
<if test="discountValue != null">discount_value,</if>
<if test="roundMode != null and roundMode != ''">round_mode,</if>
<if test="prId != null">pr_id,</if>
created_at
) values (
@@ -88,6 +90,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="fvValue != null">#{fvValue},</if>
<if test="fvRegistrationFee != null">#{fvRegistrationFee},</if>
<if test="discountValue != null">#{discountValue},</if>
<if test="roundMode != null and roundMode != ''">#{roundMode},</if>
<if test="prId != null">#{prId},</if>
sysdate()
)
@@ -107,6 +110,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
fv_value = #{fvValue},
fv_registration_fee = #{fvRegistrationFee},
discount_value = #{discountValue},
<if test="roundMode != null and roundMode != ''">round_mode = #{roundMode},</if>
pr_id = #{prId},
updated_at = sysdate()
</set>
+1
View File
@@ -47,6 +47,7 @@ CREATE TABLE IF NOT EXISTS prc_price_set_detail (
fv_value DECIMAL(10,4) DEFAULT NULL COMMENT '运费单价 RMB/KG',
fv_registration_fee DECIMAL(10,2) DEFAULT NULL COMMENT '挂号费 RMB/票',
discount_value DECIMAL(5,2) DEFAULT NULL COMMENT '明细折扣百分数,DISCOUNT 可覆盖默认',
round_mode VARCHAR(20) NOT NULL DEFAULT 'HALF_UP' COMMENT '取值方式(字典bms_round_mode)',
pr_id INT(11) DEFAULT NULL COMMENT '关联价格规则id',
created_at DATETIME DEFAULT NULL COMMENT '创建时间',
updated_at DATETIME DEFAULT NULL COMMENT '更新时间',
@@ -0,0 +1,6 @@
-- Add round_mode to price set detail
-- dict: bms_round_mode (UP / DOWN / HALF_UP)
SET NAMES utf8mb4;
ALTER TABLE prc_price_set_detail
ADD COLUMN round_mode VARCHAR(20) NOT NULL DEFAULT 'HALF_UP' COMMENT 'round mode (dict bms_round_mode)' AFTER discount_value;
+10 -1
View File
@@ -46,6 +46,7 @@
:height="height"
:max-height="maxHeight"
:default-sort="defaultSort"
:span-method="spanMethod"
@selection-change="handleSelectionChange"
@row-click="(row, col, e) => $emit('row-click', row, col, e)"
@sort-change="(c) => $emit('sort-change', c)"
@@ -241,6 +242,11 @@ const props = defineProps({
type: Object,
default: undefined
},
// 单元格合并方法(透传 el-table span-method),签名同 el-table:({ row, column, rowIndex, columnIndex }) => { rowspan, colspan }
spanMethod: {
type: Function,
default: null
},
// 每页条数可选项
pageSizes: {
type: Array,
@@ -425,7 +431,10 @@ defineExpose({
getTableRef,
getSearchFormRef,
getQueryParams,
setQueryParams
setQueryParams,
getData: () => tableData.value,
/** 当前页数据(响应式,父组件 span-method 可直接订阅) */
data: tableData
})
if (props.autoLoad) {
@@ -1,22 +1,69 @@
<template>
<el-dialog :title="title" v-model="visible" :width="width" append-to-body @close="handleClose">
<el-upload ref="uploadRef" :limit="1" accept=".xlsx, .xls" :headers="headers" :action="uploadUrl" :disabled="isUploading" :on-progress="handleProgress" :on-change="handleFileChange" :on-remove="handleFileRemove" :on-success="handleSuccess" :auto-upload="false" drag>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
<template #tip>
<div class="el-upload__tip text-center">
<div class="el-upload__tip">
<el-checkbox v-model="updateSupport"> {{ updateSupportLabel }} </el-checkbox>
<el-dialog :title="title" v-model="visible" :width="width" append-to-body @close="handleClose" class="excel-import-dialog">
<div class="import-body">
<!-- 业务说明(可选) -->
<div v-if="importDesc.length" class="import-desc">
<el-icon class="desc-icon"><Info-Filled /></el-icon>
<ul class="desc-list">
<li v-for="(line, i) in importDesc" :key="i">{{ line }}</li>
</ul>
</div>
<span>仅允许导入xls、xlsx格式文件。</span>
<el-link v-if="templateUrl" type="primary" underline="never" style="font-size: 12px; vertical-align: baseline" @click="handleDownloadTemplate">下载模板</el-link>
<!-- 上传拖拽区 -->
<el-upload
ref="uploadRef"
:limit="1"
:accept="accept"
:headers="headers"
:action="uploadUrl"
:disabled="isUploading"
:on-progress="handleProgress"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
:on-success="handleSuccess"
:auto-upload="false"
drag
class="upload-area"
>
<div class="upload-placeholder">
<el-icon class="upload-icon"><upload-filled /></el-icon>
<div class="upload-title">点击或拖拽上传文件</div>
<div class="upload-hint">支持 {{ accept }} 格式,单次最多导入 1 个文件,文件大小不超过 {{ fileSizeMb }} MB</div>
</div>
</template>
</el-upload>
<!-- 已选文件 + 进度 -->
<div v-if="selectedFile || isUploading" class="file-progress">
<div v-if="selectedFile" class="selected-file">
<el-icon class="file-icon"><Document /></el-icon>
<span class="file-name" :title="selectedFile.name">{{ selectedFile.name }}</span>
<el-icon v-if="!isUploading" class="file-remove" @click="handleClearFile"><CircleClose /></el-icon>
</div>
<el-progress v-if="isUploading" :percentage="progressPercent" :stroke-width="6" :show-text="true" status="success" class="upload-progress" />
</div>
<!-- 校验错误 -->
<div v-if="validateError" class="validate-error">
<el-icon><Warning-Filled /></el-icon>
<span>{{ validateError }}</span>
</div>
<!-- 选项与提示 -->
<div class="import-options">
<el-checkbox v-if="showUpdateSupport" v-model="updateSupport" class="update-checkbox">{{ updateSupportLabel }}</el-checkbox>
<div class="import-tips">
<el-icon class="tip-icon"><Info-Filled /></el-icon>
<div class="tip-text">
仅允许上传 {{ accept }} 格式文件,文件大小不超过 {{ fileSizeMb }} MB。
<el-link v-if="templateUrl" type="primary" underline="never" class="template-link" @click="handleDownloadTemplate">下载模板</el-link>
</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="handleSubmit">确 定</el-button>
<el-button @click="visible = false">取 消</el-button>
<el-button type="primary" :disabled="!selectedFile || isUploading" :loading="isUploading" @click="handleSubmit">{{ submitText }}</el-button>
<el-button @click="visible = false" :disabled="isUploading">取 消</el-button>
</div>
</template>
</el-dialog>
@@ -36,7 +83,7 @@ const props = defineProps({
// 对话框宽度
width: {
type: String,
default: '400px'
default: '420px'
},
// 上传接口地址(必传)
action: {
@@ -62,6 +109,31 @@ const props = defineProps({
defaultUpdateSupport: {
type: Boolean,
default: false
},
// 是否显示「覆盖更新」勾选(设为 false 可完全隐藏该选项)
showUpdateSupport: {
type: Boolean,
default: true
},
// 接收的文件类型(扩展名或 MIME),默认 xlsx/xls
accept: {
type: String,
default: '.xlsx, .xls'
},
// 单文件大小上限(MB),默认 50
fileSizeMb: {
type: Number,
default: 50
},
// 业务说明(字符串数组),显示在弹窗顶部 importDesc 段
importDesc: {
type: Array,
default: () => []
},
// 确定按钮文字
submitText: {
type: String,
default: '确 定'
}
})
@@ -72,6 +144,8 @@ const visible = ref(false)
const selectedFile = ref(null)
const isUploading = ref(false)
const updateSupport = ref(false)
const progressPercent = ref(0)
const validateError = ref('')
const headers = { Authorization: 'Bearer ' + getToken() }
const uploadUrl = computed(() => {
@@ -84,6 +158,8 @@ const templateUrl = computed(() => !!props.templateAction)
function open() {
updateSupport.value = props.defaultUpdateSupport
isUploading.value = false
progressPercent.value = 0
validateError.value = ''
visible.value = true
nextTick(() => {
selectedFile.value = null
@@ -94,6 +170,8 @@ function open() {
// 关闭时清理
function handleClose() {
isUploading.value = false
progressPercent.value = 0
validateError.value = ''
selectedFile.value = null
uploadRef.value?.clearFiles()
}
@@ -104,24 +182,64 @@ function handleDownloadTemplate() {
}
// 上传进度
function handleProgress() {
function handleProgress(event, file) {
isUploading.value = true
if (file && typeof file.percent === 'number') {
progressPercent.value = Math.round(file.percent)
}
}
// 文件类型/大小校验
function checkFile(file) {
if (!file) return '请先选择文件'
const name = (file.name || '').toLowerCase()
const types = props.accept.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
if (types.length) {
const ok = types.some(t => {
const ext = t.startsWith('.') ? t : '.' + t
return name.endsWith(ext)
})
if (!ok) return `仅允许上传 ${props.accept} 格式文件`
}
const sizeMb = file.size / 1024 / 1024
if (sizeMb > props.fileSizeMb) return `文件大小不能超过 ${props.fileSizeMb} MB`
return ''
}
/** 文件选择处理 */
const handleFileChange = (file, fileList) => {
progressPercent.value = 0
const err = checkFile(file)
if (err) {
validateError.value = err
selectedFile.value = null
nextTick(() => uploadRef.value?.clearFiles())
return
}
validateError.value = ''
selectedFile.value = file
}
/** 文件删除处理 */
const handleFileRemove = (file, fileList) => {
selectedFile.value = null
progressPercent.value = 0
validateError.value = ''
}
/** 手动清除已选文件 */
function handleClearFile() {
selectedFile.value = null
validateError.value = ''
uploadRef.value?.clearFiles()
}
// 上传成功
function handleSuccess(response) {
visible.value = false
isUploading.value = false
progressPercent.value = 0
validateError.value = ''
selectedFile.value = null
uploadRef.value?.clearFiles()
proxy.$alert("<div style='overflow:auto;overflow-x:hidden;max-height:70vh;padding:10px 20px 0;'>" + response.msg + '</div>', '导入结果', { dangerouslyUseHTMLString: true })
@@ -131,8 +249,9 @@ function handleSuccess(response) {
// 提交上传
function handleSubmit() {
const file = selectedFile.value
if (!file || file.length === 0 || !file.name.toLowerCase().endsWith('.xls') && !file.name.toLowerCase().endsWith('.xlsx')) {
proxy.$modal.msgError("请选择后缀为 “xls”或“xlsx”的文件。")
const err = checkFile(file)
if (err) {
proxy.$modal.msgError(err)
return
}
uploadRef.value.submit()
@@ -140,3 +259,167 @@ function handleSubmit() {
defineExpose({ open })
</script>
<style scoped>
.excel-import-dialog :deep(.el-dialog__body) {
padding-bottom: 8px;
}
.import-body {
padding: 0 10px;
}
.upload-area :deep(.el-upload-dragger) {
padding: 44px 20px;
border: 1px dashed var(--el-border-color);
border-radius: 8px;
transition: border-color 0.2s, background-color 0.2s;
}
.upload-area :deep(.el-upload-dragger:hover),
.upload-area :deep(.el-upload-dragger.is-dragover) {
border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9);
}
.upload-placeholder {
text-align: center;
color: var(--el-text-color-regular);
}
.upload-icon {
font-size: 46px;
color: var(--el-color-primary);
}
.upload-title {
font-size: 16px;
margin-top: 14px;
color: var(--el-text-color-primary);
font-weight: 500;
}
.upload-hint {
font-size: 13px;
margin-top: 8px;
color: var(--el-text-color-secondary);
}
.selected-file {
margin-top: 12px;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--el-color-primary-light-9);
border: 1px solid var(--el-color-primary-light-7);
border-radius: 6px;
color: var(--el-text-color-primary);
}
.file-icon {
color: var(--el-color-primary);
font-size: 18px;
flex-shrink: 0;
}
.file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
.file-remove {
color: var(--el-text-color-secondary);
cursor: pointer;
font-size: 18px;
flex-shrink: 0;
}
.file-remove:hover {
color: var(--el-color-danger);
}
.import-options {
margin-top: 16px;
padding: 14px 16px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
.update-checkbox {
font-weight: 500;
color: var(--el-text-color-primary);
}
.import-tips {
margin-top: 10px;
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
.tip-icon {
margin-top: 1px;
font-size: 14px;
color: var(--el-color-info);
flex-shrink: 0;
}
.template-link {
font-size: 12px;
vertical-align: baseline;
}
.import-desc {
margin-bottom: 14px;
padding: 12px 14px;
background: var(--el-color-info-light-9);
border: 1px solid var(--el-color-info-light-7);
border-radius: 8px;
display: flex;
align-items: flex-start;
gap: 8px;
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 1.6;
}
.desc-icon {
margin-top: 2px;
color: var(--el-color-info);
flex-shrink: 0;
}
.desc-list {
margin: 0;
padding-left: 18px;
flex: 1;
}
.file-progress {
margin-top: 12px;
}
.upload-progress {
margin-top: 8px;
}
.validate-error {
margin-top: 12px;
padding: 8px 12px;
background: var(--el-color-danger-light-9);
border: 1px solid var(--el-color-danger-light-7);
border-radius: 6px;
color: var(--el-color-danger);
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
}
</style>
+48
View File
@@ -5,6 +5,7 @@
:columns="columns"
:search-columns="searchColumns"
:query="queryList"
:span-method="objectSpanMethod"
row-key="regionId"
>
<template #toolbar="{ selection, ids }">
@@ -285,6 +286,53 @@ function queryList(params) {
return listRegion(params)
}
// 单元格合并:按「洲/大区」continent 合并相同大区的行(仅作用于 continent 列)
// 关键:el-table 在 :data 引用变化时会重新调用 span-method,
// 但这里 BasicTable 内部的 tableData 是异步请求回来的,父组件拿不到其引用,
// 因此用 watch + nextTick + el-table.doLayout() 强制刷新。
const spanMap = ref([])
function rebuildSpanMap() {
const rows = tableRef.value?.getData?.() || []
const map = new Array(rows.length)
for (let i = 0; i < rows.length; i++) {
const cur = rows[i]?.continent
if (i > 0 && rows[i - 1]?.continent === cur) {
map[i] = { rowspan: 0, colspan: 0 }
} else {
let count = 1
while (i + count < rows.length && rows[i + count]?.continent === cur) count++
map[i] = { rowspan: count, colspan: 1 }
}
}
spanMap.value = map
// 强制 el-table 重新调用 span-method
tableRef.value?.getTableRef?.()?.doLayout?.()
}
function objectSpanMethod({ column, rowIndex }) {
if (column.property !== 'continent') return { rowspan: 1, colspan: 1 }
return spanMap.value[rowIndex] || { rowspan: 1, colspan: 1 }
}
// 监听 BasicTable 数据变化
// 关键:expose.data 是 ref 对象;getData() 是 ()=>tableData.value 函数
// watch 源用 getData() 函数(Vue 会调用 source getter 并比较返回值),
// 每次 tableData.value 被重新赋值(分页/搜索/刷新),返回值(新数组)变化,watch 触发
onMounted(() => {
let lastLen = -1
watch(
() => {
const ref = tableRef.value?.data
return ref?.value || []
},
(rows) => {
if (rows.length !== lastLen) {
lastLen = rows.length
nextTick(rebuildSpanMap)
}
},
{ immediate: true }
)
})
const data = reactive({
form: {},
rules: {
@@ -20,10 +20,10 @@
<el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['price:product:add']">新建产品</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="success" plain icon="Edit" :disabled="selection.length !== 1" @click="handleUpdate(selection[0])" v-hasPermi="['price:product:edit']">修改</el-button>
<el-button type="success" plain icon="Edit" :disabled="selection.length !== 1 || (selection[0] && isProductActive(selection[0]))" @click="handleUpdate(selection[0])" v-hasPermi="['price:product:edit']">修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" plain icon="Delete" :disabled="!selection.length" @click="handleDelete(ids)" v-hasPermi="['price:product:remove']">删除</el-button>
<el-button type="danger" plain icon="Delete" :disabled="!selection.length || selection.some(isProductActive)" @click="handleDelete(ids)" v-hasPermi="['price:product:remove']">删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="info" plain icon="Upload" @click="handleImport" v-hasPermi="['price:product:import']">导入 Excel</el-button>
@@ -48,8 +48,8 @@
<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="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>
<el-button :disabled="isProductActive(row)" :title="isProductActive(row) ? '生效中不可编辑' : ''" link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['price:product:edit']">编辑</el-button>
<el-button :disabled="isProductActive(row)" :title="isProductActive(row) ? '生效中不可删除' : ''" link type="primary" icon="Delete" @click="handleDelete(row.productId)" v-hasPermi="['price:product:remove']">删除</el-button>
</template>
</basic-table>
@@ -123,7 +123,8 @@
</el-dialog>
<el-drawer v-model="countryOpen" size="920px" :title="countryTitle" append-to-body>
<el-table v-loading="countryLoading" :data="countryList" border empty-text="暂无国家明细">
<el-input v-model="countryKeyword" placeholder="搜索国家(中文名 / ISO2)" clearable class="mb8" style="width: 260px" />
<el-table v-loading="countryLoading" :data="filteredCountryList" border :span-method="countrySpanMethod" max-height="calc(100vh - 120px)" empty-text="暂无国家明细">
<el-table-column label="国家" prop="countryName" width="90" show-overflow-tooltip />
<el-table-column label="ISO2" prop="countryCode" width="60" align="center" />
<el-table-column label="重量段(KG)" width="120" align="center">
@@ -173,6 +174,15 @@ const countryOpen = ref(false)
const countryTitle = ref('')
const countryLoading = ref(false)
const countryList = ref([])
const countryKeyword = ref('')
const filteredCountryList = computed(() => {
const kw = countryKeyword.value.trim().toLowerCase()
if (!kw) return countryList.value
return countryList.value.filter(r =>
String(r.countryName || '').toLowerCase().includes(kw) ||
String(r.countryCode || '').toLowerCase().includes(kw)
)
})
const columns = [
{ prop: 'productCode', label: '产品代码', minWidth: 110 },
@@ -225,6 +235,15 @@ function queryList(params) {
return listProduct(params)
}
// 判断产品是否"生效中":status='0'(未停用)且 effectiveDate <= 今天
function isProductActive(row) {
if (!row) return false
if (row.status !== '0') return false
if (!row.effectiveDate) return false
const today = new Date().toISOString().slice(0, 10)
return String(row.effectiveDate).slice(0, 10) <= today
}
function reset() {
form.value = {
productId: undefined,
@@ -249,6 +268,10 @@ function handleAdd() {
}
function handleUpdate(row) {
if (isProductActive(row)) {
proxy.$modal.msgError('生效中的产品不可编辑')
return
}
reset()
getProduct(row.productId).then(res => {
form.value = res.data
@@ -288,12 +311,31 @@ function openCountries(row) {
countryOpen.value = true
countryLoading.value = true
countryList.value = []
countryKeyword.value = ''
listProductCountries(row.productCode).then(res => {
countryList.value = res.data || []
// 按 ISO2 排序,保证相同国家相邻,便于合并单元格
const list = (res.data || []).slice().sort((a, b) => {
return String(a.countryCode || '').localeCompare(String(b.countryCode || ''))
})
countryList.value = list
}).finally(() => {
countryLoading.value = false
})
}
// 弹窗表格合并:相同 countryCode 的连续行合并「国家」「ISO2」列
function countrySpanMethod({ column, rowIndex }) {
if (column.property !== 'countryName' && column.property !== 'countryCode') return
const list = filteredCountryList.value
if (!list.length) return
const cur = list[rowIndex]?.countryCode
if (rowIndex > 0 && list[rowIndex - 1]?.countryCode === cur) {
return { rowspan: 0, colspan: 0 }
}
let count = 1
while (rowIndex + count < list.length && list[rowIndex + count]?.countryCode === cur) count++
return { rowspan: count, colspan: 1 }
}
</script>
<style scoped>
@@ -98,6 +98,7 @@
<el-descriptions-item label="运费单价">{{ formatMoney(result.freightRate) }}</el-descriptions-item>
<el-descriptions-item label="折扣前单价">{{ formatMoney(result.baseFreightRate) }}</el-descriptions-item>
<el-descriptions-item label="折扣%">{{ result.discountPercent != null ? result.discountPercent : '-' }}</el-descriptions-item>
<el-descriptions-item label="取值方式">{{ roundModeLabel(result.roundMode) }}</el-descriptions-item>
<el-descriptions-item label="运费金额">{{ formatMoney(result.freightAmount) }}</el-descriptions-item>
<el-descriptions-item label="挂号费">{{ formatMoney(result.registrationFee) }}</el-descriptions-item>
<el-descriptions-item label="应付总额">
@@ -238,6 +239,15 @@ function sourceLabel(source) {
return map[source] || source || '-'
}
function roundModeLabel(mode) {
const map = {
UP: '向上取值',
DOWN: '向下取整',
HALF_UP: '四舍五入'
}
return map[mode] || mode || '四舍五入'
}
function sourceTagType(source) {
if (source === 'PACKAGE_FIXED' || source === 'FIXED') return 'success'
if (source === 'PACKAGE_DISCOUNT' || source === 'DISCOUNT') return 'warning'
+13 -2
View File
@@ -157,6 +157,13 @@
<el-input-number v-model="row.discountValue" :min="1" :max="99" :precision="2" controls-position="right" style="width: 100%" placeholder="空=默认折扣" />
</template>
</el-table-column>
<el-table-column label="取值方式" min-width="130">
<template #default="{ row }">
<el-select v-model="row.roundMode" placeholder="取值方式" style="width: 100%">
<el-option v-for="d in bms_round_mode" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" @click="form.details.splice($index, 1)">删除</el-button>
@@ -248,6 +255,7 @@ import { getPriceSet, savePriceSet, validatePriceSet } from '@/api/price/set'
import { listProduct, listProductCountries } from '@/api/price/product'
const { proxy } = getCurrentInstance()
const { bms_round_mode } = useDict('bms_round_mode')
const route = useRoute()
const active = ref(0)
const saving = ref(false)
@@ -366,7 +374,8 @@ function emptyDetail() {
productCountryId: undefined,
fvValue: undefined,
fvRegistrationFee: undefined,
discountValue: undefined
discountValue: undefined,
roundMode: 'HALF_UP'
}
}
@@ -430,6 +439,7 @@ function onWeightChange(row) {
function hydrateDetailRow(d) {
const row = {
...d,
roundMode: d.roundMode || 'HALF_UP',
_countryKey: countryKeyOf(d)
}
// normalize historical weight labels to min≤W≤max when possible
@@ -463,7 +473,8 @@ function buildPayload(submitFlag) {
productCountryId: d.productCountryId || undefined,
fvValue: d.fvValue,
fvRegistrationFee: d.fvRegistrationFee,
discountValue: d.discountValue
discountValue: d.discountValue,
roundMode: d.roundMode || 'HALF_UP'
}))
const customers = (form.customers || [])
.filter(c => c.customerId)
+19 -2
View File
@@ -76,6 +76,9 @@
<el-table-column v-if="set.setType === 'DISCOUNT'" label="产品折扣" min-width="110">
<template #default="{ row }">{{ row.discountValue != null ? row.discountValue + '%' : '默认折扣' }}</template>
</el-table-column>
<el-table-column label="取值方式" min-width="110" align="center">
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
</el-table-column>
<!-- <el-table-column label="关联 pr_id" prop="prId" width="110">
<template #default="{ row }">{{ row.prId || '-' }}</template>
</el-table-column> -->
@@ -175,6 +178,11 @@
<el-form-item v-else label="产品折扣%" prop="discountValue">
<el-input-number v-model="detailForm.discountValue" :min="1" :max="99" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="取值方式" prop="roundMode">
<el-select v-model="detailForm.roundMode" placeholder="请选择" style="width: 100%">
<el-option v-for="d in bms_round_mode" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
</el-form-item>
<!-- <el-form-item label="关联 pr_id" prop="prId">
<el-input v-model="detailForm.prId" placeholder="可选" />
</el-form-item> -->
@@ -221,6 +229,7 @@ import { listProduct, listProductCountries } from '@/api/price/product'
import { parseTime } from '@/utils/ruoyi'
const { proxy } = getCurrentInstance()
const { bms_round_mode } = useDict('bms_round_mode')
const route = useRoute()
const loading = ref(false)
const set = ref({})
@@ -232,7 +241,8 @@ const detailTitle = ref('')
const detailFormRef = ref()
const detailForm = ref({})
const detailRules = {
productCode: [{ required: true, message: '请选择产品', trigger: 'change' }]
productCode: [{ required: true, message: '请选择产品', trigger: 'change' }],
roundMode: [{ required: true, message: '请选择取值方式', trigger: 'change' }]
}
const customerOpen = ref(false)
@@ -360,6 +370,11 @@ function onDetailWeightChange() {
const childEditable = computed(() => ['D', 'R', 'E'].includes(set.value.status))
function roundModeLabel(mode) {
const hit = (bms_round_mode.value || []).find(d => d.value === mode)
return hit ? hit.label : (mode || '四舍五入')
}
const statusLabel = computed(() => {
if (set.value.expired) return '已过期'
const map = { D: '草稿', A: '审核中', E: '生效中', R: '驳回', S: '已停用' }
@@ -438,6 +453,7 @@ function onCopy() {
function openDetailDialog(row) {
detailForm.value = row ? {
...row,
roundMode: row.roundMode || 'HALF_UP',
_countryKey: countryKeyOf(row)
} : {
priceSetId: set.value.priceSetId,
@@ -450,7 +466,8 @@ function openDetailDialog(row) {
productCountryId: undefined,
fvValue: undefined,
fvRegistrationFee: undefined,
discountValue: undefined
discountValue: undefined,
roundMode: 'HALF_UP'
}
detailTitle.value = row ? '编辑明细' : '添加明细'
detailOpen.value = true