新增附加费计费标识,优化附加费管理逻辑

This commit is contained in:
wuweihua
2026-11-12 14:43:42 +08:00
parent cf336f7a67
commit 90147ed4e3
10 changed files with 1339 additions and 39 deletions
@@ -75,12 +75,22 @@
/>
</el-tab-pane>
<el-tab-pane :label="`附加费(${(pane.item.surcharges || []).length})`" name="fee">
<el-tab-pane :label="feeTabLabel(pane)" name="fee">
<div class="pay-edit-bar">
<el-input v-model="pane.feeKeyword" clearable placeholder="筛选附加费用、地区、价格、货币或费用解释" class="pay-filter" @input="pane.feePageNo = 1" />
<el-button v-if="editable" size="small" type="primary" plain @click="addFee(pane)">添加附加费</el-button>
<el-button v-if="editable" size="small" type="primary" plain @click="openFeePicker(pane)">添加附加费</el-button>
<template v-if="billSelect">
<el-button size="small" @click="setFeesBill(pane, true)">全选计费</el-button>
<el-button size="small" @click="setFeesBill(pane, false)">取消全选</el-button>
<span class="pay-bill-tip">勾选后才会写入 pay_surcharge;默认不勾选</span>
</template>
</div>
<el-table :data="pageFees(pane)" border size="small" empty-text="没有附加费">
<el-table-column v-if="billSelect" label="是否计费" width="88" align="center" fixed="left">
<template #default="{ row }">
<el-checkbox :model-value="row.needBill === true" @update:model-value="v => { row.needBill = !!v }" />
</template>
</el-table-column>
<el-table-column prop="feeName" label="附加费用" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<el-select
@@ -227,6 +237,12 @@
</el-tab-pane>
</el-tabs>
</section>
<pay-surcharge-picker-dialog
v-model="feePickerVisible"
:exclude-sc-ids="feePickerExcludeIds"
@confirm="onFeePickerConfirm"
/>
</div>
</template>
@@ -235,6 +251,7 @@
import { h, onMounted, watch } from 'vue'
import { ElInput } from 'element-plus'
import { listSurchargeOptions } from '@/api/price/surcharge'
import PaySurchargePickerDialog from './PaySurchargePickerDialog.vue'
const props = defineProps({
product: {
@@ -249,6 +266,11 @@ const props = defineProps({
type: Boolean,
default: false
},
/** 打单套餐:附加费表显示「是否计费」勾选,仅勾选行入库 */
billSelect: {
type: Boolean,
default: false
},
savingRowKey: {
type: [String, Number],
default: ''
@@ -517,24 +539,93 @@ function filteredFees(pane) {
return filterRows(pane.item.surcharges || [], pane.feeKeyword, ['feeName', 'zoneName', 'chargeText', 'amount', 'currency', 'priceUnit', 'feeDesc', 'calcRule', 'calcParam', 'postcodeParam'])
}
function addFee(pane) {
if (!pane.item.surcharges) pane.item.surcharges = []
const currency = String(pane.item.currency || 'EUR').trim().toUpperCase() || 'EUR'
const row = {
feeName: '',
scId: undefined,
zoneName: '',
chargeText: '',
amount: '',
priceUnit: currency + '/ITEM',
currency,
feeDesc: '',
calcRule: 'MANUAL',
calcParam: ''
function feeTabLabel(pane) {
const fees = pane.item.surcharges || []
if (!props.billSelect) return '附加费(' + fees.length + ')'
const n = fees.filter(f => f && f.needBill === true).length
return '附加费(已选 ' + n + '/' + fees.length + ')'
}
function setFeesBill(pane, value) {
const fees = pane.item.surcharges || []
for (const fee of fees) {
if (fee) fee.needBill = !!value
}
}
const { proxy } = getCurrentInstance()
const feePickerVisible = ref(false)
const feePickerPane = ref(null)
const feePickerExcludeIds = computed(() => {
const fees = feePickerPane.value?.item?.surcharges || []
return fees.map(fee => fee?.scId).filter(id => id != null)
})
function openFeePicker(pane) {
feePickerPane.value = pane
feePickerVisible.value = true
}
function toCalcRule(chargeType) {
const t = String(chargeType || '').trim().toUpperCase()
if (!t || t === 'RATE') return 'MANUAL'
if (t === 'FIXED') return 'ALWAYS'
const allowed = ['ALWAYS', 'POSTCODE', 'ZONE', 'EXTRA_KG', 'WEIGHT', 'SIZE', 'RETURN', 'MANUAL']
return allowed.includes(t) ? t : 'MANUAL'
}
function configToFeeRow(cfg, product) {
const currency = String(cfg?.currency || product?.currency || 'EUR').trim().toUpperCase() || 'EUR'
const amount = cfg?.chargeValue
const isPercent = String(cfg?.chargeType || '').toUpperCase() === 'RATE'
return {
feeName: String(cfg?.scName || '').trim(),
scId: cfg?.scId,
zoneName: String(cfg?.blockTitle || cfg?.countryCode || '').trim(),
chargeText: '',
amount: amount == null ? '' : amount,
currency,
priceUnit: isPercent ? '%' : (currency + '/ITEM'),
feeDesc: String(cfg?.scNote || '').trim(),
calcRule: toCalcRule(cfg?.chargeType),
calcParam: '',
// 从配置添加默认要计费
needBill: true
}
}
function onFeePickerConfirm(list) {
const pane = feePickerPane.value
if (!pane?.item) return
if (!pane.item.surcharges) pane.item.surcharges = []
const existed = new Set(
(pane.item.surcharges || []).map(fee => fee?.scId).filter(id => id != null).map(id => String(id))
)
let added = 0
let skipped = 0
for (const cfg of list || []) {
if (cfg?.scId != null && existed.has(String(cfg.scId))) {
skipped++
continue
}
const row = configToFeeRow(cfg, pane.item)
if (!row.feeName) continue
pane.item.surcharges.push(row)
if (row.scId != null) existed.add(String(row.scId))
added++
}
if (added > 0) {
pane.feePageNo = Math.ceil(pane.item.surcharges.length / pageSize) || 1
pane.tab = 'fee'
}
if (added && skipped) {
proxy.$modal.msgSuccess(`已添加 ${added} 项,跳过重复 ${skipped} 项`)
} else if (added) {
proxy.$modal.msgSuccess(`已添加 ${added} 项附加费`)
} else {
proxy.$modal.msgWarning('没有可添加的附加费(可能已全部存在)')
}
pane.item.surcharges.push(row)
startEdit(row)
pane.feePageNo = Math.ceil(pane.item.surcharges.length / pageSize) || 1
}
function removeRow(list, row) {
@@ -643,9 +734,14 @@ function statusText(status) {
.pay-edit-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.pay-bill-tip {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.pay-hint {
margin: 0 0 8px;
color: var(--el-text-color-secondary);
@@ -0,0 +1,234 @@
<template>
<el-dialog
v-model="visible"
title="选择附加费配置"
width="920px"
append-to-body
destroy-on-close
@opened="onOpened"
>
<div class="picker-bar">
<el-input
v-model="keyword"
clearable
placeholder="搜索附加费名称、计费方式、币种、国家或备注"
class="picker-search"
/>
<span class="picker-meta">共 {{ filteredRows.length }} 项,已选 {{ picked.length }} 项</span>
</div>
<el-table
ref="tableRef"
v-loading="loading"
:data="filteredRows"
row-key="scId"
border
size="small"
max-height="420"
empty-text="暂无附加费配置"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="46" :selectable="canSelect" />
<el-table-column prop="scName" label="附加费名称" min-width="160" show-overflow-tooltip />
<el-table-column label="计费方式" width="120" align="center">
<template #default="{ row }">{{ chargeTypeLabel(row.chargeType) }}</template>
</el-table-column>
<el-table-column label="计费数值" width="130" align="right">
<template #default="{ row }">{{ formatValue(row) }}</template>
</el-table-column>
<el-table-column prop="currency" label="币种" width="80" align="center" />
<el-table-column label="国家/分区" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ scopeText(row) }}</template>
</el-table-column>
<el-table-column prop="scNote" label="备注" min-width="160" show-overflow-tooltip />
</el-table>
<div class="picker-hint">勾选后点确定写入当前产品附加费表;已存在相同配置的不会重复添加。</div>
<template #footer>
<el-button @click="visible = false">取 消</el-button>
<el-button type="primary" :disabled="!picked.length" @click="submit">确 定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { listSurchargeOptions } from '@/api/price/surcharge'
const props = defineProps({
modelValue: { type: Boolean, default: false },
/** 已在表中的 scId,用于禁用重复勾选 */
excludeScIds: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const { proxy } = getCurrentInstance()
const tableRef = ref()
const loading = ref(false)
const keyword = ref('')
const rows = ref([])
const pickedIds = ref(new Set())
const syncing = ref(false)
const visible = computed({
get: () => props.modelValue,
set: v => emit('update:modelValue', v)
})
const excludeSet = computed(() => new Set((props.excludeScIds || []).map(id => String(id))))
const chargeTypeLabelMap = {
MANUAL: '暂不计费',
ALWAYS: '每票都收',
POSTCODE: '命中邮编',
ZONE: '命中分区',
EXTRA_KG: '超重续收',
WEIGHT: '超重',
SIZE: '超尺寸',
RETURN: '退件',
FIXED: '每票都收',
RATE: '暂不计费'
}
function chargeTypeLabel(val) {
if (!val) return chargeTypeLabelMap.MANUAL
const key = String(val).toUpperCase()
return chargeTypeLabelMap[key] || val
}
function formatValue(row) {
if (row == null || row.chargeValue == null || row.chargeValue === '') return '-'
const v = Number(row.chargeValue)
if (!Number.isFinite(v)) return row.chargeValue
if (String(row.chargeType || '').toUpperCase() === 'RATE') return (v * 100).toFixed(2) + '%'
return String(v)
}
function scopeText(row) {
const parts = []
if (row.countryCode) parts.push(row.countryCode)
if (row.blockTitle) parts.push(row.blockTitle)
return parts.join(' / ') || '全部'
}
function canSelect(row) {
if (row?.scId == null) return false
return !excludeSet.value.has(String(row.scId))
}
const filteredRows = computed(() => {
const text = String(keyword.value || '').trim().toLowerCase()
const list = rows.value || []
if (!text) return list
return list.filter(row => {
const bag = [
row.scName,
chargeTypeLabel(row.chargeType),
row.chargeType,
row.currency,
row.countryCode,
row.blockTitle,
row.scNote,
row.chargeValue
].map(v => String(v ?? '').toLowerCase())
return bag.some(item => item.includes(text))
})
})
const picked = computed(() => {
const ids = pickedIds.value
return (rows.value || []).filter(row => row?.scId != null && ids.has(String(row.scId)) && canSelect(row))
})
function onSelectionChange(selection) {
if (syncing.value) return
const visibleIds = new Set((filteredRows.value || []).map(row => String(row.scId)))
const next = new Set(pickedIds.value)
for (const id of visibleIds) {
next.delete(id)
}
for (const row of selection || []) {
if (row?.scId != null && canSelect(row)) next.add(String(row.scId))
}
pickedIds.value = next
}
function restoreSelection() {
const table = tableRef.value
if (!table) return
syncing.value = true
table.clearSelection()
for (const row of filteredRows.value || []) {
if (row?.scId != null && pickedIds.value.has(String(row.scId))) {
table.toggleRowSelection(row, true)
}
}
nextTick(() => {
syncing.value = false
})
}
async function loadRows() {
loading.value = true
try {
const res = await listSurchargeOptions({ ssCode: 'on' })
rows.value = res.data || []
} catch (e) {
rows.value = []
} finally {
loading.value = false
nextTick(() => restoreSelection())
}
}
function onOpened() {
keyword.value = ''
pickedIds.value = new Set()
nextTick(() => {
tableRef.value?.clearSelection?.()
})
if (!rows.value.length) {
loadRows()
}
}
function submit() {
const list = picked.value
if (!list.length) {
proxy.$modal.msgWarning('请先勾选要添加的附加费')
return
}
emit('confirm', list)
visible.value = false
}
watch(keyword, () => {
nextTick(() => restoreSelection())
})
watch(() => props.modelValue, v => {
if (v && !rows.value.length) loadRows()
})
</script>
<style scoped>
.picker-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.picker-search {
width: 360px;
}
.picker-meta {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.picker-hint {
margin-top: 10px;
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
</style>
@@ -87,7 +87,7 @@
</div>
<el-drawer v-model="detailVisible" :title="detailTitle" size="78%" destroy-on-close append-to-body>
<pay-config-detail :product="detailProduct" :editable="false" />
<pay-config-detail :product="detailProduct" :editable="false" :bill-select="true" />
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
@@ -187,7 +187,9 @@ async function onFile(uploadFile) {
const data = res.data || {}
data.products = (data.products || []).map((item, index) => ({
...item,
rowKey: `${item.sheetName || ''}-${item.productName || ''}-${index}`
rowKey: `${item.sheetName || ''}-${item.productName || ''}-${index}`,
// 解析后默认不计费,勾选后才入 pay_surcharge
surcharges: (item.surcharges || []).map(fee => ({ ...fee, needBill: fee?.needBill === true }))
}))
preview.value = data
const picked = data.products.filter(row => canSelect(row))
@@ -310,12 +312,17 @@ async function submit(payload = {}) {
const productsPayload = (payload.products && payload.products.length)
? payload.products
: selected.value.map(row => ({ ...row, productType: 'AR' }))
const filteredProducts = productsPayload.map(product => ({
...product,
productType: product.productType || 'AR',
surcharges: (product.surcharges || []).filter(fee => fee && fee.needBill === true)
}))
const res = await saveLabelImport({
priceSetId: payload.priceSetId || null,
priceSetName: String(payload.priceSetName || '').trim(),
priceSetNote: payload.priceSetNote || '',
overwrite: payload.overwrite != null ? payload.overwrite : overwrite.value,
products: productsPayload
products: filteredProducts
})
const data = res.data || {}
return {
+112 -10
View File
@@ -21,9 +21,18 @@
<span>{{ cardTitle }}</span>
<span class="header-tip">{{ headerTip }}</span>
</div>
<el-button type="primary" plain icon="Upload" @click="openImport" v-hasPermi="['price:label:import', 'price:label:add']">
导入价卡
</el-button>
<div class="header-actions">
<el-button
type="danger"
plain
icon="Delete"
:disabled="!selectedProducts.length"
@click="removeSelectedProducts"
>批量删除</el-button>
<el-button type="primary" plain icon="Upload" @click="openImport" v-hasPermi="['price:label:import', 'price:label:add']">
导入价卡
</el-button>
</div>
</div>
</template>
@@ -36,7 +45,18 @@
class="meta-alert"
/>
<el-table v-loading="loadingProducts" :data="productRows" border size="small" max-height="420" empty-text="请点击右上角「导入价卡」">
<el-table
ref="productTableRef"
v-loading="loadingProducts"
:data="productRows"
border
size="small"
max-height="420"
row-key="rowKey"
empty-text="请点击右上角「导入价卡」"
@selection-change="onProductSelect"
>
<el-table-column type="selection" width="48" align="center" />
<el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip />
<el-table-column prop="sheetName" label="报价工作表" min-width="140" show-overflow-tooltip />
<el-table-column prop="countryScope" label="国家/口岸" min-width="110" show-overflow-tooltip />
@@ -54,9 +74,10 @@
<span v-else>新记录</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<el-table-column label="操作" width="140" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" icon="View" @click="openDetail(row)">查看</el-button>
<el-button link type="primary" icon="Edit" @click="openDetail(row)">编辑</el-button>
<el-button link type="danger" icon="Delete" @click="removeProduct(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -79,6 +100,7 @@
<pay-config-detail
:product="detailProduct"
:editable="true"
:bill-select="true"
:saving-row-key="savingRowKey"
@save-row="saveDetailRow"
/>
@@ -104,6 +126,8 @@ const form = reactive({
basic: createEmptyBasicInfo()
})
const productRows = ref([])
const productTableRef = ref()
const selectedProducts = ref([])
const importMeta = reactive({
sourceFile: '',
supplierName: '',
@@ -141,7 +165,7 @@ const pageBanner = computed(() => {
const headerTip = computed(() => {
if (editVersionMode.value) return '可重新导入或修改明细;完成后覆盖本版本,不新增版本号'
if (newVersionMode.value) return '导入后生成待生效新版本;生效时间请回详情页设置'
return '打单价卡与应付独立;解析后回填到此表,查看明细后可在行内保存修改'
return '打单价卡与应付独立;解析后回填到此表,可删除不需要的产品,编辑明细后点完成保存'
})
function goBack() {
@@ -196,7 +220,9 @@ async function loadEditProducts() {
}
}
}))
productRows.value = details
// 库中已落库的附加费默认勾选(保留原计费项)
productRows.value = markSurchargeBillFlags(details, true)
selectedProducts.value = []
importMeta.sourceFile = details[0]?.sourceFile || ''
importMeta.supplierName = details[0]?.supplierName || ''
importMeta.overwrite = true
@@ -209,8 +235,71 @@ async function openImport() {
importRef.value?.open()
}
function markSurchargeBillFlags(products, defaultNeedBill) {
return (products || []).map(product => ({
...product,
surcharges: (product.surcharges || []).map(fee => ({
...fee,
needBill: fee?.needBill === true ? true : (fee?.needBill === false ? false : !!defaultNeedBill)
}))
}))
}
function withBillableSurcharges(products) {
return (products || []).map(product => ({
...product,
surcharges: (product.surcharges || []).filter(fee => fee && fee.needBill === true)
}))
}
function onProductSelect(rows) {
selectedProducts.value = rows || []
}
function productKey(row) {
return row?.rowKey || `${row?.sheetName || ''}-${row?.productName || ''}`
}
async function removeProduct(row) {
if (!row) return
try {
await proxy.$modal.confirm(`确认从本次导入中移除产品「${row.productName || row.sheetName || ''}」?`)
} catch {
return
}
const key = productKey(row)
productRows.value = productRows.value.filter(item => productKey(item) !== key)
selectedProducts.value = selectedProducts.value.filter(item => productKey(item) !== key)
if (detailRowKey.value === key) {
detailVisible.value = false
}
proxy.$modal.msgSuccess('已删除')
}
async function removeSelectedProducts() {
const rows = selectedProducts.value || []
if (!rows.length) {
proxy.$modal.msgWarning('请先勾选要删除的产品')
return
}
try {
await proxy.$modal.confirm(`确认删除所选 ${rows.length} 个产品?`)
} catch {
return
}
const keys = new Set(rows.map(productKey))
productRows.value = productRows.value.filter(item => !keys.has(productKey(item)))
selectedProducts.value = []
if (keys.has(detailRowKey.value)) {
detailVisible.value = false
}
proxy.$modal.msgSuccess('已批量删除')
}
function onImportConfirm(payload) {
productRows.value = payload?.products || []
// 价卡解析行默认不计费,需手动勾选
productRows.value = markSurchargeBillFlags(payload?.products || [], false)
selectedProducts.value = []
importMeta.sourceFile = payload?.sourceFile || ''
importMeta.supplierName = payload?.supplierName || ''
importMeta.effectiveDate = payload?.effectiveDate || ''
@@ -226,6 +315,10 @@ function openDetail(row) {
}
function onDetailClosed() {
// 关闭抽屉时回写勾选结果(不依赖行内「保存」)
if (detailProduct.value && detailRowKey.value) {
applyEditedProduct()
}
detailProduct.value = null
detailRowKey.value = ''
savingRowKey.value = ''
@@ -303,6 +396,10 @@ async function finish() {
proxy.$modal.msgWarning('请先导入并确认价卡产品')
return
}
// 抽屉未关闭时先回写勾选,避免丢失
if (detailVisible.value && detailProduct.value && detailRowKey.value) {
applyEditedProduct()
}
const tip = editVersionMode.value
? `将覆盖版本 V${versionNo.value} 的 ${productRows.value.length} 个产品,确认继续?`
: newVersionMode.value
@@ -317,7 +414,7 @@ async function finish() {
try {
const payload = {
overwrite: editVersionMode.value ? true : importMeta.overwrite,
products: productRows.value
products: withBillableSurcharges(productRows.value)
}
if (editVersionMode.value) {
payload.priceSetId = priceSetId.value
@@ -367,6 +464,11 @@ watch([priceSetId, versionNo, () => route.query.editVersion, () => route.query.n
color: #909399;
font-weight: normal;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.meta-alert {
margin-bottom: 12px;
}
@@ -30,11 +30,11 @@
<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">
<!-- <el-table-column :label="colStatus" width="100" align="center">
<template #default="{ row }">
<el-tag :type="isDeletedVersion(row) ? 'danger' : versionStatusTag(row.status)" size="small">{{ statusText(row) }}</el-tag>
</template>
</el-table-column>
</el-table-column> -->
<el-table-column :label="colCreate" min-width="170" align="center">
<template #default="{ row }">{{ row.createTime || '-' }}</template>
</el-table-column>