修改套餐价格

This commit is contained in:
wuweihua
2026-09-16 17:14:19 +08:00
parent 7172621fd3
commit 4441aa12bf
48 changed files with 4258 additions and 682 deletions
@@ -0,0 +1,305 @@
<template>
<div>
<div class="mask" :class="{ open: modelValue }" @click="close"></div>
<div class="drawer" :class="{ open: modelValue }">
<div class="drawer-hd">
<span>{{ title }}</span>
<div class="hd-actions">
<el-button size="small" @click="close">关闭</el-button>
</div>
</div>
<div class="drawer-bd">
<el-alert type="info" :closable="false" show-icon class="mb12" :title="hint" />
<div class="filters">
<el-input v-model="keyword" clearable :placeholder="phSearch" style="width: 240px" />
</div>
<el-table
:data="displayRows"
border
size="small"
:span-method="spanMethod"
:row-class-name="rowClass"
>
<el-table-column :label="colCountry" min-width="100">
<template #default="{ row }">{{ countryLabel(row) }}</template>
</el-table-column>
<el-table-column :label="colZone" min-width="90">
<template #default="{ row }">{{ row.zoneName || row.zoneCode || '-' }}</template>
</el-table-column>
<el-table-column :label="colWeight" prop="weightLabel" min-width="120" />
<el-table-column :label="colSetFv" width="160" align="center">
<template #default="{ row }">
<div class="price-cell">
<template v-if="editingKey !== cellKey(row, 'fv')">
<span class="price-text">{{ formatNum(row.fvValue, 4) }}</span>
<el-button link type="primary" icon="Edit" class="edit-ico" @click="startEdit(row, 'fv')" />
</template>
<el-input-number
v-else
:ref="el => setEditRef(el, row, 'fv')"
v-model="row.fvValue"
:min="0"
:precision="4"
:controls="false"
size="small"
style="width: 110px"
@blur="() => commitEdit(row, 'fv')"
@keyup.enter="() => commitEdit(row, 'fv')"
/>
</div>
</template>
</el-table-column>
<el-table-column :label="colSetReg" width="150" align="center">
<template #default="{ row }">
<div class="price-cell">
<template v-if="editingKey !== cellKey(row, 'reg')">
<span class="price-text">{{ formatNum(row.fvRegistrationFee, 2) }}</span>
<el-button link type="primary" icon="Edit" class="edit-ico" @click="startEdit(row, 'reg')" />
</template>
<el-input-number
v-else
:ref="el => setEditRef(el, row, 'reg')"
v-model="row.fvRegistrationFee"
:min="0"
:precision="2"
:controls="false"
size="small"
style="width: 100px"
@blur="() => commitEdit(row, 'reg')"
@keyup.enter="() => commitEdit(row, 'reg')"
/>
</div>
</template>
</el-table-column>
<el-table-column :label="colSurcharge" width="110" align="center">
<template #default="{ row }">
<surcharge-count-cell :list="row.surcharges || []" @click="pickSurcharge(row)" />
</template>
</el-table-column>
<el-table-column :label="colAction" width="90" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="pickSurcharge(row)">附加费</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
<script setup name="PriceEditDrawer">
import SurchargeCountCell from './SurchargeCountCell.vue'
import { isDetailPriceChanged } from '../../utils/priceSet'
const props = defineProps({
modelValue: { type: Boolean, default: false },
productCode: { type: String, default: '' },
productName: { type: String, default: '' },
rows: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'pick-surcharge', 'row-change'])
const keyword = ref('')
const editingKey = ref('')
const committing = ref(false)
const editInputRefs = {}
const title = computed(() => {
const code = props.productCode || ''
const name = props.productName || ''
return code + ' �� ' + name + ' �� 国家服务价格'
})
const hint = '黄色行=已改价;点编辑图标后修改,失焦即刻写入数据库。单位:运费 RMB/KG,挂号费 RMB/票。'
const phSearch = '搜索国家/分区'
const colCountry = '国家'
const colZone = '分区'
const colWeight = '重量段'
const colSetFv = '套餐运费'
const colSetReg = '套餐挂号费'
const colSurcharge = '附加费'
const colAction = '操作'
function cellKey(row, field) {
return String(row && row.psdId != null ? row.psdId : row && row.productCountryId) + ':' + field
}
function formatNum(v, precision) {
if (v == null || v === '') return '-'
const n = Number(v)
if (Number.isNaN(n)) return '-'
return n.toFixed(precision)
}
function setEditRef(el, row, field) {
if (el) editInputRefs[cellKey(row, field)] = el
}
function startEdit(row, field) {
if (!row) return
editingKey.value = cellKey(row, field)
nextTick(() => {
const el = editInputRefs[editingKey.value]
if (el && typeof el.focus === 'function') el.focus()
})
}
async function commitEdit(row, field) {
if (committing.value) return
if (editingKey.value !== cellKey(row, field)) return
editingKey.value = ''
if (!row || !row.psdId) return
committing.value = true
try {
emit('row-change', row)
} finally {
committing.value = false
}
}
function pickSurcharge(row) {
emit('pick-surcharge', row || null)
}
function countryKey(row) {
return String(row.countryCode || row.countryName || '').toUpperCase()
}
function countryLabel(row) {
return row.countryName || row.countryCode || '-'
}
const displayRows = computed(() => {
const q = (keyword.value || '').trim().toLowerCase()
let list = [...(props.rows || [])]
if (q) {
list = list.filter(r => {
const blob = [r.countryName, r.countryCode, r.zoneName, r.zoneCode, r.weightLabel]
.filter(Boolean).join(' ').toLowerCase()
return blob.includes(q)
})
}
list.sort((a, b) => {
const ca = countryKey(a)
const cb = countryKey(b)
if (ca !== cb) return ca.localeCompare(cb)
const za = String(a.zoneCode || a.zoneName || '')
const zb = String(b.zoneCode || b.zoneName || '')
if (za !== zb) return za.localeCompare(zb)
return String(a.weightLabel || '').localeCompare(String(b.weightLabel || ''))
})
return list
})
function buildSpans(rows, keyOf) {
const spans = new Array(rows.length).fill(1)
let i = 0
while (i < rows.length) {
let j = i + 1
const key = keyOf(rows[i])
while (j < rows.length && keyOf(rows[j]) === key) j++
spans[i] = j - i
for (let k = i + 1; k < j; k++) spans[k] = 0
i = j
}
return spans
}
function zoneKey(row) {
return String(row.zoneCode || row.zoneName || '')
}
const countrySpans = computed(() => buildSpans(displayRows.value, countryKey))
const zoneSpans = computed(() => buildSpans(displayRows.value, r => countryKey(r) + '|' + zoneKey(r)))
function spanMethod({ columnIndex, rowIndex }) {
if (columnIndex === 0) return spanOf(countrySpans.value, rowIndex)
if (columnIndex === 1) return spanOf(zoneSpans.value, rowIndex)
return { rowspan: 1, colspan: 1 }
}
function spanOf(spans, rowIndex) {
const rowspan = spans[rowIndex] || 0
return rowspan > 0 ? { rowspan, colspan: 1 } : { rowspan: 0, colspan: 0 }
}
function rowClass({ row }) {
return isDetailPriceChanged(row) ? 'changed-row' : ''
}
function close() {
editingKey.value = ''
emit('update:modelValue', false)
}
watch(() => props.modelValue, v => {
if (v) {
keyword.value = ''
editingKey.value = ''
}
})
</script>
<style scoped>
.mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 1999;
display: none;
}
.mask.open { display: block; }
.drawer {
position: fixed;
top: 0;
right: 0;
width: min(960px, 96vw);
height: 100%;
background: #fff;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
transform: translateX(100%);
transition: 0.25s;
z-index: 2000;
display: flex;
flex-direction: column;
}
.drawer.open { transform: translateX(0); }
.drawer-hd {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.hd-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.drawer-bd {
padding: 16px 20px;
overflow: auto;
flex: 1;
}
.mb12 { margin-bottom: 12px; }
.filters { margin-bottom: 12px; }
.price-cell {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 2px;
min-height: 28px;
}
.price-text {
font-variant-numeric: tabular-nums;
min-width: 64px;
text-align: right;
}
.edit-ico {
padding: 0 2px !important;
}
:deep(.changed-row > td) {
background: #fffbe6 !important;
}
</style>
@@ -41,15 +41,6 @@
<div class="form-hint">范围 1~99,88 表示默认价 × 0.88;FIXED 模式此值忽略</div>
</el-form-item>
<el-form-item label="生效日期" prop="effectiveDate">
<el-date-picker v-model="basic.effectiveDate" type="date" value-format="YYYY-MM-DD" placeholder="生效日期" />
</el-form-item>
<el-form-item label="失效日期" prop="expireDate">
<el-date-picker v-model="basic.expireDate" type="date" value-format="YYYY-MM-DD" placeholder="空表示长期有效" />
<div class="form-hint">空表示长期有效</div>
</el-form-item>
<el-form-item label="说明备注" prop="priceSetNote">
<el-input v-model="basic.priceSetNote" type="textarea" :rows="3" maxlength="200" placeholder="套餐业务说明,显示给客户与运营" />
</el-form-item>
@@ -69,7 +60,6 @@ const formRef = ref()
const rules = {
priceSetName: [{ required: true, message: '套餐名称不能为空', trigger: 'blur' }],
setType: [{ required: true, message: '请选择计价类型', trigger: 'change' }],
effectiveDate: [{ required: true, message: '生效日期不能为空', trigger: 'change' }]
}
/** 供父级在下一步 / 保存前调用 */
@@ -2,95 +2,23 @@
<el-card shadow="never" class="step-card">
<template #header>
<div class="card-head">
<span>第 3 步 · 绑定客户<span class="sub">可选,可后续补充</span></span>
<el-button type="primary" size="small" icon="Plus" @click="addRow">添加客户</el-button>
<span>已绑定客户<span class="sub">仅查看,请到套餐详情页维护绑定</span></span>
</div>
</template>
<el-alert
type="warning"
:closable="false"
show-icon
class="mb12"
title="套餐创建后处于草稿状态,可暂不绑定客户。一个客户只能绑定一个套餐。"
/>
<el-table :data="customers" border empty-text="暂未绑定客户">
<el-table-column label="客户" min-width="220">
<template #default="{ row }">
<customer-select
v-model="row.customerCode"
value-key="customerCode"
placeholder="请选择客户"
@change="(code, item) => onCustomerChange(row, code, item)"
/>
</template>
</el-table-column>
<el-table-column label="客户代码" prop="customerCode" min-width="120" show-overflow-tooltip />
<el-table-column label="客户名称" prop="customerName" min-width="140" show-overflow-tooltip />
<el-table-column label="优先级" width="110">
<template #default="{ row }">
<el-input-number v-model="row.bindPriority" :min="1" controls-position="right" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="生效日期" min-width="150">
<template #default="{ row }">
<el-date-picker v-model="row.prBegindate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="失效日期" min-width="150">
<template #default="{ row }">
<el-date-picker v-model="row.prEnddate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ $index }">
<el-button link type="danger" @click="customers.splice($index, 1)">移除</el-button>
</template>
</el-table-column>
<el-table-column label="客户代码" prop="customerCode" min-width="140" show-overflow-tooltip />
<el-table-column label="客户名称" prop="customerName" min-width="160" show-overflow-tooltip />
<el-table-column label="优先级" prop="bindPriority" width="110" align="center" />
</el-table>
</el-card>
</template>
<script setup name="StepCustomers">
/**
* 第 3 步 · 绑定客户
* customers 为父级 form.customers 的同一数组引用;新行默认继承套餐生效/失效日期。
*/
import { createEmptyCustomerRow } from '../../utils/priceSet'
const props = defineProps({
/** 客户绑定行数组(父级 form.customers) */
customers: {
type: Array,
required: true
},
/** 套餐生效日期,用于新行默认值 */
effectiveDate: {
type: String,
default: undefined
},
/** 套餐失效日期,用于新行默认值 */
expireDate: {
type: String,
default: undefined
}
defineProps({
customers: { type: Array, required: true },
readOnly: { type: Boolean, default: true }
})
function addRow() {
props.customers.push(createEmptyCustomerRow({
effectiveDate: props.effectiveDate,
expireDate: props.expireDate,
seq: props.customers.length + 1
}))
}
/** 下拉选中客户后回填代码 / 名称 / ID */
function onCustomerChange(row, code, item) {
row.customerCode = code || undefined
row.customerName = item ? item.customerName : ''
row.customerId = item ? item.customerId : undefined
}
</script>
<style scoped>
@@ -105,7 +33,4 @@ function onCustomerChange(row, code, item) {
font-weight: normal;
font-size: 13px;
}
.mb12 {
margin-bottom: 12px;
}
</style>
@@ -1,191 +1,473 @@
<template>
<el-card shadow="never" class="step-card">
<el-card shadow="never" class="step-card" v-loading="pulling" :element-loading-text="loadingText">
<template #header>
<div class="card-head">
<span>
第 2 步 · 套餐明细
<span class="sub">当前:{{ setTypeLabel(setType) }}模式</span>
{{ headerTitle }}
<span class="sub">{{ typeHint }}</span>
</span>
<el-button type="primary" size="small" icon="Plus" @click="addRow">添加明细行</el-button>
</div>
</template>
<el-alert
v-if="isFixedType(setType)"
type="info"
:closable="false"
show-icon
class="mb12"
title="FIXED 模式:可不填明细(计费取 bms_product_country 默认运费/挂号费);有明细时按产品×国家×分区×重量段覆盖固定价。附加费来自产品关联,只读展示。"
/>
<el-alert
v-else
type="warning"
:closable="false"
show-icon
class="mb12"
title="DISCOUNT 模式:可不填明细(全部产品适用默认折扣),或按产品限定折扣范围。"
/>
<el-alert type="info" :closable="false" show-icon class="mb12" :title="metaText" />
<el-table :data="details" border empty-text="暂无明细,点击添加明细行">
<el-table-column label="产品" min-width="180">
<div class="filters">
<el-input v-model="query.keyword" clearable :placeholder="phKeyword" style="width: 180px" />
<el-select v-model="query.taxMode" clearable :placeholder="phTax" style="width: 140px">
<el-option v-for="d in bms_tax_mode" :key="d.value" :label="d.label" :value="d.value" />
</el-select>
<el-button size="small" @click="noop">查询</el-button>
</div>
<el-table :data="filteredProducts" border empty-text="暂无产品价格明细">
<el-table-column :label="colCode" min-width="80">
<template #default="{ row }"><b>{{ row.productCode }}</b></template>
</el-table-column>
<el-table-column :label="colName" prop="productName" min-width="80" show-overflow-tooltip />
<el-table-column :label="colLevel" prop="serviceLevel" width="90" align="center" />
<el-table-column :label="colCargo" width="200" align="center">
<template #default="{ row }">
<el-select
v-model="row.productCode"
filterable
clearable
placeholder="选择产品"
style="width: 100%"
@change="onProductChange(row, setType)"
>
<el-option v-for="p in refData.productOptions.value" :key="p.value" :label="p.label" :value="p.value" />
</el-select>
<el-tag v-if="row.cargoAttr" type="primary" size="small" effect="plain">{{ row.cargoAttr }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column v-if="isFixedType(setType)" label="国家" min-width="140">
<el-table-column :label="colRegion" prop="destRegion" width="90" align="center" />
<el-table-column :label="colTax" width="90" align="center">
<template #default="{ row }">
<el-select
v-model="row._countryKey"
filterable
clearable
placeholder="选择国家"
style="width: 100%"
:disabled="!row.productCode"
@change="onCountryChange(row)"
>
<el-option
v-for="c in refData.countryOptionsOf(row)"
:key="c.key"
:label="c.label"
:value="c.key"
/>
</el-select>
<dict-tag :options="bms_tax_mode" :value="row.taxMode" />
</template>
</el-table-column>
<el-table-column v-if="isFixedType(setType)" label="分区" min-width="140">
<el-table-column :label="colSurcharge" width="110" align="center">
<template #default="{ row }">
<el-select
v-if="refData.hasZonesOf(row)"
v-model="row.zoneCode"
filterable
clearable
placeholder="选择分区"
style="width: 100%"
:disabled="!row._countryKey"
@change="onZoneChange(row)"
>
<el-option
v-for="z in refData.zoneOptionsOf(row)"
:key="z.key"
:label="z.label"
:value="z.zoneCode"
/>
</el-select>
<span v-else class="muted">-</span>
<surcharge-count-cell :list="productSurcharges(row)" @click="openSurcharge(row)" />
</template>
</el-table-column>
<el-table-column v-if="isFixedType(setType)" label="重量段" min-width="150">
<el-table-column :label="colAction" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-select
v-model="row.weightLabel"
filterable
clearable
placeholder="选择重量段"
style="width: 100%"
:disabled="!row._countryKey || (refData.hasZonesOf(row) && !row.zoneCode)"
@change="onWeightChange(row)"
>
<el-option
v-for="w in refData.weightOptionsOf(row)"
:key="w.value"
:label="w.label"
:value="w.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column v-if="isFixedType(setType)" label="运费单价(RMB/KG)" min-width="150">
<template #default="{ row }">
<el-input-number v-model="row.fvValue" :min="0" :precision="4" :step="1" controls-position="right" style="width: 100%" />
</template>
</el-table-column>
<el-table-column v-if="isFixedType(setType)" label="挂号费(RMB/票)" min-width="140">
<template #default="{ row }">
<el-input-number v-model="row.fvRegistrationFee" :min="0" :precision="2" :step="1" controls-position="right" style="width: 100%" />
</template>
</el-table-column>
<el-table-column v-if="isDiscountType(setType)" label="产品折扣%" min-width="140">
<template #default="{ row }">
<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="220">
<template #default="{ row }">
<div v-if="!row.productCode" class="muted">-</div>
<surcharge-tags v-else :list="surchargesOf(row)" />
</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="details.splice($index, 1)">删除</el-button>
<el-button link type="primary" @click="openPrice(row)">编辑价格</el-button>
<el-button link type="primary" @click="openSurcharge(row)">附加费</el-button>
<el-button link type="danger" @click="removeProduct(row.productCode)">移除</el-button>
</template>
</el-table-column>
</el-table>
<surcharge-picker-dialog
v-model="scVisible"
:title="scTitle"
:list="scList"
:selected-ids="scPickedIds"
@confirm="onSurchargeConfirm"
/>
<el-dialog v-model="discVisible" :title="discTitle" width="420px" append-to-body>
<el-form label-width="100px">
<el-form-item :label="colDiscount">
<el-input-number v-model="discValue" :min="1" :max="99" :precision="2" controls-position="right" />
<span class="unit">%</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="discVisible = false">取消</el-button>
<el-button type="primary" @click="saveDiscount">确定</el-button>
</template>
</el-dialog>
<price-edit-drawer
v-model="priceVisible"
:product-code="editCode"
:product-name="editName"
:rows="editRows"
@row-change="onRowPriceChange"
@pick-surcharge="openSurchargeFromDrawer"
/>
</el-card>
</template>
<script setup name="StepDetails">
/**
* 第 2 步 · 套餐明细
* details 为父级 form.details 的同一数组引用,增删行直接原地操作。
*/
import SurchargeTags from '../SurchargeTags.vue'
import PriceEditDrawer from './PriceEditDrawer.vue'
import SurchargeCountCell from './SurchargeCountCell.vue'
import SurchargePickerDialog from './SurchargePickerDialog.vue'
import { useDetailCascade } from '../../composables/usePriceSetRefData'
import { createEmptyDetailRow, isDiscountType, isFixedType, setTypeLabel } from '../../utils/priceSet'
import {
createEmptyDetailRow,
formatWeightLabel,
isDiscountType,
isFixedType,
setTypeLabel,
surchargeAppliesToRow,
toDetailPayload
} from '../../utils/priceSet'
import { updateSetDetail, delSetDetail } from '@/api/price/set'
const props = defineProps({
/** 明细行数组(父级 form.details) */
details: {
type: Array,
required: true
},
/** 计价类型 FIXED / DISCOUNT */
setType: {
type: String,
default: 'FIXED'
},
/** 页面共享的参考数据(usePriceSetRefData 返回值) */
refData: {
type: Object,
required: true
}
details: { type: Array, required: true },
priceSetId: { type: [Number, String], default: undefined },
setType: { type: String, default: 'FIXED' },
effectiveTime: { type: String, default: undefined },
versionNo: { type: String, default: '' },
refData: { type: Object, required: true }
})
const { bms_round_mode } = useDict('bms_round_mode')
const { proxy } = getCurrentInstance()
const { bms_tax_mode } = useDict('bms_tax_mode')
const { hydrateRow } = useDetailCascade(props.refData)
/** 参考数据来自父页面,缓存与请求去重全页面共享 */
const { productOptions, surchargesOf, countryOptionsOf, weightOptionsOf } = props.refData
const { onProductChange, onCountryChange, onZoneChange, onWeightChange } = useDetailCascade(props.refData)
const loadingText = '正在全量拉取产品价格…'
const headerTitle = '第 2 步 · 编辑产品价格'
const phKeyword = '产品代码/名称'
const phTax = '全部税模式'
const colCode = '产品代码'
const colName = '产品名称'
const colLevel = '时效等级'
const colCargo = '走货属性'
const colRegion = '目的区域'
const colTax = '税模式'
const colSurcharge = '附加费'
const colAction = '操作'
const colDiscount = '产品折扣'
function addRow() {
props.details.push(createEmptyDetailRow())
const query = reactive({ keyword: '', taxMode: undefined })
const priceVisible = ref(false)
const editCode = ref('')
const editName = ref('')
const editRows = ref([])
/** 抽屉当前对应的产品分组(抽屉内点附加费时用它打开勾选弹窗) */
const editGroup = ref(null)
const scVisible = ref(false)
const scTitle = ref('附加费')
const scList = ref([])
const scRows = ref([])
const scPickedIds = ref([])
/** 作用范围:null=产品级(覆盖全部明细行),否则=行级键(只写回国家+重量段那一行) */
const scScopeKey = ref(null)
const scScopeName = ref('')
const discVisible = ref(false)
const discTitle = ref('编辑价格')
const discValue = ref(undefined)
const discCode = ref('')
const typeHint = computed(() => '· 当前:' + setTypeLabel(props.setType))
const metaText = computed(() => {
const ver = props.versionNo || 'V?'
return ver + ' · 数据已落库,编辑价格/附加费会立即写入数据库。'
})
const productGroups = computed(() => {
const map = new Map()
for (const d of props.details) {
if (!d.productCode) continue
if (!map.has(d.productCode)) {
const meta = props.refData.productMetaOf(d.productCode) || {}
map.set(d.productCode, {
productCode: d.productCode,
productName: d.productName || meta.name || d.productCode,
serviceLevel: meta.serviceLevel,
cargoAttr: meta.cargoAttr,
destRegion: meta.destRegion,
taxMode: meta.taxMode,
rows: []
})
}
map.get(d.productCode).rows.push(d)
}
return [...map.values()]
})
const filteredProducts = computed(() => {
const kw = (query.keyword || '').trim().toLowerCase()
return productGroups.value.filter(p => {
if (query.taxMode && p.taxMode !== query.taxMode) return false
if (!kw) return true
const blob = [p.productCode, p.productName].filter(Boolean).join(' ').toLowerCase()
return blob.includes(kw)
})
})
const pulling = ref(false)
function noop() {}
/** 该产品所有明细行附加费的并集(按 scId 去重),列表列展示用 */
function productSurcharges(row) {
const seen = new Set()
const list = []
;((row && row.rows) || []).forEach(r => {
(r.surcharges || []).forEach(s => {
const key = s && s.scId != null ? String(s.scId) : (s && s.scName)
if (!key || seen.has(key)) return
seen.add(key)
list.push(s)
})
})
return list
}
/** enter step 3: pull all library products + country prices (skip already added) */
async function pullAllFromLibrary(opts = {}) {
const silent = !!opts.silent
const replace = !!opts.replace
if (!(props.refData.productOptions.value || []).length) {
await props.refData.loadProducts()
}
const products = props.refData.productOptions.value || []
if (!products.length) {
if (!silent) proxy.$modal.msgWarning('产品库无可用产品')
return { added: 0 }
}
if (replace) {
props.details.splice(0, props.details.length)
}
const used = new Set((props.details || []).map(d => d.productCode).filter(Boolean))
const toAdd = products.filter(p => !used.has(p.value))
if (!toAdd.length) return { added: 0 }
pulling.value = true
let added = 0
try {
// parallel fetch countries/surcharges, then push rows
await Promise.all(toAdd.map(async (p) => {
const code = p.value
const name = p.name || props.refData.productNameOf(code)
await props.refData.ensureSurcharges(code)
if (isDiscountType(props.setType)) {
const row = createEmptyDetailRow()
row.productCode = code
row.productName = name
row.surcharges = props.refData.surchargesOf(row)
props.details.push(row)
added++
return
}
const open = await props.refData.ensureCountries(code, props.effectiveTime)
if (!open.length) return
open.forEach(item => {
const row = countryToDetail(item, code, name)
row.surcharges = props.refData.surchargesOf(row)
props.details.push(row)
})
added++
}))
if (!silent) {
if (added > 0) proxy.$modal.msgSuccess('已从产品库全量拉取 ' + added + ' 个产品')
else proxy.$modal.msgWarning('未拉取到可展示的产品价格明细,请检查产品库国家配置')
}
} finally {
pulling.value = false
}
return { added }
}
function countryToDetail(item, productCode, productName) {
const row = createEmptyDetailRow()
row.productCode = productCode
row.productName = productName
row.countryCode = item.countryCode
row.countryName = item.countryName
row.zoneId = item.zoneId
row.zoneCode = item.zoneCode
row.zoneName = item.zoneName
row.weightMin = item.weightMin
row.weightMax = item.weightMax
row.weightLabel = formatWeightLabel(item.weightMin, item.weightMax)
row.productCountryId = item.detailId
const baseFv = item.freightPrice != null ? Number(item.freightPrice) : undefined
const baseReg = item.registrationFee != null ? Number(item.registrationFee) : undefined
row._baseFreight = baseFv
row._baseReg = baseReg
row.fvValue = baseFv
row.fvRegistrationFee = baseReg
row.effectiveDate = item.effectiveDate
row.expireDate = item.expireDate
row._countryKey = item.countryCode || (item.countryName ? 'N:' + item.countryName : undefined)
return hydrateRow(row, props.setType)
}
function openPrice(row) {
if (isDiscountType(props.setType)) {
discCode.value = row.productCode
discTitle.value = row.productCode + ' · ' + (row.productName || '') + ' · 产品折扣'
const hit = (props.details || []).find(d => d.productCode === row.productCode)
discValue.value = hit && hit.discountValue != null ? Number(hit.discountValue) : undefined
discVisible.value = true
return
}
editCode.value = row.productCode
editName.value = row.productName || ''
editRows.value = (row.rows || []).map(r => {
if (r._baseFreight == null && r.fvValue != null) r._baseFreight = Number(r.fvValue)
if (r._baseReg == null && r.fvRegistrationFee != null) r._baseReg = Number(r.fvRegistrationFee)
return r
})
editGroup.value = row
priceVisible.value = true
}
/** 抽屉里点附加费:带行 = 行级(只改该行,国家+重量段),不带 = 产品级(覆盖全部明细行) */
function openSurchargeFromDrawer(row) {
const group = editGroup.value || { productCode: editCode.value, rows: editRows.value }
if (row && (row.countryCode || row.countryName)) {
openSurcharge(group, rowScopeKey(row), rowScopeLabel(row))
} else {
openSurcharge(group)
}
}
/** persist one detail row to DB immediately */
async function persistDetail(row, withSurcharge) {
if (!row || !row.psdId || !props.priceSetId) return
const payload = toDetailPayload(row)
payload.priceSetId = props.priceSetId
if (!withSurcharge) delete payload.surcharges
await updateSetDetail(payload)
}
/** drawer field change -> immediate DB update */
async function onRowPriceChange(row) {
if (!row || !row.psdId) return
try {
await persistDetail(row, false)
proxy.$modal.msgSuccess('价格已更新')
} catch (e) {}
}
async function saveDiscount() {
const hit = (props.details || []).find(d => d.productCode === discCode.value)
if (hit) {
hit.discountValue = discValue.value
try {
await persistDetail(hit, false)
proxy.$modal.msgSuccess('折扣已写入数据库')
} catch (e) {}
}
discVisible.value = false
}
/** 明细行的国家 key(与 countryToDetail 的 _countryKey 同口径,大写便于比较) */
function rowCountryKey(r) {
if (!r) return ''
return String(r.countryCode || (r.countryName ? 'N:' + r.countryName : '')).toUpperCase()
}
/** 明细行的行级唯一键(国家 + 分区 + 重量段),用于精确定位「只改这一行」 */
function rowScopeKey(r) {
if (!r) return ''
const c = rowCountryKey(r)
const z = String(r.zoneCode || r.zoneName || '')
const w = String(r.weightLabel || r.weightMin || r.weightMax || '')
return `${c}|${z}|${w}`
}
/** 行级作用域展示文案,如「荷兰 · 0.3-2kg」 */
function rowScopeLabel(r) {
if (!r) return ''
const c = r.countryName || r.countryCode || ''
const z = r.zoneName || r.zoneCode || ''
const w = r.weightLabel || ''
return [c, z, w].filter(Boolean).join(' · ')
}
/**
* 打开附加费勾选弹窗
* 列表 = 价格基础 → 附加费配置 主表全量(不要求产品已做关联);
* 勾选回显 = 作用范围内明细行当前附加费的并集;
* scopeKey 传行级键时只作用于该行(国家+重量段),不传则产品级全覆盖。
*/
async function openSurcharge(row, scopeKey, scopeName) {
await props.refData.ensureAllSurcharges()
scRows.value = row.rows || []
scScopeKey.value = scopeKey || null
const targets = scScopeKey.value
? scRows.value.filter(r => rowScopeKey(r) === scScopeKey.value)
: scRows.value
scScopeName.value = scopeName || (scScopeKey.value
? ((targets[0] && rowScopeLabel(targets[0])) || '')
: '')
scList.value = props.refData.allSurchargeConfigs()
const picked = new Set()
targets.forEach(r => {
(r.surcharges || []).forEach(s => {
if (s && s.scId != null) picked.add(String(s.scId))
})
})
scPickedIds.value = [...picked]
scTitle.value = row.productCode
+ (scScopeName.value ? ' · ' + scScopeName.value : '')
+ ' · 附加费'
scVisible.value = true
}
/**
* 勾选结果写回:行级只改该行(国家+重量段),产品级覆盖该产品全部明细行;同一附加费只留一条。
*
* 校验口径:附加费的 country_code 是它自身的计费适用国家(如「燃油附加费 RO」仅对罗马
* 尼亚计费),勾选挂载时【按目标明细行的国家做适用性校验】——国家码不匹配的附加费
* 不落到该行(该行计费时也不会命中它,见 TmsPriceQuoteServiceImpl.surchargeApplies),
* 并统计不匹配数提示用户。country_code 为空 = 全部国家,直接适用。
*/
function onSurchargeConfirm(picked) {
const ids = new Set((picked || []).map(s => String(s.scId)))
const targets = scScopeKey.value
? scRows.value.filter(r => rowScopeKey(r) === scScopeKey.value)
: scRows.value
let applied = 0
let skipped = 0
targets.forEach(r => {
const seen = new Set()
const list = []
scList.value.forEach(rel => {
const key = String(rel.scId)
if (!ids.has(key) || seen.has(key)) return
if (!surchargeAppliesToRow(rel, r)) {
skipped++
return
}
seen.add(key)
list.push(rel)
})
r.surcharges = list
applied += list.length
})
const scopeText = scScopeName.value ? `(仅 ${scScopeName.value})` : ''
if (!scList.value.length) {
proxy.$modal.msgWarning('附加费配置列表为空,请先到「价格基础 → 附加费配置」维护')
return
}
if (ids.size && !applied) {
proxy.$modal.msgWarning('已勾选但未生效:这些附加费配置了适用国家/重量段,与当前明细行不匹配')
return
}
if (skipped > 0) {
Promise.all(targets.filter(r => r.psdId).map(r => persistDetail(r, true))).finally(() => {
proxy.$modal.msgWarning(`已纳入 ${applied} 项附加费${scopeText};${skipped} 项因适用国家不匹配被跳过(已写库)`)
})
return
}
const okText = ids.size ? `已纳入 ${ids.size} 项附加费${scopeText}` : `已清空附加费${scopeText}`
Promise.all(targets.filter(r => r.psdId).map(r => persistDetail(r, true))).then(() => {
proxy.$modal.msgSuccess(okText + ',已写入数据库')
}).catch(() => {
proxy.$modal.msgSuccess(okText)
})
}
async function removeProduct(code) {
const ids = props.details.filter(d => d.productCode === code && d.psdId).map(d => d.psdId)
if (ids.length) {
try {
await delSetDetail(ids.join(','))
} catch (e) {
return
}
}
for (let i = props.details.length - 1; i >= 0; i--) {
if (props.details[i].productCode === code) props.details.splice(i, 1)
}
proxy.$modal.msgSuccess('已移除产品明细')
}
defineExpose({ pullAllFromLibrary })
</script>
<style scoped>
@@ -193,6 +475,7 @@ function addRow() {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.sub {
margin-left: 8px;
@@ -200,10 +483,16 @@ function addRow() {
font-weight: normal;
font-size: 13px;
}
.mb12 {
.mb12 { margin-bottom: 12px; }
.filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.muted {
color: #c0c4cc;
.unit {
margin-left: 8px;
color: #909399;
}
</style>
@@ -0,0 +1,146 @@
<template>
<div class="step-version">
<el-card shadow="never" class="step-card summary-card">
<template #header>
<div class="card-head">
<span>{{ summaryTitle }}</span>
<el-tag size="small" type="info" effect="plain">{{ summaryHint }}</el-tag>
</div>
</template>
<el-descriptions :column="2" border class="summary">
<el-descriptions-item :label="labelName">{{ basic.priceSetName || '-' }}</el-descriptions-item>
<el-descriptions-item :label="labelType">{{ setTypeLabel(basic.setType) }}</el-descriptions-item>
<el-descriptions-item v-if="isDiscountType(basic.setType)" :label="labelDiscount">
{{ basic.defaultDiscount }}%
</el-descriptions-item>
<el-descriptions-item :label="labelNote" :span="2">{{ basic.priceSetNote || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="customer-block">
<div class="customer-head">
<span class="customer-title">{{ customerTitle }}</span>
<span class="customer-count">{{ boundCustomers.length }}</span>
</div>
<el-table :data="boundCustomers" border size="small" empty-text="暂未绑定客户">
<el-table-column :label="colCode" prop="customerCode" min-width="140" show-overflow-tooltip />
<el-table-column :label="colName" prop="customerName" min-width="160" show-overflow-tooltip />
<el-table-column :label="colPriority" prop="bindPriority" width="110" align="center" />
</el-table>
</div>
</el-card>
<el-card shadow="never" class="step-card version-card">
<template #header><span>{{ title }}</span></template>
<el-form ref="formRef" :model="version" :rules="rules" label-width="120px" class="max-form">
<el-form-item :label="labelVersionNo">
<el-input :model-value="version.versionNo" disabled style="width: 280px" />
<div class="form-hint">{{ hintVersionNo }}</div>
</el-form-item>
<el-form-item :label="labelEffTime" prop="effectiveTime">
<el-date-picker
v-model="version.effectiveTime"
type="datetime"
value-format="YYYY-MM-DD HH:mm:ss"
:placeholder="phEffTime"
style="width: 280px"
/>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup name="StepVersion">
import { isDiscountType, setTypeLabel } from '../../utils/priceSet'
const version = defineModel({ type: Object, required: true })
const props = defineProps({
basic: { type: Object, required: true },
customers: { type: Array, default: () => [] }
})
const formRef = ref()
const title = '第 2 步 · 版本与生效时间'
const labelVersionNo = '版本号'
const hintVersionNo = '自动生成,下一个版本 +1'
const labelEffTime = '生效时间'
const phEffTime = '请选择版本生效时间'
const summaryTitle = '第 1 步信息回显'
const summaryHint = '仅回显,不可编辑'
const labelName = '套餐名称'
const labelType = '计价类型'
const labelDiscount = '默认折扣'
const labelNote = '说明'
const customerTitle = '已绑定客户'
const colCode = '客户代码'
const colName = '客户名称'
const colPriority = '优先级'
const rules = {
effectiveTime: [{ required: true, message: '请填写版本生效时间', trigger: 'change' }]
}
const boundCustomers = computed(() => (props.customers || []).filter(c => c.customerCode))
function validate() {
return new Promise(resolve => {
if (!formRef.value) return resolve(false)
formRef.value.validate(valid => resolve(!!valid))
})
}
defineExpose({ validate })
</script>
<style scoped>
.step-version {
display: flex;
flex-direction: column;
gap: 12px;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.max-form {
max-width: 760px;
}
.form-hint {
font-size: 12px;
color: #909399;
line-height: 1.4;
margin-top: 4px;
}
.summary-card :deep(.el-card__body) {
padding-top: 12px;
}
.customer-block {
margin-top: 16px;
}
.customer-head {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
}
.customer-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.customer-count {
margin-left: auto;
min-width: 22px;
height: 22px;
line-height: 22px;
text-align: center;
border-radius: 11px;
background: #f0f2f5;
color: #606266;
font-size: 12px;
padding: 0 8px;
}
</style>
@@ -0,0 +1,54 @@
<template>
<div class="sc-count">
<el-popover
v-if="list.length"
placement="top"
:width="380"
trigger="hover"
>
<template #default>
<div class="sc-count__pop-title">已纳入 {{ list.length }} 项附加费</div>
<surcharge-tags :list="list" :popover="false" empty-text="-" />
</template>
<template #reference>
<el-link type="primary" :underline="false" @click="emit('click')">
{{ list.length }} 项
</el-link>
</template>
</el-popover>
<el-link v-else type="info" :underline="false" @click="emit('click')">0</el-link>
</div>
</template>
<script setup name="SurchargeCountCell">
/**
* 套餐 · 附加费数量单元格(新建第 3 步产品列表 / 价格编辑抽屉共用)
*
* 显示当前数量(默认 0);hover 气泡看具体是哪几项,点击进入勾选弹窗调整。
*/
import SurchargeTags from '../SurchargeTags.vue'
defineProps({
/** 该行 / 该产品当前纳入的附加费列表 */
list: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['click'])
</script>
<style scoped>
.sc-count {
display: inline-flex;
align-items: center;
gap: 4px;
}
.sc-count__pop-title {
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: #606266;
}
</style>
@@ -0,0 +1,137 @@
<template>
<el-dialog
v-model="visible"
:title="title"
width="1000px"
append-to-body
class="ps-dialog"
>
<div class="ps-picker-bar">
<span>共 {{ rows.length }} 项附加费配置,勾选后写入所选范围的明细行</span>
<span class="ps-picker-bar__picked">已选 {{ picked.length }} 项</span>
</div>
<el-table
ref="tableRef"
:data="rows"
row-key="scId"
border
size="small"
max-height="400"
empty-text="暂无附加费配置"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="46" />
<el-table-column label="附加费名称" prop="scName" min-width="150" show-overflow-tooltip />
<el-table-column label="计费方式" width="200" align="center">
<template #default="{ row }">{{ chargeTypeLabel(row.chargeType) }}</template>
</el-table-column>
<el-table-column label="计费值" width="130" align="right">
<template #default="{ row }">{{ formatChargeValue(row) }}</template>
</el-table-column>
<el-table-column label="作用范围" min-width="170" show-overflow-tooltip>
<template #default="{ row }">{{ row.scopeText }}</template>
</el-table-column>
</el-table>
<div class="ps-hint ps-hint--block">取消勾选即不纳入本套餐;「作用范围」表示该附加费自身计费适用的国家(如「燃油附加费 RO」仅对罗马尼亚计费),勾选后按所选范围写回明细行,计费时再按国家匹配。</div>
<template #footer>
<el-button @click="visible = false">取 消</el-button>
<el-button type="primary" @click="submit">确 定</el-button>
</template>
</el-dialog>
</template>
<script setup name="SurchargePickerDialog">
/**
* 套餐 · 附加费勾选弹窗(新建流程第 3 步)
*
* 数据来源:产品已关联的附加费配置(prc_surcharge_rel ∩ prc_surcharge),
* 与明细表格里「附加费」列展示的口径一致;勾选结果由父级写回该产品的明细行。
*/
import { formatChargeValue, surchargeScopeLabel } from '../../utils/priceSet'
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: '附加费' },
/** 附加费配置主表全量(refData.allSurchargeConfigs()) */
list: { type: Array, default: () => [] },
/** 当前已勾选的 scId 集合,用于打开时回显 */
selectedIds: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const { bms_charge_type } = useDict('bms_charge_type')
const tableRef = ref()
const picked = ref([])
const visible = computed({
get: () => props.modelValue,
set: v => emit('update:modelValue', v)
})
/** 同一附加费可能同时挂在产品级与国家/重量段上,按 scId 合并成一行,作用范围拼接展示 */
const rows = computed(() => {
const map = new Map()
;(props.list || []).forEach(rel => {
if (!rel || rel.scId == null) return
const key = String(rel.scId)
if (!map.has(key)) {
map.set(key, { ...rel, scopeTexts: [] })
}
const item = map.get(key)
const text = surchargeScopeLabel(rel)
if (!item.scopeTexts.includes(text)) item.scopeTexts.push(text)
})
return [...map.values()].map(item => ({
...item,
scopeText: item.scopeTexts.join(' / ')
}))
})
function chargeTypeLabel(val) {
const found = (bms_charge_type.value || []).find(c => c.value === val)
return found ? found.label : (val || '-')
}
function onSelectionChange(selection) {
picked.value = selection
}
/** 确定:以表格当前选中为准(回显/重渲染可能清掉 selection,不能只信 selection-change 的快照) */
function submit() {
const table = tableRef.value
let list = picked.value
if (table && typeof table.getSelection === 'function') {
const cur = table.getSelection() || []
if (cur.length || !list.length) list = cur
}
emit('confirm', list)
visible.value = false
}
/** 回显勾选:首次打开时 el-dialog 内容才挂载,取不到表格就再等一帧(最多 5 帧) */
function applySelection(ids, tries) {
const table = tableRef.value
if (!table) {
if ((tries || 0) < 5) nextTick(() => applySelection(ids, (tries || 0) + 1))
return
}
table.clearSelection()
rows.value.forEach(row => {
table.toggleRowSelection(row, ids.includes(String(row.scId)))
})
picked.value = rows.value.filter(row => ids.includes(String(row.scId)))
}
watch(() => props.modelValue, v => {
if (!v) return
const ids = (props.selectedIds || []).map(id => String(id))
nextTick(() => applySelection(ids, 0))
})
</script>
<!-- 非 scoped:弹窗被 append-to-body 传送到 body,样式需全局生效;.ps-picker-bar 工具类见 styles/dialog.scss -->
<style lang="scss" src="../../styles/dialog.scss"></style>
@@ -22,10 +22,7 @@
*/
import SetSectionCard from './SetSectionCard.vue'
import {
formatDateRange,
isDiscountType,
setStatusLabel,
setStatusTagType,
setTypeLabel
} from '../../utils/priceSet'
@@ -50,8 +47,6 @@ const items = computed(() => {
value: discount ? `${s.defaultDiscount}%` : '不适用(FIXED)',
muted: !discount
},
{ label: '状态', value: setStatusLabel(s), tag: setStatusTagType(s) },
{ label: '有效期', value: formatDateRange(s.effectiveDate, s.expireDate) },
{ label: '创建人', value: s.createBy || '-' },
{ label: '创建时间', value: s.createTime || '-' },
{ label: '最后更新', value: (s.updateTime || '-') + ' ' + updater }
@@ -5,17 +5,6 @@
: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 }">
@@ -29,62 +18,18 @@
<span v-else class="priority">{{ row.bindPriority }}</span>
</template>
</el-table-column>
<el-table-column label="有效期" min-width="220">
<template #default="{ row }">{{ formatDateRange(row.prBegindate, row.prEnddate) }}</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>
<div class="table-hint">解绑后该客户可绑定其他套餐;生效中的套餐修改绑定会即时影响计费命中。</div>
<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 { formatDateRange, 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>
@@ -104,10 +49,4 @@ function remove(row) {
.priority {
color: #303133;
}
.table-hint {
margin-top: 10px;
font-size: 12px;
color: #909399;
line-height: 1.6;
}
</style>
@@ -82,6 +82,7 @@
/>
<span class="ps-field__unit">RMB/KG</span>
</div>
<div class="ps-hint">可留空:原基础表无值时表示不覆盖运费,按产品基础价计费</div>
</el-form-item>
</el-col>
<el-col :span="12">
@@ -6,60 +6,40 @@
<span v-if="set.priceSetCode" class="hd-code">{{ set.priceSetCode }}</span>
</div>
<div class="hd-line hd-meta">
<el-tag :type="statusTagType" size="small" effect="dark">{{ statusText }}</el-tag>
<el-tag :type="isFixedType(set.setType) ? 'warning' : 'primary'" size="small" effect="plain">
{{ setTypeLabel(set.setType) }}
</el-tag>
<span class="hd-range">
<el-icon class="hd-icon"><Calendar /></el-icon>
{{ dateRange }}
</span>
</div>
</div>
<div class="hd-actions">
<el-button @click="emit('back')">返回列表</el-button>
<template v-if="editableStatus">
<el-button type="primary" @click="emit('edit')" v-hasPermi="['price:set:edit']">继续配置</el-button>
<el-button type="primary" plain @click="emit('submit')" v-hasPermi="['price:set:audit']">提交审核</el-button>
</template>
<template v-else-if="set.status === 'A'">
<el-button type="success" @click="emit('approve')" v-hasPermi="['price:set:audit']">审批生效</el-button>
<el-button type="warning" plain @click="emit('reject')" v-hasPermi="['price:set:audit']">驳回</el-button>
</template>
<el-button
v-else-if="set.status === 'E' && !set.expired"
v-if="set.status === 'E' && !set.expired"
type="danger"
plain
@click="emit('disable')"
v-hasPermi="['price:set:audit']"
>停用</el-button>
<el-button v-else-if="set.status === 'S'" type="primary" plain @click="emit('copy')" v-hasPermi="['price:set:add']">复用新建</el-button>
<el-button
v-else-if="set.status === 'S'"
type="primary"
plain
@click="emit('copy')"
v-hasPermi="['price:set:add']"
>复用新建</el-button>
</div>
</div>
</template>
<script setup name="SetDetailHeader">
/**
* 套餐详情 · 页头
* 只负责展示套餐标识 / 状态与派发操作事件,接口调用与确认框由页面处理。
*/
import { SET_STATUS, formatDateRange, isFixedType, setStatusLabel, setStatusTagType, setTypeLabel } from '../../utils/priceSet'
import { isFixedType, setTypeLabel } from '../../utils/priceSet'
const props = defineProps({
/** 套餐详情对象 */
set: {
type: Object,
required: true
}
defineProps({
set: { type: Object, required: true }
})
const emit = defineEmits(['back', 'edit', 'submit', 'approve', 'reject', 'disable', 'copy'])
const statusText = computed(() => setStatusLabel(props.set))
const statusTagType = computed(() => setStatusTagType(props.set))
const dateRange = computed(() => formatDateRange(props.set.effectiveDate, props.set.expireDate))
const editableStatus = computed(() => [SET_STATUS.DRAFT, SET_STATUS.REJECT].includes(props.set.status))
const emit = defineEmits(['back', 'disable', 'copy'])
</script>
<style scoped>
@@ -76,9 +56,7 @@ const editableStatus = computed(() => [SET_STATUS.DRAFT, SET_STATUS.REJECT].incl
border-left: 3px solid #1890ff;
border-radius: 8px;
}
.hd-main {
min-width: 0;
}
.hd-main { min-width: 0; }
.hd-line {
display: flex;
align-items: center;
@@ -101,19 +79,7 @@ const editableStatus = computed(() => [SET_STATUS.DRAFT, SET_STATUS.REJECT].incl
border-radius: 4px;
padding: 1px 8px;
}
.hd-meta {
margin-top: 10px;
}
.hd-range {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #909399;
}
.hd-icon {
font-size: 14px;
}
.hd-meta { margin-top: 10px; }
.hd-actions {
display: flex;
align-items: center;
@@ -36,8 +36,13 @@
<el-table-column label="重量段" prop="weightLabel" min-width="130" />
<el-table-column label="运费单价" min-width="130" align="right">
<template #default="{ row }">
<span class="hl">{{ row.fvValue }}</span>
<span class="unit"> RMB/KG</span>
<template v-if="row.fvValue == null">
<span class="unit">未覆盖</span>
</template>
<template v-else>
<span class="hl">{{ row.fvValue }}</span>
<span class="unit"> RMB/KG</span>
</template>
</template>
</el-table-column>
<el-table-column label="挂号费" min-width="120" align="right">
@@ -57,7 +62,7 @@
<el-table-column label="附加费" min-width="220">
<template #default="{ row }">
<surcharge-tags :list="surchargesOf(row)" />
<surcharge-tags :list="surchargesOfRow(row)" />
</template>
</el-table-column>
@@ -116,6 +121,11 @@ const { bms_round_mode } = useDict('bms_round_mode')
const { surchargesOf } = props.refData
function surchargesOfRow(row) {
if (row && row.surcharges && row.surcharges.length) return row.surcharges
return surchargesOf(row)
}
const dialogRef = ref()
const details = computed(() => props.set.details || [])
@@ -126,7 +136,7 @@ const subTitle = computed(() => isFixedType(props.set.setType)
: '按产品限定折扣,留空走套餐默认折扣')
const hint = computed(() => isFixedType(props.set.setType)
? '运费单价单位 RMB/KG,挂号费单位 RMB/票;附加费来自产品关联配置,此处只读展示。'
? '运费单价单位 RMB/KG(留空表示不覆盖,按产品基础价计费),挂号费单位 RMB/票;附加费来自产品关联配置,此处只读展示。'
: '未配置明细时全部在售产品均适用套餐默认折扣。')
function roundModeLabel(mode) {
@@ -0,0 +1,222 @@
<template>
<el-drawer
:model-value="modelValue"
:title="title"
size="72%"
destroy-on-close
@close="close"
>
<div class="drawer-body" v-loading="loading">
<div class="toolbar">
<el-input
v-model="query.keyword"
clearable
:placeholder="phSearch"
style="width: 280px"
@keyup.enter="search"
/>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="reset">重置</el-button>
</div>
<el-table :data="rows" border height="calc(100vh - 220px)" empty-text="暂无明细">
<el-table-column :label="colProduct" min-width="180">
<template #default="{ row }">
<div class="cell-product">
<span class="p-code">{{ row.productCode }}</span>
<span class="p-name">{{ row.productName || '' }}</span>
</div>
</template>
</el-table-column>
<template v-if="isFixed">
<el-table-column :label="colCountry" min-width="120">
<template #default="{ row }">{{ row.countryName || row.countryCode || '-' }}</template>
</el-table-column>
<el-table-column :label="colZone" min-width="110">
<template #default="{ row }">{{ row.zoneName || row.zoneCode || '-' }}</template>
</el-table-column>
<el-table-column :label="colWeight" prop="weightLabel" min-width="120" />
<el-table-column :label="colFv" min-width="130" align="right">
<template #default="{ row }">
<template v-if="row.fvValue == null">
<span class="unit">未覆盖</span>
</template>
<template v-else>
<span class="hl">{{ row.fvValue }}</span>
<span class="unit"> RMB/KG</span>
</template>
</template>
</el-table-column>
<el-table-column :label="colReg" min-width="120" align="right">
<template #default="{ row }">
<span class="reg">{{ row.fvRegistrationFee }}</span>
<span class="unit"> RMB/票</span>
</template>
</el-table-column>
</template>
<el-table-column v-else :label="colDisc" min-width="120" align="center">
<template #default="{ row }">
<el-tag v-if="row.discountValue == null" size="small" type="info" effect="plain">默认折扣</el-tag>
<span v-else class="pct">{{ row.discountValue }}%</span>
</template>
</el-table-column>
<el-table-column :label="colSc" min-width="200">
<template #default="{ row }">
<surcharge-tags :list="row.surcharges || []" :empty-text="noneSc" />
</template>
</el-table-column>
<el-table-column :label="colRound" min-width="100" align="center">
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
</el-table-column>
</el-table>
<div class="pager">
<pagination
v-show="total > 0"
:total="total"
v-model:page="query.pageNum"
v-model:limit="query.pageSize"
@pagination="load"
/>
</div>
</div>
<template #footer>
<el-button @click="close">关闭</el-button>
</template>
</el-drawer>
</template>
<script setup name="SetVersionDetailDrawer">
import SurchargeTags from '../SurchargeTags.vue'
import { isFixedType } from '../../utils/priceSet'
import { listSetDetail } from '@/api/price/set'
const props = defineProps({
modelValue: { type: Boolean, default: false },
version: { type: Object, default: null },
setType: { type: String, default: 'FIXED' }
})
const emit = defineEmits(['update:modelValue'])
const { bms_round_mode } = useDict('bms_round_mode')
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const query = reactive({
pageNum: 1,
pageSize: 20,
keyword: ''
})
const phSearch = '产品/国家/分区/重量段'
const colProduct = '产品'
const colCountry = '国家'
const colZone = '分区'
const colWeight = '重量段'
const colFv = '运费单价'
const colReg = '挂号费'
const colDisc = '产品折扣'
const colSc = '附加费'
const colRound = '取值方式'
const noneSc = '无'
const isFixed = computed(() => isFixedType(props.setType))
const title = computed(() => {
const v = props.version || {}
return (v.versionNo || '-') + ' · 版本明细'
})
function roundModeLabel(mode) {
const hit = (bms_round_mode.value || []).find(d => d.value === mode)
return hit ? hit.label : (mode || '四舍五入')
}
function close() {
emit('update:modelValue', false)
}
function search() {
query.pageNum = 1
load()
}
function reset() {
query.keyword = ''
query.pageNum = 1
load()
}
function load() {
const psvId = props.version && props.version.psvId
if (!psvId) {
rows.value = []
total.value = 0
return
}
loading.value = true
listSetDetail({
psvId,
keyword: query.keyword || undefined,
pageNum: query.pageNum,
pageSize: query.pageSize
}).then(res => {
rows.value = res.rows || []
total.value = res.total || 0
}).finally(() => {
loading.value = false
})
}
watch(
() => [props.modelValue, props.version && props.version.psvId],
([open]) => {
if (open) {
query.pageNum = 1
query.keyword = ''
load()
}
}
)
</script>
<style scoped>
.drawer-body {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.pager {
margin-top: 12px;
}
.cell-product {
display: flex;
align-items: baseline;
gap: 6px;
}
.p-code {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
color: #606266;
background: #f4f4f5;
border-radius: 3px;
padding: 0 6px;
}
.p-name { color: #303133; }
.hl { color: #1890ff; font-weight: 600; }
.reg { color: #d48806; font-weight: 600; }
.pct { color: #1890ff; font-weight: 600; }
.unit { font-size: 12px; color: #909399; }
</style>
@@ -0,0 +1,131 @@
<template>
<set-section-card :title="title" tone="green" :count="filteredVersions.length">
<el-tabs v-model="statusTab" class="ver-tabs">
<el-tab-pane :label="tabLabel('all')" name="all" />
<el-tab-pane :label="tabLabel('0')" name="0" />
<el-tab-pane :label="tabLabel('1')" name="1" />
<el-tab-pane :label="tabLabel('2')" name="2" />
</el-tabs>
<el-table :data="filteredVersions" border empty-text="暂无版本">
<el-table-column :label="colNo" width="100">
<template #default="{ row }">
<el-button link type="primary" @click="openDetails(row)">{{ row.versionNo || '-' }}</el-button>
</template>
</el-table-column>
<el-table-column :label="colEff" min-width="170">
<template #default="{ row }">{{ row.effectiveTime || '-' }}</template>
</el-table-column>
<el-table-column :label="colExp" min-width="170">
<template #default="{ row }">{{ row.expireTime || '-' }}</template>
</el-table-column>
<el-table-column :label="colStatus" width="100" align="center">
<template #default="{ row }">
<el-tag :type="versionStatusTag(row.status)" size="small">{{ versionStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="colRows" width="100" align="center">
<template #default="{ row }">{{ detailCount(row) }}</template>
</el-table-column>
<el-table-column :label="colAction" width="320" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetails(row)">查看详情</el-button>
<el-button
v-if="String(row.status) === '0'"
link
type="primary"
@click="emit('edit', row)"
v-hasPermi="['price:set:edit']"
>继续配置</el-button>
<el-button
v-if="String(row.status) === '0'"
link
type="success"
@click="emit('activate', row)"
v-hasPermi="['price:set:edit']"
>启动</el-button>
<el-button
v-if="String(row.status) === '0'"
link
type="danger"
@click="emit('remove', row)"
v-hasPermi="['price:set:edit']"
>删除</el-button>
<el-button
v-if="String(row.status) === '1'"
link
type="primary"
@click="emit('copy-version', row)"
v-hasPermi="['price:set:add']"
>复制为新版本</el-button>
</template>
</el-table-column>
</el-table>
</set-section-card>
</template>
<script setup name="SetVersionTable">
import SetSectionCard from './SetSectionCard.vue'
import { versionStatusLabel, versionStatusTag } from '../../utils/priceSet'
const props = defineProps({
versions: { type: Array, default: () => [] },
modelValue: { type: [Number, String], default: undefined }
})
const emit = defineEmits(['update:modelValue', 'copy-version', 'open-details', 'activate', 'edit', 'remove'])
const title = '套餐版本'
const colNo = '版本号'
const colEff = '生效时间'
const colExp = '失效时间'
const colStatus = '状态'
const colRows = '明细行数'
const colAction = '操作'
const statusTab = ref('all')
const counts = computed(() => {
const map = { all: 0, '0': 0, '1': 0, '2': 0 }
;(props.versions || []).forEach(v => {
map.all++
const s = String(v.status == null ? '' : v.status)
if (map[s] != null) map[s]++
})
return map
})
function tabLabel(key) {
const labels = {
all: '全部',
'0': '草稿',
'1': '生效',
'2': '失效'
}
return labels[key] + ' (' + (counts.value[key] || 0) + ')'
}
const filteredVersions = computed(() => {
const list = props.versions || []
if (statusTab.value === 'all') return list
return list.filter(v => String(v.status) === statusTab.value)
})
function detailCount(row) {
if (row && row.detailCount != null) return row.detailCount
return (row && row.details || []).length
}
function openDetails(row) {
if (!row || row.psvId == null) return
emit('update:modelValue', row.psvId)
emit('open-details', row)
}
</script>
<style scoped>
.ver-tabs {
margin-bottom: 8px;
}
.ver-tabs :deep(.el-tabs__header) {
margin-bottom: 8px;
}
</style>
@@ -5,7 +5,7 @@
* 避免产品 / 产品国家 / 附加费 被重复请求。
*/
import { listProduct, listProductCountries } from '@/api/price/product'
import { listSurchargeByProduct } from '@/api/price/surcharge'
import { listSurchargeByProduct, listSurchargeOptions } from '@/api/price/surcharge'
import {
buildCountryOptions,
buildZoneOptions,
@@ -14,6 +14,7 @@ import {
countryKeyOf,
formatWeightLabel,
matchCountryZone,
surchargeAppliesToRow,
toEditableDetailRow
} from '../utils/priceSet'
@@ -25,20 +26,30 @@ export function usePriceSetRefData() {
const productOptions = ref([])
/** productCode -> bms_product_country 行 */
const countryCache = reactive({})
/** productCode -> 进行中的国家明细请求(去重,避免 hydrate 按行打爆接口) */
const countryPending = {}
/** productCode -> 有效附加费关联 */
const surchargeCache = reactive({})
/** productCode -> 进行中的请求 */
const surchargePending = {}
/** 价格基础 → 附加费配置 主表全量(勾选弹窗数据源,页面内共享一份) */
const allSurchargeList = ref(null)
let allSurchargePending = null
const productMap = computed(() => new Map(productOptions.value.map(p => [p.value, p])))
/** 加载可选产品(已生效) */
/** 加载可选产品(已生效),保留列表展示字段 */
function loadProducts() {
return listProduct({ status: '1', pageNum: 1, pageSize: 1000 }).then(res => {
productOptions.value = (res.rows || []).map(p => ({
value: p.productCode,
label: `${p.productCode} ${p.productName || ''}`.trim(),
name: p.productName
name: p.productName,
serviceLevel: p.serviceLevel,
cargoAttr: p.cargoAttr,
destRegion: p.destRegion,
taxMode: p.taxMode,
countryCount: p.countryCount
}))
return productOptions.value
})
@@ -50,15 +61,72 @@ export function usePriceSetRefData() {
return hit ? hit.name : productCode
}
/** 确保产品的国家×重量段配置已加载(过滤已失效配置) */
function ensureCountries(productCode) {
/** product meta for step-3 product list */
function productMetaOf(productCode) {
return productMap.value.get(productCode) || null
}
/** YYYY-MM-DD from datetime / date */
function dateOnly(v) {
if (!v) return null
return String(v).slice(0, 10)
}
/**
* Country row active at asOf (version effective time).
* Window: effective_date <= asOf AND (expire_date is null OR expire_date > asOf)
*/
function isCountryActiveAt(item, asOf) {
const t = dateOnly(asOf)
if (!t) return true
const eff = dateOnly(item.effectiveDate)
const exp = dateOnly(item.expireDate)
if (eff && eff > t) return false
if (exp && exp <= t) return false
return true
}
/** currently open country rows (not expired) */
function isCountryOpen(item) {
if (String(item.status) === '2') return false
const exp = dateOnly(item.expireDate)
if (!exp) return true
const today = dateOnly(new Date().toISOString())
return exp > today
}
/**
* Load product country×weight rows.
* Cache keeps full list; pass asOf (version effective_time) to filter the active window.
* If asOf match is empty, fall back to currently open rows so step-3 can still show full product prices.
* In-flight requests are deduped per productCode (hydrateRow is per-detail-row).
*/
function ensureCountries(productCode, asOf) {
if (!productCode) return Promise.resolve([])
if (countryCache[productCode]) return Promise.resolve(countryCache[productCode])
return listProductCountries(productCode).then(res => {
const rows = (res.data || []).filter(item => !item.expireDate)
const filterRows = (rows) => {
const all = rows || []
if (!asOf) return all.filter(isCountryOpen)
const hit = all.filter(item => isCountryActiveAt(item, asOf))
if (hit.length) return hit
return all.filter(isCountryOpen)
}
if (countryCache[productCode]) {
return Promise.resolve(filterRows(countryCache[productCode]))
}
if (countryPending[productCode]) {
return countryPending[productCode].then(filterRows)
}
countryPending[productCode] = listProductCountries(productCode).then(res => {
const rows = res.data || []
countryCache[productCode] = rows
return rows
}).catch(() => {
// do not cache failure; allow later retry without stampeding while in-flight
return []
}).finally(() => {
delete countryPending[productCode]
})
return countryPending[productCode].then(filterRows)
}
/** 确保产品的有效附加费已加载(剔除 OF 燃油,结果缓存 + 请求去重) */
@@ -71,7 +139,7 @@ export function usePriceSetRefData() {
surchargeCache[productCode] = rows
return rows
}).catch(() => {
surchargeCache[productCode] = []
// 失败不写缓存,后续可重试(避免一次抖动就让该产品永久显示无附加费)
return []
}).finally(() => {
delete surchargePending[productCode]
@@ -79,18 +147,41 @@ export function usePriceSetRefData() {
return surchargePending[productCode]
}
/** 产品已关联的附加费全量(不按国家/重量段过滤),供套餐勾选使用 */
function allSurchargesOf(productCode) {
if (!productCode) return []
return surchargeCache[productCode] || []
}
/** 确保附加费配置主表已加载(剔除 OF 燃油,页面内共享一份缓存) */
function ensureAllSurcharges() {
if (allSurchargeList.value) return Promise.resolve(allSurchargeList.value)
if (!allSurchargePending) {
allSurchargePending = listSurchargeOptions().then(res => {
// /options 返回 AjaxResult(data),兼容分页接口的 rows 写法
const raw = res.data || res.rows || []
const rows = raw.filter(item => String(item.ssCode || '').toLowerCase() !== 'of')
allSurchargeList.value = rows
return rows
}).catch(() => {
// 失败不缓存,允许下次重试;调用方拿空数组也不至于报错中断
return []
}).finally(() => {
allSurchargePending = null
})
}
return allSurchargePending
}
/** 附加费配置主表全量(需先 ensureAllSurcharges) */
function allSurchargeConfigs() {
return allSurchargeList.value || []
}
/** 明细行命中的附加费(按产品 + 国家 + 重量段过滤) */
function surchargesOf(row) {
if (!row || !row.productCode) return []
const all = surchargeCache[row.productCode] || []
return all.filter(s => {
if (s.countryCode && row.countryCode && String(s.countryCode).toUpperCase() !== String(row.countryCode).toUpperCase()) {
return false
}
if (s.detailId == null) return true
if (row.productCountryId != null && Number(s.detailId) === Number(row.productCountryId)) return true
return false
})
return (surchargeCache[row.productCode] || []).filter(s => surchargeAppliesToRow(s, row))
}
/** 明细行可选国家 */
@@ -115,8 +206,12 @@ export function usePriceSetRefData() {
productOptions,
loadProducts,
productNameOf,
productMetaOf,
ensureCountries,
ensureSurcharges,
allSurchargesOf,
ensureAllSurcharges,
allSurchargeConfigs,
surchargesOf,
countryOptionsOf,
zoneOptionsOf,
@@ -140,9 +235,13 @@ export function useDetailCascade(refData) {
row.zoneCode = undefined
row.zoneName = undefined
row.weightLabel = undefined
row.weightMin = undefined
row.weightMax = undefined
row.productCountryId = undefined
row.fvValue = undefined
row.fvRegistrationFee = undefined
row.effectiveDate = undefined
row.expireDate = undefined
}
/** 选择产品:回填名称、清空级联、预取附加费与国家配置 */
@@ -163,9 +262,13 @@ export function useDetailCascade(refData) {
row.zoneCode = undefined
row.zoneName = undefined
row.weightLabel = undefined
row.weightMin = undefined
row.weightMax = undefined
row.productCountryId = undefined
row.fvValue = undefined
row.fvRegistrationFee = undefined
row.effectiveDate = undefined
row.expireDate = undefined
}
function onZoneChange(row) {
@@ -174,9 +277,13 @@ export function useDetailCascade(refData) {
row.zoneCode = opt ? opt.zoneCode : undefined
row.zoneName = opt ? opt.zoneName : undefined
row.weightLabel = undefined
row.weightMin = undefined
row.weightMax = undefined
row.productCountryId = undefined
row.fvValue = undefined
row.fvRegistrationFee = undefined
row.effectiveDate = undefined
row.expireDate = undefined
}
/** 选择重量段:绑定产品国家明细,并带出默认运费 / 挂号费 */
@@ -184,6 +291,10 @@ export function useDetailCascade(refData) {
const opt = refData.weightOptionsOf(row).find(w => w.value === row.weightLabel)
if (!opt) {
row.productCountryId = undefined
row.weightMin = undefined
row.weightMax = undefined
row.effectiveDate = undefined
row.expireDate = undefined
return
}
row.productCountryId = opt.detailId
@@ -192,14 +303,32 @@ export function useDetailCascade(refData) {
if (opt.zoneName) row.zoneName = opt.zoneName
if (opt.freightPrice != null) row.fvValue = Number(opt.freightPrice)
if (opt.registrationFee != null) row.fvRegistrationFee = Number(opt.registrationFee)
row.weightMin = opt.weightMin
row.weightMax = opt.weightMax
row.effectiveDate = opt.effectiveDate
row.expireDate = opt.expireDate
}
/** hydrate detail row; normalize weight label to min<W\u2264max */
function hydrateRow(d, setType) {
const row = toEditableDetailRow(d)
if (!row.productCode) return row
refData.ensureSurcharges(row.productCode)
// Do NOT prefetch /surcharge/byProduct here: applySetData hydrates every detail row
// on step-1 load; use version snapshot row.surcharges instead. Fetch on demand in picker.
if (setType !== 'FIXED') return row
// Bulk load from DB already has productCountryId + weight bounds: normalize locally,
// do not fire /countries per detail row (was stampeding thousands of identical requests).
if (row.productCountryId != null && (row.weightMin != null || row.weightMax != null || row.weightLabel)) {
if (row.weightMin != null || row.weightMax != null) {
row.weightLabel = formatWeightLabel(row.weightMin, row.weightMax)
}
row._countryKey = countryKeyOf(row)
// Snapshot from DB: treat current package price as baseline so drawer cells stay editable
// and yellow "changed" highlight still works after first edit.
if (row._baseFreight == null && row.fvValue != null) row._baseFreight = Number(row.fvValue)
if (row._baseReg == null && row.fvRegistrationFee != null) row._baseReg = Number(row.fvRegistrationFee)
return row
}
refData.ensureCountries(row.productCode).then(rows => {
if (!row.weightLabel) return
const hit = rows.find(item => {
@@ -217,6 +346,12 @@ export function useDetailCascade(refData) {
if (hit.zoneId != null) row.zoneId = hit.zoneId
if (hit.zoneCode) row.zoneCode = hit.zoneCode
if (hit.zoneName) row.zoneName = hit.zoneName
row.weightMin = hit.weightMin
row.weightMax = hit.weightMax
row.effectiveDate = hit.effectiveDate
row.expireDate = hit.expireDate
if (row._baseFreight == null && hit.freightPrice != null) row._baseFreight = Number(hit.freightPrice)
if (row._baseReg == null && hit.registrationFee != null) row._baseReg = Number(hit.registrationFee)
})
return row
}
+188 -84
View File
@@ -1,59 +1,61 @@
<template>
<div class="app-container">
<el-steps :active="active" finish-status="success" align-center class="wizard-steps">
<el-step title="基本信息" />
<el-step title="套餐明细" />
<el-step title="绑定客户" />
<el-step :title="step1Title" />
<el-step :title="step2Title" />
</el-steps>
<!-- 第 1 步 -->
<step-basic-info v-show="active === 0" ref="basicRef" v-model="form.basic" />
<div v-show="active === 0">
<step-basic-info ref="basicRef" v-model="form.basic" />
</div>
<!-- 第 2 步 -->
<step-details
v-show="active === 1"
ref="detailsRef"
:details="form.details"
:price-set-id="form.priceSetId"
:set-type="form.basic.setType"
:version-no="form.version.versionNo"
:ref-data="refData"
/>
<!-- 第 3 步 -->
<step-customers
v-show="active === 2"
:customers="form.customers"
:effective-date="form.basic.effectiveDate"
:expire-date="form.basic.expireDate"
/>
<div class="wizard-actions">
<el-button @click="goBack">取消</el-button>
<el-button v-if="active > 0" @click="active--">上一步</el-button>
<el-button v-if="active < 2" type="primary" @click="nextStep">下一步</el-button>
<el-button type="info" :loading="saving" @click="saveDraft">保存草稿</el-button>
<el-button type="primary" :loading="saving" @click="saveAndSubmit">保存并提交审核</el-button>
<el-button v-if="active > 0 && !directStep2" @click="active--">上一步</el-button>
<el-button v-if="active === 0" type="primary" :loading="creating" @click="goStep2">创建套餐价格</el-button>
<el-button v-if="active === 0" type="info" :loading="saving" @click="saveDraft">保存草稿</el-button>
<el-button v-if="active === 1" type="primary" :loading="saving" @click="saveDraft">保存</el-button>
</div>
<el-dialog
v-model="progressVisible"
:title="'正在保存套餐'"
width="420px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
append-to-body
>
<div class="save-progress">
<el-progress :percentage="progressPercent" :status="progressStatus" />
<p class="save-progress__msg">{{ progressMessage }}</p>
<p class="save-progress__hint">请勿关闭页面,大量明细保存可能需要一些时间</p>
</div>
</el-dialog>
</div>
</template>
<script setup name="PriceSetCreate">
/**
* 新建 / 编辑套餐 · 流程编排
*
* 三步表单已拆分到 components/,页面只负责:
* 步骤切换、保存前校验、payload 组装、路由返回。
* 参考数据(产品 / 国家 / 附加费)由 composables 提供,整页共享一份缓存。
*/
import StepBasicInfo from './components/create/StepBasicInfo.vue'
import StepDetails from './components/create/StepDetails.vue'
import StepCustomers from './components/create/StepCustomers.vue'
import { usePriceSetRefData, useDetailCascade } from './composables/usePriceSetRefData'
import {
createEmptyBasicInfo,
createEmptyDetailRow,
nextVersionNo,
toCustomerPayload,
toDetailPayload
} from './utils/priceSet'
import { getPriceSet, savePriceSet } from '@/api/price/set'
import { getPriceSet, savePriceSetAsync } from '@/api/price/set'
const { proxy } = getCurrentInstance()
const route = useRoute()
@@ -63,71 +65,144 @@ const cascade = useDetailCascade(refData)
const active = ref(0)
const saving = ref(false)
const creating = ref(false)
const progressVisible = ref(false)
const progressPercent = ref(0)
const progressMessage = ref('')
const progressStatus = ref(undefined)
const basicRef = ref()
const detailsRef = ref()
const copyMode = computed(() => String(route.query.copyVersion || '') === '1')
/** Entered from detail: continue-config / copy-version jump to step2 — hide 上一步 */
const directStep2 = computed(() => copyMode.value || String(route.query.step || '') === '2')
const step1Title = '基本信息'
const step2Title = '编辑产品价格'
/** 整个套餐表单:基本信息 / 明细 / 绑定客户 */
const form = reactive({
priceSetId: undefined,
basic: createEmptyBasicInfo(),
version: { psvId: undefined, versionNo: 'V1', effectiveTime: undefined, expireTime: undefined },
details: [],
customers: []
})
/** 下一步:仅第 1 步需要校验 */
async function nextStep() {
if (active.value === 0) {
const valid = await basicRef.value.validate()
if (!valid) return
function mapSaveProgressMessage(message) {
const m = message || ''
if (!m) return ''
if (m === 'queued') return '排队中'
if (m === 'pulling library') return '拉取产品库'
if (m === 'details exist, skip pull') return '明细已存在,跳过拉取'
if (m === 'saving basic') return '保存基本信息'
if (m === 'saving version') return '保存版本'
if (m.startsWith('saving details ')) return '保存明细 ' + m.slice('saving details '.length)
if (m === 'saving surcharges') return '保存附加费'
if (m === 'saving customers') return '保存客户'
if (m === 'finishing') return '收尾中'
if (m === 'done') return '完成'
if (m === 'failed') return '失败'
return m
}
async function runSaveWithProgress(payload) {
progressVisible.value = true
progressPercent.value = 0
progressMessage.value = ''
progressStatus.value = undefined
try {
const result = await savePriceSetAsync(payload, (task) => {
progressPercent.value = Math.min(100, Number(task.percent) || 0)
progressMessage.value = mapSaveProgressMessage(task.message)
if (task.status === 'FAIL') {
progressStatus.value = 'exception'
} else if (task.status === 'SUCCESS') {
progressStatus.value = 'success'
} else {
progressStatus.value = undefined
}
})
progressPercent.value = 100
progressStatus.value = 'success'
return result.priceSetId
} catch (e) {
progressStatus.value = 'exception'
const msg = (e && e.task && e.task.error) || (e && e.message) || '保存失败'
progressMessage.value = msg
if (proxy && proxy.$modal) {
proxy.$modal.msgError(msg)
}
await new Promise(resolve => setTimeout(resolve, 800))
throw e
} finally {
progressVisible.value = false
}
active.value++
}
function saveDraft() {
save(false)
/** Step1 -> Step2: create package prices from library when needed */
async function goStep2() {
const validBasic = await basicRef.value.validate()
if (!validBasic) return
const hasPersistedDetails = (form.details || []).some(d => d && d.psdId)
if (form.priceSetId && form.version.psvId && hasPersistedDetails) {
active.value = 1
return
}
creating.value = true
try {
form.details.splice(0, form.details.length)
const priceSetId = await runSaveWithProgress({
...buildPayload(),
details: [],
pullFromLibrary: 'Y'
})
form.priceSetId = priceSetId
const fresh = await getPriceSet(priceSetId)
applySetData(fresh.data || {}, false)
active.value = 1
proxy.$modal.msgSuccess('已创建套餐价格,可在第 2 步编辑')
} catch (e) {
// surfaced
} finally {
creating.value = false
}
}
function saveAndSubmit() {
save(true)
}
/** 校验 + 保存;校验失败回到第 1 步 */
async function save(submit) {
const valid = await basicRef.value.validate()
if (!valid) {
async function saveDraft() {
const validBasic = await basicRef.value.validate()
if (!validBasic) {
active.value = 0
return
}
const ids = form.customers.filter(c => c.customerId).map(c => String(c.customerId))
if (new Set(ids).size !== ids.length) {
proxy.$modal.msgError('该客户已绑定套餐,一个客户只能绑定一个套餐')
return
}
saving.value = true
savePriceSet(buildPayload(submit)).then(res => {
form.priceSetId = res.data
proxy.$modal.msgSuccess(submit ? '已保存并提交审核' : '草稿已保存,套餐ID=' + res.data)
try {
const priceSetId = await runSaveWithProgress(buildPayload())
form.priceSetId = priceSetId
proxy.$modal.msgSuccess('草稿已保存,套餐ID=' + priceSetId)
goBack()
}).catch(() => {
// 错误提示由请求拦截器统一处理
}).finally(() => {
} catch (e) {
// surfaced
} finally {
saving.value = false
})
}
}
/** 表单 -> 后端 payload */
function buildPayload(submitFlag) {
const { basic } = form
function buildPayload() {
const { basic, version } = form
return {
priceSetId: form.priceSetId,
priceSetName: basic.priceSetName,
setType: basic.setType,
defaultDiscount: basic.setType === 'DISCOUNT' ? basic.defaultDiscount : undefined,
effectiveDate: basic.effectiveDate,
expireDate: basic.expireDate || undefined,
priceSetNote: basic.priceSetNote,
version: {
psvId: copyMode.value ? undefined : version.psvId,
versionNo: version.versionNo
// no effectiveTime: set on activate
},
details: form.details.filter(d => d.productCode).map(toDetailPayload),
customers: form.customers.filter(c => c.customerCode).map(toCustomerPayload),
submitFlag: submitFlag ? 'Y' : undefined
customers: form.customers.filter(c => c.customerCode).map(toCustomerPayload)
}
}
@@ -140,21 +215,44 @@ function goBack() {
}
}
/** 编辑 / 复用新建:回显已有套餐 */
function loadExisting(id) {
function applySetData(data, asCopy) {
form.priceSetId = data.priceSetId
Object.assign(form.basic, {
priceSetName: data.priceSetName,
setType: data.setType || 'FIXED',
defaultDiscount: data.defaultDiscount != null ? Number(data.defaultDiscount) : 88,
priceSetNote: data.priceSetNote
})
const versions = data.versions || []
const qPsv = route.query.psvId
const byQuery = qPsv != null ? versions.find(v => String(v.psvId) === String(qPsv)) : null
const current = byQuery || data.version || versions[0] || {}
if (asCopy) {
form.version.psvId = undefined
form.version.versionNo = nextVersionNo(versions)
form.version.effectiveTime = undefined
form.version.expireTime = undefined
} else {
form.version.psvId = current.psvId
form.version.versionNo = current.versionNo || nextVersionNo(versions)
form.version.effectiveTime = current.effectiveTime
form.version.expireTime = current.expireTime
}
const srcDetails = (current.details && current.details.length) ? current.details : (data.details || [])
form.details = srcDetails.map(d => {
const row = cascade.hydrateRow(d, form.basic.setType)
if (asCopy) row.psdId = undefined
return row
})
form.customers = (data.customers || []).map(c => ({ ...c }))
}
function loadExisting(id, asCopy) {
getPriceSet(id).then(res => {
const data = res.data || {}
form.priceSetId = data.priceSetId
Object.assign(form.basic, {
priceSetName: data.priceSetName,
setType: data.setType || 'FIXED',
defaultDiscount: data.defaultDiscount != null ? Number(data.defaultDiscount) : 88,
effectiveDate: data.effectiveDate,
expireDate: data.expireDate,
priceSetNote: data.priceSetNote
})
form.details = (data.details || []).map(d => cascade.hydrateRow(d, form.basic.setType))
form.customers = (data.customers || []).map(c => ({ ...c }))
applySetData(res.data || {}, asCopy)
if (asCopy || String(route.query.step || '') === '2') {
active.value = 1
}
})
}
@@ -162,9 +260,7 @@ onMounted(() => {
refData.loadProducts().finally(() => {
const id = route.query.priceSetId
if (id) {
loadExisting(id)
} else {
form.details.push(createEmptyDetailRow())
loadExisting(id, copyMode.value)
}
})
})
@@ -177,11 +273,19 @@ onMounted(() => {
padding: 20px 12px 12px;
border-radius: 6px;
}
.step-card {
margin-bottom: 16px;
}
.wizard-actions {
text-align: center;
padding: 8px 0 24px;
}
.save-progress__msg {
margin: 12px 0 4px;
color: #606266;
font-size: 13px;
word-break: break-all;
}
.save-progress__hint {
margin: 0;
color: #909399;
font-size: 12px;
}
</style>
+68 -39
View File
@@ -3,62 +3,72 @@
<set-detail-header
:set="set"
@back="goList"
@edit="goEdit"
@submit="onSubmit"
@approve="onApprove"
@reject="onReject"
@disable="onDisable"
@copy="onCopy"
/>
<set-basic-info-card :set="set" />
<set-detail-table :set="set" :ref-data="refData" @refresh="load" />
<set-customer-table :set="set" />
<set-customer-table :set="set" @refresh="load" />
<set-version-table
v-model="currentPsvId"
:versions="set.versions || []"
@copy-version="onCopyVersion"
@open-details="onOpenVersionDetails"
@activate="onActivateVersion"
@edit="onEditVersion"
@remove="onRemoveVersion"
/>
<set-version-detail-drawer
v-model="detailDrawerVisible"
:version="detailVersion"
:set-type="set.setType"
/>
</div>
</template>
<script setup name="PriceSetDetail">
/**
* 套餐详情 · 页面编排
*
* 展示区拆到 components/:页头、基本信息、明细表(含明细弹窗)、客户表(含客户弹窗);
* 本页只负责:加载详情、状态流转操作、向子组件下发共享参考数据。
*/
import SetDetailHeader from './components/detail/SetDetailHeader.vue'
import SetBasicInfoCard from './components/detail/SetBasicInfoCard.vue'
import SetDetailTable from './components/detail/SetDetailTable.vue'
import SetCustomerTable from './components/detail/SetCustomerTable.vue'
import SetVersionTable from './components/detail/SetVersionTable.vue'
import SetVersionDetailDrawer from './components/detail/SetVersionDetailDrawer.vue'
import { usePriceSetRefData } from './composables/usePriceSetRefData'
import {
getPriceSet, submitPriceSet, approvePriceSet, rejectPriceSet, disablePriceSet, copyPriceSet
getPriceSet, disablePriceSet, copyPriceSet, activatePriceSetVersion, copyPriceSetVersion, deletePriceSetVersion
} from '@/api/price/set'
const { proxy } = getCurrentInstance()
const route = useRoute()
/** 产品 / 国家 / 附加费 参考数据与明细弹窗级联共用,与新建页同一套实现 */
const refData = usePriceSetRefData()
const loading = ref(false)
const set = ref({})
const currentPsvId = ref()
const detailDrawerVisible = ref(false)
const detailVersion = ref(null)
function onOpenVersionDetails(row) {
detailVersion.value = row || null
detailDrawerVisible.value = true
}
/** 拉取套餐详情,并预取明细涉及产品的附加费 */
function load() {
const id = route.query.priceSetId
if (!id) return
loading.value = true
getPriceSet(id).then(res => {
set.value = res.data || {}
const codes = [...new Set((set.value.details || []).map(d => d.productCode).filter(Boolean))]
codes.forEach(code => refData.ensureSurcharges(code))
const versions = set.value.versions || []
const cur = set.value.version || versions[0]
currentPsvId.value = cur ? cur.psvId : undefined
}).finally(() => {
loading.value = false
})
}
/** 二次确认 -> 调接口 -> 提示 -> 刷新(或跳转) */
function confirmAction(text, request, successText, done) {
proxy.$modal.confirm(text).then(() => request()).then(() => {
proxy.$modal.msgSuccess(successText)
@@ -66,30 +76,53 @@ function confirmAction(text, request, successText, done) {
}).catch(() => {})
}
function onSubmit() {
confirmAction('确认提交审核?', () => submitPriceSet(set.value.priceSetId), '已提交审核')
}
function onApprove() {
confirmAction('确认审批生效?', () => approvePriceSet(set.value.priceSetId), '套餐已生效')
}
function onReject() {
confirmAction('确认驳回该套餐?', () => rejectPriceSet(set.value.priceSetId), '已驳回')
}
function onDisable() {
confirmAction('停用后不再参与计费命中,确认停用?', () => disablePriceSet(set.value.priceSetId), '已停用')
}
/** 复用新建:复制为草稿后跳转到新建页 */
function onActivateVersion(row) {
if (!row || row.psvId == null) return
confirmAction(
'确认启动版本 ' + (row.versionNo || '') + '?启动后将以当前时间生效,上一版本自动失效。',
() => activatePriceSetVersion(row.psvId),
'版本已启动'
)
}
function onCopy() {
copyPriceSet(set.value.priceSetId).then(res => {
proxy.$modal.msgSuccess('已复制为草稿')
proxy.$router.push({ path: '/customer/setCreate', query: { priceSetId: res.data } })
})
}
function onCopyVersion(row) {
if (!row || row.psvId == null) return
proxy.$modal.confirm(
'确认复制版本 ' + (row.versionNo || '') + '为新草稿?将新增版本并拷贝全部明细。'
).then(() => copyPriceSetVersion(row.psvId)).then(res => {
proxy.$modal.msgSuccess('已复制为新版本')
const newPsvId = res.data
load()
if (newPsvId != null) {
proxy.$router.push({
path: '/customer/setCreate',
query: { priceSetId: set.value.priceSetId, psvId: newPsvId, step: '2' }
})
}
}).catch(() => {})
}
function onRemoveVersion(row) {
if (!row || row.psvId == null) return
proxy.$modal.confirm(
'确认删除草稿版本 ' + (row.versionNo || '') + '?其明细与附加费将一并删除。'
).then(() => deletePriceSetVersion(row.psvId)).then(() => {
proxy.$modal.msgSuccess('版本已删除')
load()
}).catch(() => {})
}
function onEditVersion(row) {
const q = { priceSetId: set.value.priceSetId, step: '2' }
if (row && row.psvId != null) q.psvId = row.psvId
proxy.$router.push({ path: '/customer/setCreate', query: q })
}
function goList() {
const obj = { path: '/customer/set' }
if (proxy.$tab) {
@@ -99,10 +132,6 @@ function goList() {
}
}
function goEdit() {
proxy.$router.push({ path: '/customer/setCreate', query: { priceSetId: set.value.priceSetId } })
}
onMounted(() => {
refData.loadProducts()
load()
+3 -85
View File
@@ -7,7 +7,7 @@
:query="queryList"
row-key="priceSetId"
>
<template #toolbar="{ selection, ids }">
<template #toolbar>
<el-col :span="1.5">
<el-button
type="primary"
@@ -17,16 +17,6 @@
v-hasPermi="['price:set:add']"
>新建套餐</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:set:remove']"
>删除</el-button>
</el-col>
</template>
<template #col-setType="{ row }">
@@ -46,47 +36,11 @@
<template #col-action="{ row }">
<el-button link type="primary" @click="handleDetail(row)" v-hasPermi="['price:set:query']">详情</el-button>
<el-button
v-if="row.status === 'D' || row.status === 'R'"
link
type="primary"
@click="handleEdit(row)"
v-hasPermi="['price:set:edit']"
>{{ row.status === 'D' ? '继续配置' : '编辑' }}</el-button>
<el-button
v-if="row.status === 'D' || row.status === 'R'"
link
type="primary"
@click="handleSubmit(row)"
v-hasPermi="['price:set:audit']"
>提交审核</el-button>
<el-button
v-if="row.status === 'A'"
link
type="success"
@click="handleApprove(row)"
v-hasPermi="['price:set:audit']"
>审批</el-button>
<el-button
v-if="row.status === 'E' && !row.expired"
link
type="warning"
@click="handleDisable(row)"
v-hasPermi="['price:set:audit']"
>停用</el-button>
<el-button
v-if="row.status === 'S'"
link
type="primary"
@click="handleCopy(row)"
v-hasPermi="['price:set:add']"
>复用</el-button>
<el-button
v-if="row.status === 'D' || row.status === 'R'"
link
type="danger"
@click="handleDelete(row.priceSetId)"
v-hasPermi="['price:set:remove']"
>删除</el-button>
</template>
</basic-table>
@@ -95,7 +49,7 @@
<script setup name="PriceSet">
import {
listPriceSet, delPriceSet, submitPriceSet, approvePriceSet, disablePriceSet, copyPriceSet
listPriceSet, disablePriceSet, copyPriceSet
} from '@/api/price/set'
import { parseTime } from '@/utils/ruoyi'
@@ -120,10 +74,7 @@ const searchColumns = [
prop: 'status',
component: 'select',
options: [
{ label: '草稿', value: 'D' },
{ label: '审核中', value: 'A' },
{ label: '生效', value: 'E' },
{ label: '驳回', value: 'R' },
{ label: '已停用', value: 'S' }
],
placeholder: '全部状态',
@@ -137,11 +88,7 @@ const columns = [
{ label: '套餐名称', prop: 'priceSetName', minWidth: 160 },
{ label: '类型', prop: 'setType', width: 140 },
{ label: '说明', prop: 'priceSetNote', minWidth: 160 },
{ label: '产品数', prop: 'productCount', width: 80 },
{ label: '客户数', prop: 'customerCount', width: 80 },
{ label: '生效日期', prop: 'dateRange', minWidth: 200 },
{ label: '状态', prop: 'status', width: 90 },
{ label: '操作', prop: 'action', width: 280, showInToolbar: false }
{ label: '操作', prop: 'action', width: 160, showInToolbar: false }
]
function queryList(params) {
@@ -172,9 +119,6 @@ function handleDetail(row) {
proxy.$router.push({ path: '/customer/setDetail', query: { priceSetId: row.priceSetId } })
}
function handleEdit(row) {
proxy.$router.push({ path: '/customer/setCreate', query: { priceSetId: row.priceSetId } })
}
function refresh() {
tableRef.value?.refresh()
@@ -188,32 +132,6 @@ onActivated(() => {
tableRef.value?.reload()
})
function handleDelete(ids) {
proxy.$modal.confirm('是否确认删除套餐编号为 "' + ids + '" 的数据项?').then(() => {
return delPriceSet(ids)
}).then(() => {
proxy.$modal.msgSuccess('删除成功')
refresh()
}).catch(() => {})
}
function handleSubmit(row) {
proxy.$modal.confirm('确认提交套餐【' + row.priceSetName + '】审核?').then(() => {
return submitPriceSet(row.priceSetId)
}).then(() => {
proxy.$modal.msgSuccess('已提交审核')
refresh()
}).catch(() => {})
}
function handleApprove(row) {
proxy.$modal.confirm('确认将套餐【' + row.priceSetName + '】审批生效?').then(() => {
return approvePriceSet(row.priceSetId)
}).then(() => {
proxy.$modal.msgSuccess('套餐已生效')
refresh()
}).catch(() => {})
}
function handleDisable(row) {
proxy.$modal.confirm('停用后该套餐不再参与计费命中,确认停用【' + row.priceSetName + '】?').then(() => {
@@ -212,3 +212,24 @@
border-radius: 8px;
}
}
/* 弹窗顶部说明条:左侧口径说明 + 右侧已选计数(附加费勾选弹窗) */
.ps-picker-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
font-size: 13px;
color: #606266;
&__picked {
flex: none;
padding: 0 8px;
font-size: 12px;
line-height: 20px;
color: #1890ff;
background: #ecf5ff;
border-radius: 10px;
}
}
@@ -40,6 +40,29 @@ const STATUS_TAG = {
/** 默认取值方式 */
export const DEFAULT_ROUND_MODE = 'HALF_UP'
/** version status: 0 draft / 1 active / 2 expired */
export const VER_STATUS = {
DRAFT: '0',
EFFECT: '1',
EXPIRED: '2'
}
export function versionStatusLabel(status) {
const map = { '0': '草稿', '1': '生效', '2': '失效' }
return map[status] || status || '-'
}
export function versionStatusTag(status) {
const map = { '0': 'warning', '1': 'success', '2': 'info' }
return map[status] || 'info'
}
export function nextVersionNo(versions) {
const seqs = (versions || []).map(v => Number(v.versionSeq) || 0)
const max = seqs.length ? Math.max.apply(null, seqs) : 0
return 'V' + (max + 1)
}
/** 状态文案(已过期优先) */
export function setStatusLabel(set) {
if (set && set.expired) return '已过期'
@@ -201,7 +224,11 @@ export function buildWeightOptions(rows, row) {
zoneCode: item.zoneCode,
zoneName: item.zoneName,
freightPrice: item.freightPrice,
registrationFee: item.registrationFee
registrationFee: item.registrationFee,
weightMin: item.weightMin,
weightMax: item.weightMax,
effectiveDate: item.effectiveDate,
expireDate: item.expireDate
})
})
return [...map.values()]
@@ -217,11 +244,29 @@ export function formatChargeValue(row) {
/** 附加费作用范围文案 */
export function surchargeScopeLabel(s) {
if (s.detailId == null) return '产品级'
// 主表(prc_surcharge)数据:country_code 有值 = 该国专属(计费归属),空 = 全部国家
if (s.detailId == null && s.psdId == null) {
return s.countryCode ? `国家 ${s.countryCode}` : '全部国家'
}
const w = s.weightMin != null && s.weightMax != null ? `${s.weightMin}-${s.weightMax}kg` : '-'
return `${s.countryName || '-'} · ${w}`
}
/**
* 产品关联附加费(prc_surcharge_rel)是否作用于该明细行
* 与后端 snapshotVersionSurcharges 的过滤口径保持一致:
* 国家三字码一致 + 产品级(detailId 空)直接命中,明细级需 productCountryId 相同
*/
export function surchargeAppliesToRow(rel, row) {
if (!rel || !row) return false
if (rel.countryCode && row.countryCode
&& String(rel.countryCode).toUpperCase() !== String(row.countryCode).toUpperCase()) {
return false
}
if (rel.detailId == null) return true
return row.productCountryId != null && Number(rel.detailId) === Number(row.productCountryId)
}
/** 附加费标签文案 */
export function surchargeTagLabel(s) {
return `${s.scName} ${formatChargeValue(s)}`
@@ -239,11 +284,19 @@ export function createEmptyDetailRow() {
zoneCode: undefined,
zoneName: undefined,
weightLabel: undefined,
weightMin: undefined,
weightMax: undefined,
productCountryId: undefined,
fvValue: undefined,
fvRegistrationFee: undefined,
/** product default freight when copied from bms_product_country */
_baseFreight: undefined,
/** product default registration fee */
_baseReg: undefined,
discountValue: undefined,
roundMode: DEFAULT_ROUND_MODE
roundMode: DEFAULT_ROUND_MODE,
effectiveDate: undefined,
expireDate: undefined
}
}
@@ -252,10 +305,20 @@ export function toEditableDetailRow(row) {
return {
...row,
roundMode: row.roundMode || DEFAULT_ROUND_MODE,
_countryKey: countryKeyOf(row)
_countryKey: countryKeyOf(row),
_baseFreight: row._baseFreight != null ? row._baseFreight : undefined,
_baseReg: row._baseReg != null ? row._baseReg : undefined
}
}
/** FIXED row changed vs product default */
export function isDetailPriceChanged(row) {
if (!row) return false
if (row._baseFreight != null && Number(row.fvValue) !== Number(row._baseFreight)) return true
if (row._baseReg != null && Number(row.fvRegistrationFee) !== Number(row._baseReg)) return true
return false
}
/** 新增一条空客户绑定行 */
export function createEmptyCustomerRow({ effectiveDate, expireDate, seq = 1 } = {}) {
return {
@@ -283,6 +346,9 @@ export function createEmptyBasicInfo() {
/** 明细行 -> 提交后端的 payload */
export function toDetailPayload(d) {
return {
psdId: d.psdId || undefined,
psvId: d.psvId || undefined,
priceSetId: d.priceSetId || undefined,
productCode: d.productCode,
productName: d.productName,
countryCode: d.countryCode || undefined,
@@ -291,11 +357,17 @@ export function toDetailPayload(d) {
zoneCode: d.zoneCode || undefined,
zoneName: d.zoneName || undefined,
weightLabel: d.weightLabel || undefined,
weightMin: d.weightMin,
weightMax: d.weightMax,
productCountryId: d.productCountryId || undefined,
fvValue: d.fvValue,
fvRegistrationFee: d.fvRegistrationFee,
discountValue: d.discountValue,
roundMode: d.roundMode || DEFAULT_ROUND_MODE
roundMode: d.roundMode || DEFAULT_ROUND_MODE,
effectiveDate: d.effectiveDate || undefined,
expireDate: d.expireDate || undefined,
/** 勾选保存的附加费:带数组即按勾选落快照,空数组=一条都不选 */
surcharges: Array.isArray(d.surcharges) ? d.surcharges.map(s => ({ scId: s.scId })) : undefined
}
}