Enhance surcharge management by adding support for price type filtering in SurchargeController and related services. Introduce new methods for handling redline product codes in mappers and services. Update front-end API to accommodate price type parameters in surcharge queries. Modify database schema to include redline product and country tables for improved surcharge calculations.

This commit is contained in:
wuweihua
2026-10-09 17:30:29 +08:00
parent 380577f691
commit f1f6ddd368
34 changed files with 5196 additions and 30 deletions
@@ -75,7 +75,7 @@ function open(row) {
function loadList() {
if (!product.value) return
loading.value = true
listSurchargeByProduct(product.value.productCode).then(res => {
listSurchargeByProduct(product.value.productCode, 'PRODUCT').then(res => {
list.value = res.data || []
}).finally(() => {
loading.value = false
@@ -0,0 +1,281 @@
<template>
<el-drawer v-model="visible" size="80%" :title="title" append-to-body>
<div class="mb8" style="display: flex; align-items: center; gap: 12px">
<el-input v-model="keyword" placeholder="搜索国家/分区(中文名 / ISO2 / 分区)" clearable style="width: 260px" />
</div>
<simple-table
:data="filteredList"
:columns="columns"
:loading="loading"
border
max-height="calc(100vh - 120px)"
empty-text="暂无国家明细"
:span-method="spanMethod"
row-key="detailId"
>
<template #col-countryName="{ row }">
<span class="pre-wrap">{{ row.countryName }} ({{ row.countryCode }})</span>
</template>
<template #col-sizeLimit="{ row }">
<span class="pre-wrap">{{ row.sizeLimit }}</span>
</template>
<template #col-lastMile="{ row }">
<span class="pre-wrap">{{ row.lastMile }}</span>
</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="Edit" @click="openEdit(row)" v-hasPermi="['price:redline:edit']">编辑</el-button>
</template>
</simple-table>
<!-- 编辑国家明细弹窗 -->
<el-dialog v-model="editVisible" :title="editTitle" width="620px" append-to-body :close-on-click-modal="false">
<div class="edit-context">国家 / 分区:<b>{{ editRow.countryName }} ({{ editRow.countryCode }})</b> · {{ editRow.zoneName || '无分区' }}</div>
<el-form ref="editFormRef" :model="editForm" :rules="editRules" label-width="110px">
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="重量下限(KG)" prop="weightMin">
<el-input-number v-model="editForm.weightMin" :min="0" :precision="3" :step="0.1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="重量上限(KG)" prop="weightMax">
<el-input-number v-model="editForm.weightMax" :min="0" :precision="3" :step="0.1" controls-position="right" placeholder="留空为不封顶" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="运费(RMB/KG)" prop="freightPrice">
<el-input-number v-model="editForm.freightPrice" :min="0" :precision="2" :step="1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="挂号费(RMB/票)" prop="registrationFee">
<el-input-number v-model="editForm.registrationFee" :min="0" :precision="2" :step="1" controls-position="right" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="尾程" prop="lastMile">
<el-input v-model="editForm.lastMile" placeholder="如 DHL Paket" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="时效" prop="leadTime">
<el-input v-model="editForm.leadTime" placeholder="如 7-9工作日" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="生效时间" prop="effectiveDate">
<el-date-picker v-model="editForm.effectiveDate" type="datetime" format="YYYY-MM-DD HH:00" value-format="YYYY-MM-DD HH:00:00" placeholder="选择时间" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="失效时间" prop="expireDate">
<el-date-picker v-model="editForm.expireDate" type="datetime" format="YYYY-MM-DD HH:00" value-format="YYYY-MM-DD HH:00:00" placeholder="留空为长期" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="尺寸限制" prop="sizeLimit">
<el-input v-model="editForm.sizeLimit" type="textarea" :rows="2" placeholder="如 最大尺寸 60x50x40cm" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
</el-drawer>
</template>
<script setup>
import SimpleTable from '@/components/SimpleTable'
import { listRedlineCountries, updateRedlineCountry } from '@/api/price/redline'
import { formatWeightLabel } from '@/views/price/set/utils/priceSet'
const { proxy } = getCurrentInstance()
const columns = [
{ prop: 'countryName', label: '国家', width: 120, showOverflowTooltip: true },
// { prop: 'countryCode', label: 'ISO2', width: 60, align: 'center' },
{ prop: 'zoneName', label: '分区名称', width: 140, showOverflowTooltip: true, formatter: (row) => row.zoneName || '-' },
{
prop: 'weightMin',
label: '重量段(KG)',
width: 130,
align: 'center',
formatter: (row) => (row.weightMin != null || row.weightMax != null
? formatWeightLabel(row.weightMin, row.weightMax)
: '-')
},
{ prop: 'freightPrice', label: '运费(RMB/KG)', width: 110, align: 'right' },
{ prop: 'registrationFee', label: '挂号费(RMB/票)', width: 120, align: 'right' },
{ prop: 'sizeLimit', label: '尺寸限制', minWidth: 140 },
{ prop: 'lastMile', label: '尾程', minWidth: 110 },
{ prop: 'leadTime', label: '时效', width: 100, showOverflowTooltip: true },
{ prop: 'effectiveDate', label: '生效时间', width: 160, align: 'center' },
{ prop: 'expireDate', label: '失效时间', width: 110, align: 'center', formatter: (row) => row.expireDate || '长期' },
{ prop: 'status', label: '状态', width: 90, align: 'center', dict: 'bms_product_effect_status' },
// { prop: 'action', label: '操作', width: 70, fixed: 'right', slot: 'action' }
]
const visible = ref(false)
const title = ref('')
const loading = ref(false)
const list = ref([])
const keyword = ref('')
const status = ref('')
// 当前产品行(用于编辑保存后重新加载明细)
const currentProduct = ref(null)
const filteredList = computed(() => {
const kw = keyword.value.trim().toLowerCase()
const st = status.value
return list.value.filter(r => {
if (kw && !(String(r.countryName || '').toLowerCase().includes(kw) ||
String(r.countryCode || '').toLowerCase().includes(kw) ||
String(r.zoneCode || '').toLowerCase().includes(kw) ||
String(r.zoneName || '').toLowerCase().includes(kw))) return false
if (st && String(r.status) !== String(st)) return false
return true
})
})
function formatEffDate(v) {
if (!v) return undefined
return String(v).slice(0, 10)
}
function load() {
if (!currentProduct.value) return
const row = currentProduct.value
loading.value = true
listRedlineCountries(row.productCode, formatEffDate(row.effectiveDate), row.productId).then(res => {
list.value = (res.data || []).slice().sort((a, b) => {
const c = String(a.countryCode || '').localeCompare(String(b.countryCode || ''))
if (c !== 0) return c
return String(a.zoneCode || '').localeCompare(String(b.zoneCode || ''))
})
}).finally(() => {
loading.value = false
})
}
function open(row) {
title.value = row.productCode + ' · ' + row.productName + ' · 国家服务明细'
currentProduct.value = row
visible.value = true
list.value = []
keyword.value = ''
status.value = ''
load()
}
/* ---------- 编辑明细 ---------- */
const editVisible = ref(false)
const editTitle = ref('编辑国家明细')
const editFormRef = ref()
const saving = ref(false)
const editRow = ref({})
function normalizeDrawerHour(val) {
if (val == null || val === '') return ''
const s = String(val).trim().replace('T', ' ')
const m = s.match(/^(\d{4}-\d{2}-\d{2})[ ](\d{2})/)
if (m) return m[1] + ' ' + m[2] + ':00:00'
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s + ' 00:00:00'
return s
}
const editForm = reactive({
detailId: null,
weightMin: null,
weightMax: null,
freightPrice: null,
registrationFee: null,
sizeLimit: '',
lastMile: '',
leadTime: '',
effectiveDate: '',
expireDate: ''
})
const editRules = {
weightMin: [{ required: true, message: '重量下限不能为空', trigger: 'blur' }],
weightMax: [{
validator: (rule, value, callback) => {
if (value != null && editForm.weightMin != null && Number(value) <= Number(editForm.weightMin)) {
callback(new Error('上限需大于下限'))
} else {
callback()
}
},
trigger: 'blur'
}],
freightPrice: [{ required: true, message: '运费不能为空', trigger: 'blur' }],
effectiveDate: [{ required: true, message: '生效时间不能为空', trigger: 'change' }]
}
function openEdit(row) {
editRow.value = row
editTitle.value = `编辑明细 · ${row.countryName}${row.zoneName ? ' · ' + row.zoneName : ''} · ${formatWeightLabel(row.weightMin, row.weightMax) || ''}`
Object.assign(editForm, {
detailId: row.detailId,
weightMin: row.weightMin ?? null,
weightMax: row.weightMax ?? null,
freightPrice: row.freightPrice ?? null,
registrationFee: row.registrationFee ?? null,
sizeLimit: row.sizeLimit || '',
lastMile: row.lastMile || '',
leadTime: row.leadTime || '',
effectiveDate: row.effectiveDate ? normalizeDrawerHour(row.effectiveDate) : '',
expireDate: row.expireDate ? normalizeDrawerHour(row.expireDate) : '',
})
editVisible.value = true
nextTick(() => editFormRef.value?.clearValidate())
}
function saveEdit() {
editFormRef.value.validate(valid => {
if (!valid) return
saving.value = true
updateRedlineCountry({ ...editForm, expireDate: editForm.expireDate || null }).then(() => {
proxy.$modal.msgSuccess('修改成功')
editVisible.value = false
load()
}).finally(() => {
saving.value = false
})
})
}
function rowGroupKey(row) {
return String(row?.countryCode || '') + '|' + String(row?.zoneCode || '') + '|' + String(row?.countryName || '')
}
function spanMethod({ column, rowIndex }) {
if (!['countryName', 'countryCode', 'zoneCode', 'zoneName', 'zoneId'].includes(column.property)) return
const rows = filteredList.value
if (!rows.length) return
const cur = rowGroupKey(rows[rowIndex])
if (rowIndex > 0 && rowGroupKey(rows[rowIndex - 1]) === cur) {
return { rowspan: 0, colspan: 0 }
}
let count = 1
while (rowIndex + count < rows.length && rowGroupKey(rows[rowIndex + count]) === cur) count++
return { rowspan: count, colspan: 1 }
}
defineExpose({ open })
</script>
<style scoped>
.mb8 { margin-bottom: 8px; }
.pre-wrap { white-space: pre-wrap; font-size: 12px; line-height: 1.4; }
.edit-context {
margin-bottom: 14px;
padding: 8px 12px;
background: var(--el-fill-color-light);
border-radius: 4px;
font-size: 13px;
color: var(--el-text-color-regular);
}
</style>
@@ -0,0 +1,65 @@
<template>
<el-drawer v-model="visible" size="860px" :title="title" append-to-body>
<el-table v-loading="loading" :data="list" border max-height="calc(100vh - 160px)" empty-text="该产品暂无导入附件">
<el-table-column label="文件名称" prop="fileName" min-width="220" show-overflow-tooltip />
<el-table-column label="生效时间" prop="effectiveTime" width="170" align="center" />
<el-table-column label="大小" width="100" align="right">
<template #default="{ row }">{{ formatSize(row.fileSize) }}</template>
</el-table-column>
<el-table-column label="上传人" prop="createBy" width="110" align="center" show-overflow-tooltip />
<el-table-column label="上传时间" prop="createTime" width="170" align="center" />
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="!row.filePath" @click="handleDownload(row)">下载</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
</template>
<script setup>
import { listProductExcelFiles } from '@/api/price/redline'
const { proxy } = getCurrentInstance()
const visible = ref(false)
const title = ref('')
const loading = ref(false)
const list = ref([])
const product = ref(null)
function open(row) {
product.value = row
title.value = (row.productCode || '') + ' · ' + (row.productName || '') + ' · 导入附件'
visible.value = true
loadList()
}
function loadList() {
if (!product.value || !product.value.productCode) return
loading.value = true
listProductExcelFiles(product.value.productCode).then(res => {
list.value = res.data || []
}).finally(() => {
loading.value = false
})
}
function formatSize(bytes) {
const n = Number(bytes)
if (!n || n < 0) return '-'
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
return (n / (1024 * 1024)).toFixed(2) + ' MB'
}
function handleDownload(row) {
if (!row || !row.filePath) {
proxy.$modal.msgWarning('文件路径不存在')
return
}
proxy.$download.resource(row.filePath)
}
defineExpose({ open })
</script>
@@ -0,0 +1,148 @@
<template>
<el-drawer v-model="visible" size="960px" :title="title" append-to-body>
<el-form :inline="true" @submit.prevent>
<el-form-item label="产品代码">
<el-input
v-model="queryParams.productCode"
placeholder="产品代码"
clearable
style="width: 160px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="产品名称">
<el-input
v-model="queryParams.productName"
placeholder="产品名称"
clearable
style="width: 160px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="失效时间段">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="list" border max-height="calc(100vh - 260px)" empty-text="暂无失效产品">
<el-table-column label="产品代码" prop="productCode" min-width="110" show-overflow-tooltip />
<el-table-column label="产品名称" prop="productName" min-width="140" show-overflow-tooltip />
<el-table-column label="时效等级" prop="serviceLevel" width="90" align="center" />
<el-table-column label="走货属性" prop="cargoAttr" min-width="110" show-overflow-tooltip />
<el-table-column label="目的区域" prop="destRegion" width="90" align="center" />
<el-table-column label="生效日期" prop="effectiveDate" width="120" align="center" />
<el-table-column label="失效日期" prop="expireDate" width="120" align="center" />
<el-table-column label="国家数" prop="countryCount" width="80" align="center" />
<el-table-column label="附加费数" prop="surchargeCount" width="90" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<dict-tag :options="bms_product_effect_status" :value="row.status" />
</template>
</el-table-column>
<el-table-column label="更新内容" prop="updateNote" min-width="140" show-overflow-tooltip />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
title="价格"
icon="Document"
@click="excelFileRef.open(row)"
v-hasPermi="['price:redline:query']"
></el-button>
<el-button
link
type="primary"
title="红线价明细"
icon="View"
@click="emit('view-country', row)"
v-hasPermi="['price:redline:query']"
></el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="loadList"
/>
<redline-excel-file-drawer ref="excelFileRef" />
</el-drawer>
</template>
<script setup>
import { listRedline } from '@/api/price/redline'
import RedlineExcelFileDrawer from './RedlineExcelFileDrawer.vue'
const emit = defineEmits(['view-country'])
const { proxy } = getCurrentInstance()
const { bms_product_effect_status } = useDict('bms_product_effect_status')
const excelFileRef = ref()
const title = '失效价格'
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const dateRange = ref([])
const queryParams = ref({
pageNum: 1,
pageSize: 10,
productCode: undefined,
productName: undefined,
statusGroup: 'expired'
})
function open() {
visible.value = true
resetQuery()
}
function loadList() {
loading.value = true
const p = { ...queryParams.value }
if (dateRange.value && dateRange.value.length === 2) {
proxy.addDateRange(p, dateRange.value)
}
listRedline(p).then(res => {
list.value = res.rows || []
total.value = res.total || 0
}).finally(() => {
loading.value = false
})
}
function handleQuery() {
queryParams.value.pageNum = 1
loadList()
}
function resetQuery() {
dateRange.value = []
queryParams.value = {
pageNum: 1,
pageSize: 10,
productCode: undefined,
productName: undefined,
statusGroup: 'expired'
}
loadList()
}
defineExpose({ open })
</script>
@@ -0,0 +1,167 @@
<template>
<el-dialog :title="title" v-model="visible" width="640px" append-to-body>
<basic-form ref="formRef" v-model="form" :schemas="schemas" label-width="100px" />
<template #footer>
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="visible = false">取 消</el-button>
</template>
</el-dialog>
</template>
<script setup>
import BasicForm from '@/components/BasicForm'
import { getRedline, addRedline, updateRedline } from '@/api/price/redline'
const emit = defineEmits(['success'])
const { proxy } = getCurrentInstance()
const formRef = ref()
const visible = ref(false)
const title = ref('')
const form = ref(emptyForm())
const schemas = computed(() => [
{
label: '产品代码',
prop: 'productCode',
component: 'input',
placeholder: '如 WSP037',
span: 12,
width: '100%',
disabled: !!form.value.productId,
props: { maxlength: 20 },
rules: [{ required: true, message: '产品代码不能为空', trigger: 'blur' }]
},
{
label: '产品名称',
prop: 'productName',
component: 'input',
placeholder: '产品名称',
span: 12,
width: '100%',
props: { maxlength: 100 },
rules: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }]
},
{
label: '时效等级',
prop: 'serviceLevel',
component: 'input',
placeholder: '如 标准',
span: 12,
width: '100%',
props: { maxlength: 20 }
},
{
label: '走货属性',
prop: 'cargoAttr',
component: 'input',
placeholder: '普货/带电等',
span: 12,
width: '100%',
props: { maxlength: 100 }
},
{
label: '目的区域',
prop: 'destRegion',
component: 'input',
placeholder: '如 欧盟',
span: 12,
width: '100%',
props: { maxlength: 50 }
},
// {
// label: '税模式',
// prop: 'taxMode',
// component: 'select',
// placeholder: '请选择',
// dict: 'bms_tax_mode',
// span: 12,
// width: '100%',
// rules: [{ required: true, message: '请选择税模式', trigger: 'change' }]
// },
{
label: '生效时间',
prop: 'effectiveDate',
component: 'datetime',
placeholder: '选择时间',
span: 12,
width: '100%',
rules: [{ required: true, message: '生效时间不能为空', trigger: 'change' }]
},
{
label: '失效时间',
prop: 'expireDate',
component: 'datetime',
placeholder: '选择时间',
span: 12,
width: '100%'
},
{
label: '更新内容',
prop: 'updateNote',
component: 'textarea',
span: 24,
width: '100%',
props: { rows: 2, maxlength: 500 }
},
{
label: '备注',
prop: 'remark',
component: 'textarea',
span: 24,
width: '100%',
props: { rows: 2, maxlength: 500 }
}
])
function emptyForm() {
return {
productId: undefined,
productCode: undefined,
productName: undefined,
serviceLevel: undefined,
cargoAttr: undefined,
destRegion: undefined,
updateNote: undefined,
taxMode: 'TAX_INCLUDED',
effectiveDate: undefined,
expireDate: undefined,
status: '0',
remark: undefined
}
}
function reset() {
form.value = emptyForm()
nextTick(() => formRef.value?.clearValidate())
}
function open(row) {
reset()
if (!row) {
title.value = '新建红线价'
visible.value = true
return
}
getRedline(row.productId).then(res => {
form.value = { ...emptyForm(), ...(res.data || {}) }
title.value = '修改产品'
visible.value = true
nextTick(() => formRef.value?.clearValidate())
})
}
function submitForm() {
formRef.value.validate(valid => {
if (!valid) return
const req = form.value.productId ? updateRedline(form.value) : addRedline(form.value)
req.then(() => {
proxy.$modal.msgSuccess('操作成功')
visible.value = false
emit('success')
})
})
}
defineExpose({ open })
</script>
@@ -0,0 +1,148 @@
<template>
<el-drawer v-model="visible" size="920px" :title="title" append-to-body>
<div class="mb8" style="display: flex; justify-content: space-between">
<el-button type="primary" plain icon="Plus" @click="handleRelAdd" v-hasPermi="['price:surcharge:add']">新增关联</el-button>
</div>
<el-table v-loading="loading" :data="list" border max-height="calc(100vh - 160px)" empty-text="该红线价暂无关联附加费">
<el-table-column label="附加费名称" prop="scName" min-width="140" show-overflow-tooltip />
<el-table-column label="价格类型" width="100" align="center">
<template #default="{ row }">{{ priceTypeLabel(row.priceType) }}</template>
</el-table-column>
<el-table-column label="计费方式" width="110" align="center">
<template #default="{ row }">{{ chargeTypeLabel(row.chargeType) }}</template>
</el-table-column>
<el-table-column label="计费数值" width="140" align="right">
<template #default="{ row }">
<span class="charge-value">{{ formatChargeValue(row) }}</span>
</template>
</el-table-column>
<el-table-column label="取值方式" width="100" align="center">
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
</el-table-column>
<el-table-column label="适用国家" width="110" align="center">
<template #default="{ row }">{{ countryLabel(row.countryCode) }}</template>
</el-table-column>
<el-table-column label="作用范围" width="170" align="center">
<template #default="{ row }">
<el-tag v-if="row.detailId == null" type="primary" size="small" effect="plain">产品级 · 全部明细</el-tag>
<el-tag v-else type="warning" size="small" effect="plain">{{ row.countryName || '-' }} · {{ row.weightMin }}-{{ row.weightMax }}kg</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="String(row.ssCode).toLowerCase() === 'on' ? 'success' : 'info'" size="small">
{{ String(row.ssCode).toLowerCase() === 'on' ? '有效' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" prop="scNote" min-width="120" show-overflow-tooltip />
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="danger" icon="Delete" @click="handleRelDelete(row.relId)" v-hasPermi="['price:surcharge:remove']">解绑</el-button>
</template>
</el-table-column>
</el-table>
<redline-surcharge-rel-dialog ref="relRef" @success="handleRelSuccess" />
</el-drawer>
</template>
<script setup>
import { onMounted } from 'vue'
import { listSurchargeByProduct, delSurchargeRel } from '@/api/price/surcharge'
import { listRegionOptions } from '@/api/data/region'
import RedlineSurchargeRelDialog from './RedlineSurchargeRelDialog.vue'
const emit = defineEmits(['success'])
const { proxy } = getCurrentInstance()
const { bms_round_mode, bms_charge_type } = useDict('bms_round_mode', 'bms_charge_type')
const relRef = ref()
const visible = ref(false)
const title = ref('')
const loading = ref(false)
const list = ref([])
const product = ref(null)
const regionOptions = ref([])
function open(row) {
product.value = row
title.value = row.productCode + ' · ' + row.productName + ' · 附加费关联'
visible.value = true
loadList()
}
function loadList() {
if (!product.value) return
loading.value = true
listSurchargeByProduct(product.value.productCode, 'REDLINE').then(res => {
const rows = res.data || []
list.value = product.value && product.value.productId != null
? rows.filter(r => Number(r.productId) === Number(product.value.productId) || r.priceType === 'REDLINE')
: rows
}).finally(() => {
loading.value = false
})
}
function handleRelAdd() {
relRef.value.open(product.value)
}
function handleRelSuccess() {
loadList()
emit('success')
}
function handleRelDelete(relId) {
proxy.$modal.confirm('是否确认解绑该附加费?').then(() => {
return delSurchargeRel(relId)
}).then(() => {
proxy.$modal.msgSuccess('解绑成功')
loadList()
emit('success')
}).catch(() => {})
}
function priceTypeLabel(val) {
if (val === 'REDLINE') return '红线价'
if (val === 'SALES') return '销售价'
return '产品价'
}
function chargeTypeLabel(val) {
const found = bms_charge_type.value.find(c => c.value === val)
return found ? found.label : (val || '-')
}
function roundModeLabel(val) {
const found = bms_round_mode.value.find(d => d.value === val)
return found ? found.label : (val || '-')
}
function formatChargeValue(row) {
if (row.chargeValue == null) return '-'
const v = Number(row.chargeValue)
if (row.chargeType === 'RATE') return (v * 100).toFixed(2) + '%'
return v + ' ' + (row.currency || 'CNY')
}
function countryLabel(code) {
if (!code) return '全部'
const found = regionOptions.value.find(r => r.countryCode2 === code)
return found ? (found.countryNameCn || found.countryNameEn || code) : code
}
onMounted(() => {
listRegionOptions().then(res => {
regionOptions.value = res.data || []
})
})
defineExpose({ open })
</script>
<style scoped>
.mb8 { margin-bottom: 8px; }
.charge-value { font-weight: 600; color: #d63031; }
</style>
@@ -0,0 +1,121 @@
<template>
<el-dialog :title="title" v-model="visible" width="560px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="附加费" prop="scId">
<el-select v-model="form.scId" placeholder="请选择附加费" filterable style="width: 100%">
<el-option v-for="s in surchargePool" :key="s.scId" :label="s.scName" :value="s.scId" />
</el-select>
</el-form-item>
<el-form-item label="价格类型">
<el-tag type="danger" effect="plain">红线价</el-tag>
</el-form-item>
<el-form-item label="适用国家" prop="countryCode">
<el-select v-model="form.countryCode" clearable filterable placeholder="空=全部国家" style="width: 100%">
<el-option
v-for="r in regionOptions"
:key="r.countryCode2"
:label="(r.countryNameCn || r.countryNameEn || r.countryCode2) + ' (' + r.countryCode2 + ')'"
:value="r.countryCode2"
/>
</el-select>
</el-form-item>
<el-form-item label="作用范围" prop="scope">
<el-radio-group v-model="form.scope">
<el-radio value="PRODUCT">产品级(全部明细)</el-radio>
<el-radio value="DETAIL">明细级(指定国家×重量段)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="form.scope === 'DETAIL'" label="明细" prop="detailId">
<el-select v-model="form.detailId" placeholder="请选择明细" filterable style="width: 100%">
<el-option v-for="d in countryList" :key="d.detailId" :label="detailLabel(d)" :value="d.detailId" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submit">确 定</el-button>
<el-button @click="visible = false">取 消</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { listRedlineCountries } from '@/api/price/redline'
import { listSurcharge, addSurchargeRel } from '@/api/price/surcharge'
import { listRegionOptions } from '@/api/data/region'
const emit = defineEmits(['success'])
const { proxy } = getCurrentInstance()
const formRef = ref()
const visible = ref(false)
const title = ref('新增附加费关联')
const product = ref(null)
const surchargePool = ref([])
const countryList = ref([])
const regionOptions = ref([])
const form = ref({})
const rules = {
scId: [{ required: true, message: '请选择附加费', trigger: 'change' }],
}
function detailLabel(d) {
const w = d.weightMin != null && d.weightMax != null ? (d.weightMin + '-' + d.weightMax + 'kg') : '-'
return (d.countryName || d.countryCode || '-') + ' · ' + w
}
function reset() {
form.value = {
scId: undefined,
priceType: 'REDLINE',
countryCode: undefined,
scope: 'PRODUCT',
detailId: undefined
}
formRef.value?.resetFields()
}
function open(row) {
product.value = row
reset()
visible.value = true
listSurcharge({ ssCode: 'on', pageNum: 1, pageSize: 1000 }).then(res => {
surchargePool.value = res.rows || []
})
listRegionOptions().then(res => {
regionOptions.value = res.data || []
})
if (row && row.productCode) {
listRedlineCountries(row.productCode, undefined, row.productId).then(res => {
countryList.value = res.data || []
})
}
}
function submit() {
formRef.value.validate(valid => {
if (!valid) return
if (form.value.scope === 'DETAIL' && !form.value.detailId) {
proxy.$modal.msgError('请选择明细')
return
}
const payload = {
scId: form.value.scId,
productId: product.value ? product.value.productId : undefined,
productCode: product.value ? product.value.productCode : undefined,
detailId: form.value.scope === 'DETAIL' ? form.value.detailId : null,
priceType: 'REDLINE',
countryCode: form.value.countryCode || null
}
addSurchargeRel(payload).then(() => {
proxy.$modal.msgSuccess('关联成功')
visible.value = false
emit('success')
})
})
}
defineExpose({ open })
</script>
@@ -0,0 +1,375 @@
<template>
<div class="app-container">
<el-alert
type="info"
:closable="false"
show-icon
class="mb16"
title="导入前须先选择生效时间;红线价与国家明细均按此时间落库,上一版产品/明细失效时间设为该时间。多国家价表用「全部导入」,单国专线成本价用「按国家导入」。相同产品代码+生效日期不覆盖主数据。"
/>
<basic-table
ref="tableRef"
:extra-params="extraParams"
:columns="columns"
:search-columns="searchColumns"
:query="queryList"
row-key="productId"
>
<template #toolbar="{ selection }">
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['price:redline:add']">新建红线价</el-button>
</el-col>
<el-col :span="1.5">
<el-dropdown @command="handleImportCommand">
<el-button type="info" plain icon="Upload" v-hasPermi="['price:redline:import']">
导入 Excel<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="all">全部导入(多国家价表)</el-dropdown-item>
<el-dropdown-item command="country">按国家导入(单国专线成本价)</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-col>
<el-col :span="1.5">
<el-button plain icon="Clock" @click="expiredRef.open()" v-hasPermi="['price:redline:list']">失效价格</el-button>
</el-col>
</template>
<template #col-cargoAttr="{ row }">
<el-tag type="primary" size="small" effect="plain">{{ row.cargoAttr || '-' }}</el-tag>
</template>
<template #col-status="{ row }">
<dict-tag :options="bms_product_effect_status" :value="row.status" />
</template>
<template #col-action="{ row }">
<el-button link type="primary" title="价格" icon="Document" @click="excelFileRef.open(row)" v-hasPermi="['price:redline:query']"></el-button>
<el-button link type="primary" title="红线价明细" icon="View" @click="countryRef.open(row)" v-hasPermi="['price:redline:query']"></el-button>
<el-button link type="primary" title="附加费" icon="Money" @click="surchargeRef.open(row)" v-hasPermi="['price:surcharge:query']"></el-button>
<el-button title="编辑" link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['price:redline:edit']"></el-button>
</template>
</basic-table>
<redline-form-dialog ref="formRef" @success="() => tableRef.refresh()" />
<redline-excel-file-drawer ref="excelFileRef" />
<redline-country-drawer ref="countryRef" />
<redline-surcharge-drawer ref="surchargeRef" @success="() => tableRef.refresh()" />
<redline-expired-drawer ref="expiredRef" @view-country="(row) => countryRef.open(row)" />
<excel-import-dialog
ref="importRef"
title="红线价 Excel 导入"
width="560px"
:show-update-support="false"
template-url="/templates/product/product-import-all-template.xlsx"
template-file-name="产品多国家价表导入模板"
:import-desc="allImportDesc"
:before-submit="validateImportEffectiveDate"
:submit-handler="runImportAllAsync"
@success="() => tableRef.refresh()"
>
<template #extra>
<el-form label-width="90px">
<el-form-item label="生效时间" required>
<el-date-picker
v-model="importEffectiveDate"
type="datetime"
format="YYYY-MM-DD HH:00"
value-format="YYYY-MM-DD HH:00:00"
popper-class="redline-import-hour-picker"
placeholder="请选择生效时间"
style="width: 100%"
@change="onImportEffectiveTimeChange"
/>
</el-form-item>
</el-form>
</template>
</excel-import-dialog>
<excel-import-dialog
ref="countryImportRef"
title="按国家导入"
width="560px"
:show-update-support="false"
:before-submit="validateCountryImport"
:import-desc="countryImportDesc"
template-url="/templates/product/product-import-country-template.xlsx"
template-file-name="产品按国家导入模板"
:submit-handler="runImportCountryAsync"
@success="() => tableRef.refresh()"
>
<template #extra>
<el-form label-width="90px">
<el-form-item label="生效时间" required>
<el-date-picker
v-model="importEffectiveDate"
type="datetime"
format="YYYY-MM-DD HH:00"
value-format="YYYY-MM-DD HH:00:00"
popper-class="redline-import-hour-picker"
placeholder="请选择生效时间"
style="width: 100%"
@change="onImportEffectiveTimeChange"
/>
</el-form-item>
<el-form-item label="目的国家" required>
<el-select v-model="importCountryCode" filterable clearable placeholder="请选择国家" style="width: 100%">
<el-option
v-for="item in regionOptions"
:key="item.countryCode2"
:label="regionLabel(item)"
:value="item.countryCode2"
/>
</el-select>
</el-form-item>
</el-form>
</template>
</excel-import-dialog>
<el-dialog
v-model="importProgressVisible"
title="正在导入产品"
width="420px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
append-to-body
>
<div class="import-progress">
<el-progress :percentage="importProgressPercent" :status="importProgressStatus" />
<div class="import-progress-msg">{{ importProgressMessage }}</div>
</div>
</el-dialog>
</div>
</template>
<script setup name="PriceProduct">
import ExcelImportDialog from '@/components/ExcelImportDialog'
import RedlineFormDialog from './components/RedlineFormDialog.vue'
import RedlineExcelFileDrawer from './components/RedlineExcelFileDrawer.vue'
import RedlineCountryDrawer from './components/RedlineCountryDrawer.vue'
import RedlineSurchargeDrawer from './components/RedlineSurchargeDrawer.vue'
import RedlineExpiredDrawer from './components/RedlineExpiredDrawer.vue'
import { listRedline, importRedlineExcelAsync, importRedlineCountryExcelAsync } from '@/api/price/redline'
import { listRegionOptions } from '@/api/data/region'
const { bms_product_effect_status } = useDict('bms_product_effect_status')
const extraParams = { statusGroup: 'active' }
const tableRef = ref()
const formRef = ref()
const excelFileRef = ref()
const countryRef = ref()
const surchargeRef = ref()
const expiredRef = ref()
const importRef = ref()
const countryImportRef = ref()
const importCountryCode = ref('')
const importEffectiveDate = ref('')
function pad2(n) {
return String(n).padStart(2, '0')
}
function formatImportHour(d) {
if (!d || Number.isNaN(d.getTime())) return undefined
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
+ ' ' + pad2(d.getHours()) + ':00:00'
}
function normalizeImportToHour(val) {
if (val == null || val === '') return val
if (val instanceof Date) return formatImportHour(val)
const s = String(val).trim().replace('T', ' ')
const m = s.match(/^(\d{4}-\d{2}-\d{2})[ ](\d{2})/)
if (m) return m[1] + ' ' + m[2] + ':00:00'
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s + ' 00:00:00'
return formatImportHour(new Date(s.replace(/-/g, '/'))) || val
}
function onImportEffectiveTimeChange(val) {
importEffectiveDate.value = normalizeImportToHour(val) || ''
}
const regionOptions = ref([])
const importProgressVisible = ref(false)
const importProgressPercent = ref(0)
const importProgressMessage = ref('')
const importProgressStatus = ref(undefined)
const allImportDesc = [
'适用多国家价表(B2C 小包价表等),按目录 sheet 匹配国家导入。',
'必选生效时间:产品与 product_country 明细均按此时间计费;上一版产品/明细失效时间设为该时间。',
'请在弹窗底部下载模板,按模板格式整理后上传。相同产品代码+生效日期不覆盖主数据。'
]
const countryImportDesc = [
'选择目的国家后上传专线成本价表,目录中的产品都会记到该国家。',
'必选生效时间:产品与 product_country 明细均按此时间计费;上一版产品/明细失效时间设为该时间。',
'邮编、赔偿标准等 sheet 不导入。可在弹窗底部下载模板。'
]
const columns = [
{ prop: 'productCode', label: '产品代码', minWidth: 110 },
{ prop: 'productName', label: '产品名称', minWidth: 140, showOverflowTooltip: true },
{ prop: 'serviceLevel', label: '时效等级', width: 90, align: 'center' },
{ prop: 'cargoAttr', label: '走货属性', minWidth: 120, slot: 'cargoAttr' },
{ prop: 'destRegion', label: '目的区域', width: 90, align: 'center' },
{ prop: 'effectiveDate', label: '生效时间', width: 160, align: 'center' },
{ prop: 'expireDate', label: '失效时间', width: 160, align: 'center' },
{ prop: 'countryCount', label: '国家数', width: 80, align: 'center' },
{ prop: 'surchargeCount', label: '附加费数', width: 90, align: 'center' },
{ prop: 'status', label: '状态', width: 90, align: 'center', slot: 'status' },
{ prop: 'updateNote', label: '更新内容', minWidth: 160, showOverflowTooltip: true },
{ prop: 'action', label: '操作', width: 240, fixed: 'right', slot: 'action' }
]
const searchColumns = [
{ prop: 'productCode', label: '产品代码', component: 'input', placeholder: '产品代码' },
{ prop: 'productName', label: '产品名称', component: 'input', placeholder: '产品名称' }
]
function queryList(params) {
return listRedline(params)
}
function handleAdd() {
formRef.value.open()
}
function handleUpdate(row) {
formRef.value.open(row)
}
function handleImportCommand(command) {
if (command === 'country') {
handleCountryImport()
return
}
handleImport()
}
function handleImport() {
importEffectiveDate.value = ''
importRef.value.open()
}
function regionLabel(item) {
const name = item.countryNameCn || item.countryNameEn || ''
const code = item.countryCode2 || ''
return name && code ? name + ' (' + code + ')' : (name || code)
}
function loadRegionOptions() {
if (regionOptions.value.length) return
listRegionOptions().then(res => {
regionOptions.value = res.data || []
})
}
function handleCountryImport() {
importCountryCode.value = ''
importEffectiveDate.value = ''
loadRegionOptions()
countryImportRef.value.open()
}
function validateImportEffectiveDate() {
if (!importEffectiveDate.value) {
return '请先选择生效时间'
}
return ''
}
function validateCountryImport() {
const effErr = validateImportEffectiveDate()
if (effErr) return effErr
if (!importCountryCode.value) {
return '请先选择目的国家'
}
return ''
}
function applyImportProgress(t) {
importProgressPercent.value = Math.min(100, Number(t.percent) || 0)
if (t.status === 'PENDING') importProgressMessage.value = '排队中…'
else if (t.status === 'RUNNING') {
const m = t.message || ''
if (m === 'parsing excel') importProgressMessage.value = '正在解析价表…'
else if (m === 'finishing') importProgressMessage.value = '收尾中…'
else if (m === 'queued') importProgressMessage.value = '排队中…'
else if (m.indexOf('importing ') === 0) {
const rest = m.slice('importing '.length)
importProgressMessage.value = '正在导入产品 ' + rest
} else importProgressMessage.value = m || '正在导入…'
} else if (t.status === 'SUCCESS') {
importProgressMessage.value = t.message || '导入完成'
importProgressStatus.value = 'success'
} else if (t.status === 'FAIL') {
importProgressMessage.value = t.error || t.message || '导入失败'
importProgressStatus.value = 'exception'
}
}
async function runImportWithProgress(runner) {
importProgressVisible.value = true
importProgressPercent.value = 0
importProgressMessage.value = '上传并解析中…'
importProgressStatus.value = undefined
try {
const { task } = await runner(applyImportProgress)
importProgressPercent.value = 100
importProgressStatus.value = 'success'
return (task && task.message) ? task.message : '导入完成'
} catch (e) {
importProgressStatus.value = 'exception'
const tip = (e && e.task && e.task.error) || (e && e.message) || '导入失败'
importProgressMessage.value = tip
await new Promise(resolve => setTimeout(resolve, 600))
throw e
} finally {
importProgressVisible.value = false
}
}
async function runImportAllAsync(uploadFile) {
const raw = uploadFile && (uploadFile.raw || uploadFile)
if (!raw) {
throw new Error('请先选择文件')
}
if (!importEffectiveDate.value) {
throw new Error('请先选择生效时间')
}
const eff = importEffectiveDate.value
return runImportWithProgress((onProgress) => importRedlineExcelAsync(raw, eff, onProgress))
}
async function runImportCountryAsync(uploadFile) {
const raw = uploadFile && (uploadFile.raw || uploadFile)
if (!raw) {
throw new Error('请先选择文件')
}
if (!importEffectiveDate.value) {
throw new Error('请先选择生效时间')
}
if (!importCountryCode.value) {
throw new Error('请先选择目的国家')
}
const code = importCountryCode.value
const eff = importEffectiveDate.value
return runImportWithProgress((onProgress) => importRedlineCountryExcelAsync(raw, code, eff, onProgress))
}
</script>
<style scoped>
.mb16 { margin-bottom: 16px; }
.import-progress { padding: 8px 4px 4px; }
.import-progress-msg {
margin-top: 12px;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.5;
word-break: break-word;
max-height: 160px;
overflow: auto;
}
</style>