导入批量修改价格明细

This commit is contained in:
wuweihua
2026-09-28 18:01:26 +08:00
parent c278ed76f7
commit b247bc2fd2
19 changed files with 777 additions and 54 deletions
@@ -27,6 +27,16 @@
<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="colBaseFv" width="130" align="center">
<template #default="{ row }">
<span class="price-text base-price">{{ formatNum(row._baseFreight, 4) }}</span>
</template>
</el-table-column>
<el-table-column :label="colBaseReg" width="130" align="center">
<template #default="{ row }">
<span class="price-text base-price">{{ formatNum(row._baseReg, 2) }}</span>
</template>
</el-table-column>
<el-table-column :label="colSetFv" width="160" align="center">
<template #default="{ row }">
<div class="price-cell">
@@ -111,11 +121,13 @@ const title = computed(() => {
const name = props.productName || ''
return code + ' · ' + name + ' · 国家服务价格'
})
const hint = '黄色行=已改价;点编辑图标后修改,失焦即刻写入数据库。单位:运费 RMB/KG,挂号费 RMB/票。'
const hint = '黄色行=已改价(与产品基础价不同);点编辑图标后修改,失焦即刻写入数据库。单位:运费 RMB/KG,挂号费 RMB/票。'
const phSearch = '搜索国家/分区'
const colCountry = '国家'
const colZone = '分区'
const colWeight = '重量段'
const colBaseFv = '产品基础运费'
const colBaseReg = '产品基础挂号费'
const colSetFv = '套餐运费'
const colSetReg = '套餐挂号费'
const colSurcharge = '附加费'
@@ -289,7 +301,7 @@ watch(() => props.modelValue, v => {
position: fixed;
top: 0;
right: 0;
width: min(960px, 96vw);
width: min(1180px, 98vw);
height: 100%;
background: #fff;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
@@ -333,6 +345,9 @@ watch(() => props.modelValue, v => {
min-width: 64px;
text-align: right;
}
.base-price {
color: #909399;
}
.edit-ico {
padding: 0 2px !important;
}
@@ -20,6 +20,7 @@
v-if="loaded"
:details="details"
:price-set-id="priceSetId"
:psv-id="version.psvId"
:set-type="setType"
:effective-time="version.effectiveTime"
:version-no="version.versionNo"
@@ -15,6 +15,7 @@
<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>
<el-button size="small" type="primary" plain :disabled="!priceSetId || !isFixedType(setType)" @click="openImport">导入修改价格</el-button>
</div>
<el-table :data="filteredProducts" border empty-text="暂无产品价格明细">
@@ -74,10 +75,36 @@
@surcharge-change="onRowSurchargeChange"
@pick-surcharge="openSurchargeFromDrawer"
/>
<excel-import-dialog
ref="importRef"
title="导入修改套餐价格"
width="560px"
:show-update-support="false"
:import-desc="importDesc"
:submit-handler="runImportAsync"
@success="onImportSuccess"
/>
<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>
</el-card>
</template>
<script setup name="StepDetails">
import ExcelImportDialog from '@/components/ExcelImportDialog'
import PriceEditDrawer from './PriceEditDrawer.vue'
import SurchargePickerDialog from './SurchargePickerDialog.vue'
import { useDetailCascade } from '../../composables/usePriceSetRefData'
@@ -90,11 +117,12 @@ import {
surchargeAppliesToRow,
toDetailPayload
} from '../../utils/priceSet'
import { updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
import { getPriceSet, importSetDetailPricesAsync, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
const props = defineProps({
details: { type: Array, required: true },
priceSetId: { type: [Number, String], default: undefined },
psvId: { type: [Number, String], default: undefined },
setType: { type: String, default: 'FIXED' },
effectiveTime: { type: String, default: undefined },
versionNo: { type: String, default: '' },
@@ -137,6 +165,16 @@ const discVisible = ref(false)
const discTitle = ref('编辑价格')
const discValue = ref(undefined)
const discCode = ref('')
const importRef = ref()
const importProgressVisible = ref(false)
const importProgressPercent = ref(0)
const importProgressMessage = ref('')
const importProgressStatus = ref(undefined)
const importDesc = [
'适用威狮B2C小包价表(含「产品目录」)。',
'按产品+国家+分区(有则匹配)+重量段匹配已有套餐明细,仅更新运费/挂号费。',
'仅固定价套餐可导入;未匹配行不会新增明细。'
]
const typeHint = computed(() => '· 当前:' + setTypeLabel(props.setType))
@@ -175,6 +213,85 @@ const pulling = ref(false)
function noop() {}
function openImport() {
if (!props.priceSetId) {
proxy.$modal.msgWarning('缺少套餐ID')
return
}
if (!isFixedType(props.setType)) {
proxy.$modal.msgWarning('仅固定价套餐支持导入')
return
}
importRef.value && importRef.value.open()
}
async function runImportAsync(uploadFile) {
const raw = uploadFile && (uploadFile.raw || uploadFile)
if (!raw) {
throw new Error('请先选择文件')
}
importProgressVisible.value = true
importProgressPercent.value = 0
importProgressMessage.value = '上传并解析中…'
importProgressStatus.value = undefined
try {
const { task } = await importSetDetailPricesAsync(
raw,
{ priceSetId: props.priceSetId, psvId: props.psvId },
(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 === 'matching details') importProgressMessage.value = '正在匹配并更新价格…'
else if (m === 'finishing') importProgressMessage.value = '收尾中…'
else if (m === 'queued') importProgressMessage.value = '排队中…'
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'
}
}
)
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 onImportSuccess() {
if (!props.priceSetId) return
try {
const res = await getPriceSet(props.priceSetId)
const data = res.data || {}
const versions = data.versions || []
const qPsv = props.psvId
const byQuery = qPsv != null ? versions.find(v => String(v.psvId) === String(qPsv)) : null
const current = byQuery || data.version || versions[0] || {}
const src = (current.details && current.details.length) ? current.details : (data.details || [])
props.details.splice(0, props.details.length, ...src.map(d => hydrateRow(d, props.setType)))
if (priceVisible.value && editCode.value) {
const group = productGroups.value.find(p => p.productCode === editCode.value)
editRows.value = group ? group.rows : []
editGroup.value = group || null
await stampPriceFloors(editRows.value, editCode.value)
}
} catch (e) {}
}
/** enter step 3: pull all library products + country prices (skip already added) */
async function pullAllFromLibrary(opts = {}) {
const silent = !!opts.silent
@@ -268,11 +385,7 @@ function openPrice(row) {
}
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
})
editRows.value = row.rows || []
editGroup.value = row
Promise.resolve(stampPriceFloors(editRows.value, row.productCode)).finally(() => {
priceVisible.value = true
@@ -326,8 +439,14 @@ async function onRowPriceChange(row) {
function applyImportedFloor(row, hit) {
if (!row || !hit) return
if (hit.freightPrice != null) row._floorFreight = Number(hit.freightPrice)
if (hit.registrationFee != null) row._floorReg = Number(hit.registrationFee)
if (hit.freightPrice != null) {
row._floorFreight = Number(hit.freightPrice)
row._baseFreight = Number(hit.freightPrice)
}
if (hit.registrationFee != null) {
row._floorReg = Number(hit.registrationFee)
row._baseReg = Number(hit.registrationFee)
}
}
function findImportedCountry(rows, row) {
@@ -341,6 +460,11 @@ function findImportedCountry(rows, row) {
&& String(row.countryCode).toUpperCase() === String(item.countryCode).toUpperCase())
|| (!row.countryCode && row.countryName && item.countryName && row.countryName === item.countryName)
if (!sameCountry) return false
const rz = String(row.zoneCode || '').toUpperCase()
const iz = String(item.zoneCode || '').toUpperCase()
if (rz || iz) {
if (rz !== iz) return false
}
if (row.weightMin != null && item.weightMin != null && Number(row.weightMin) !== Number(item.weightMin)) return false
if (row.weightMax != null && item.weightMax != null && Number(row.weightMax) !== Number(item.weightMax)) return false
return row.weightMin != null || row.weightMax != null
@@ -514,6 +638,15 @@ defineExpose({ pullAllFromLibrary })
font-size: 13px;
}
.mb12 { margin-bottom: 12px; }
.import-progress {
padding: 8px 4px 0;
}
.import-progress-msg {
margin-top: 12px;
color: #606266;
font-size: 13px;
word-break: break-all;
}
.filters {
display: flex;
align-items: center;
@@ -36,19 +36,33 @@
<el-table-column label="重量段" prop="weightLabel" min-width="130" />
<el-table-column label="运费单价" 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>
<el-tooltip placement="top" :disabled="!hasBaseFreight(row)">
<template #content>
<span>产品基础运费:{{ formatBase(row.baseFreightPrice, 4) }} RMB/KG</span>
</template>
<span class="price-tip">
<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>
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column label="挂号费" min-width="120" align="right">
<template #default="{ row }">
<span class="reg">{{ row.fvRegistrationFee }}</span>
<span class="unit"> RMB/票</span>
<el-tooltip placement="top" :disabled="!hasBaseReg(row)">
<template #content>
<span>产品基础挂号费:{{ formatBase(row.baseRegistrationFee, 2) }} RMB/票</span>
</template>
<span class="price-tip">
<span class="reg">{{ row.fvRegistrationFee }}</span>
<span class="unit"> RMB/票</span>
</span>
</el-tooltip>
</template>
</el-table-column>
</template>
@@ -139,6 +153,19 @@ const hint = computed(() => isFixedType(props.set.setType)
? '运费单价单位 RMB/KG(留空表示不覆盖,按产品基础价计费),挂号费单位 RMB/票;附加费来自产品关联配置,此处只读展示。'
: '未配置明细时全部在售产品均适用套餐默认折扣。')
function hasBaseFreight(row) {
return row && row.baseFreightPrice != null && row.baseFreightPrice !== ''
}
function hasBaseReg(row) {
return row && row.baseRegistrationFee != null && row.baseRegistrationFee !== ''
}
function formatBase(v, precision) {
if (v == null || v === '') return '-'
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return n.toFixed(precision)
}
function roundModeLabel(mode) {
const hit = (bms_round_mode.value || []).find(d => d.value === mode)
return hit ? hit.label : (mode || '四舍五入')
@@ -199,4 +226,11 @@ function remove(row) {
color: #909399;
line-height: 1.6;
}
.price-tip {
cursor: help;
border-bottom: 1px dashed transparent;
}
.price-tip:hover {
border-bottom-color: #c0c4cc;
}
</style>
@@ -73,19 +73,33 @@
<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>
<el-tooltip placement="top" :disabled="!hasBaseFreight(row)">
<template #content>
<span>产品基础运费:{{ formatBase(row.baseFreightPrice, 4) }} RMB/KG</span>
</template>
<span class="price-tip">
<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>
</span>
</el-tooltip>
</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>
<el-tooltip placement="top" :disabled="!hasBaseReg(row)">
<template #content>
<span>产品基础挂号费:{{ formatBase(row.baseRegistrationFee, 2) }} RMB/票</span>
</template>
<span class="price-tip">
<span class="reg">{{ row.fvRegistrationFee }}</span>
<span class="unit"> RMB/票</span>
</span>
</el-tooltip>
</template>
</el-table-column>
</template>
@@ -211,6 +225,19 @@ const title = computed(() => {
return (v.versionNo || '-') + ' · 版本明细'
})
function hasBaseFreight(row) {
return row && row.baseFreightPrice != null && row.baseFreightPrice !== ''
}
function hasBaseReg(row) {
return row && row.baseRegistrationFee != null && row.baseRegistrationFee !== ''
}
function formatBase(v, precision) {
if (v == null || v === '') return '-'
const n = Number(v)
if (Number.isNaN(n)) return String(v)
return n.toFixed(precision)
}
function roundModeLabel(mode) {
const hit = (bms_round_mode.value || []).find(d => d.value === mode)
return hit ? hit.label : (mode || '四舍五入')
@@ -313,4 +340,11 @@ watch(
.reg { color: #d48806; font-weight: 600; }
.pct { color: #1890ff; font-weight: 600; }
.unit { font-size: 12px; color: #909399; }
.price-tip {
cursor: help;
border-bottom: 1px dashed transparent;
}
.price-tip:hover {
border-bottom-color: #c0c4cc;
}
</style>
@@ -323,10 +323,7 @@ export function useDetailCascade(refData) {
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)
// Keep package prices in fv*; product base is stamped later from bms_product_country.
return row
}
refData.ensureCountries(row.productCode).then(rows => {
+1
View File
@@ -14,6 +14,7 @@
ref="detailsRef"
:details="form.details"
:price-set-id="form.priceSetId"
:psv-id="form.version.psvId"
:set-type="form.basic.setType"
:version-no="form.version.versionNo"
:ref-data="refData"