修改套餐导入功能
This commit is contained in:
@@ -210,7 +210,7 @@ export function addSetDetail(data) {
|
||||
/**
|
||||
* Async import quote Excel into FIXED package detail prices; poll until done.
|
||||
* @param {File|Blob} file
|
||||
* @param {{ priceSetId: string|number, psvId?: string|number, updateOnly?: boolean }} query
|
||||
* @param {{ priceSetId: string|number, psvId?: string|number, updateOnly?: boolean, replaceByProductCountry?: boolean, countryCode?: string }} query
|
||||
* @param {(task: object) => void} [onProgress]
|
||||
* @returns {Promise<{ task: object }>}
|
||||
*/
|
||||
@@ -221,6 +221,9 @@ export async function importSetDetailPricesAsync(file, query, onProgress) {
|
||||
if (query && query.priceSetId != null) params.set('priceSetId', query.priceSetId)
|
||||
if (query && query.psvId != null && query.psvId !== '') params.set('psvId', query.psvId)
|
||||
if (query && query.updateOnly) params.set('updateOnly', 'true')
|
||||
// Package Excel build-price only (product+country replace scope)
|
||||
if (query && query.replaceByProductCountry) params.set('replaceByProductCountry', 'true')
|
||||
if (query && query.countryCode) params.set('countryCode', query.countryCode)
|
||||
const submitRes = await request({
|
||||
url: '/price/set/detail/importPrices?' + params.toString(),
|
||||
method: 'post',
|
||||
@@ -247,20 +250,24 @@ export function previewPullFromLibrary() {
|
||||
/**
|
||||
* Preview Excel import (no DB write).
|
||||
* @param {File|Blob} file
|
||||
* @param {{ updateOnly?: boolean, details?: Array }} [opts]
|
||||
* @param {{ updateOnly?: boolean, replaceByProductCountry?: boolean, countryCode?: string, details?: Array }} [opts]
|
||||
*/
|
||||
export function previewImportDetailPrices(file, opts = {}) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (opts.updateOnly) formData.append('updateOnly', 'true')
|
||||
if (opts.updateOnly && Array.isArray(opts.details)) {
|
||||
if (opts.replaceByProductCountry) formData.append('replaceByProductCountry', 'true')
|
||||
if (opts.countryCode) formData.append('countryCode', opts.countryCode)
|
||||
// updateOnly / Excel build-price merge need current draft details
|
||||
if ((opts.updateOnly || opts.replaceByProductCountry || opts.countryCode) && Array.isArray(opts.details)) {
|
||||
formData.append('detailsJson', JSON.stringify(opts.details))
|
||||
}
|
||||
return request({
|
||||
url: '/price/set/detail/previewImportPrices',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
// FormData must not go through repeat-submit JSON.stringify (collapses to "{}")
|
||||
headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false },
|
||||
timeout: 10 * 60 * 1000
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
<template>
|
||||
<el-dialog :title="title" v-model="visible" :width="width" append-to-body @close="handleClose" class="excel-import-dialog">
|
||||
<el-dialog
|
||||
:title="title"
|
||||
v-model="visible"
|
||||
:width="width"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
@closed="handleClosed"
|
||||
class="excel-import-dialog"
|
||||
>
|
||||
<div class="import-body">
|
||||
<!-- 业务说明(可选) -->
|
||||
<div v-if="importDesc.length" class="import-desc">
|
||||
@@ -81,6 +89,23 @@ import { getToken } from '@/utils/auth'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
function plainText(msg) {
|
||||
if (msg == null || msg === '') return '导入完成'
|
||||
return String(msg).replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '')
|
||||
}
|
||||
|
||||
/** Do not call el-upload.clearFiles while the dialog is hiding: EP sets inputRef.value.value = ''. */
|
||||
function safeClearFiles() {
|
||||
if (!visible.value) return
|
||||
const inst = uploadRef.value
|
||||
if (!inst || typeof inst.clearFiles !== 'function') return
|
||||
try {
|
||||
inst.clearFiles()
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
// 对话框标题
|
||||
title: {
|
||||
@@ -203,17 +228,14 @@ function open() {
|
||||
visible.value = true
|
||||
nextTick(() => {
|
||||
selectedFile.value = null
|
||||
uploadRef.value?.clearFiles()
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭时清理
|
||||
function handleClose() {
|
||||
function handleClosed() {
|
||||
isUploading.value = false
|
||||
progressPercent.value = 0
|
||||
validateError.value = ''
|
||||
selectedFile.value = null
|
||||
uploadRef.value?.clearFiles()
|
||||
}
|
||||
|
||||
// 下载模板:静态文件直接 <a download>,接口走 proxy.download
|
||||
@@ -262,7 +284,7 @@ const handleFileChange = (file, fileList) => {
|
||||
if (err) {
|
||||
validateError.value = err
|
||||
selectedFile.value = null
|
||||
nextTick(() => uploadRef.value?.clearFiles())
|
||||
safeClearFiles()
|
||||
return
|
||||
}
|
||||
validateError.value = ''
|
||||
@@ -280,18 +302,17 @@ const handleFileRemove = (file, fileList) => {
|
||||
function handleClearFile() {
|
||||
selectedFile.value = null
|
||||
validateError.value = ''
|
||||
uploadRef.value?.clearFiles()
|
||||
safeClearFiles()
|
||||
}
|
||||
|
||||
// 上传成功
|
||||
function handleSuccess(response) {
|
||||
visible.value = false
|
||||
isUploading.value = false
|
||||
progressPercent.value = 0
|
||||
validateError.value = ''
|
||||
selectedFile.value = null
|
||||
uploadRef.value?.clearFiles()
|
||||
proxy.$alert("<div style='overflow:auto;overflow-x:hidden;max-height:70vh;padding:10px 20px 0;'>" + response.msg + '</div>', '导入结果', { dangerouslyUseHTMLString: true })
|
||||
visible.value = false
|
||||
proxy.$modal.msgSuccess(plainText(response && response.msg))
|
||||
emit('success')
|
||||
}
|
||||
|
||||
@@ -315,25 +336,21 @@ async function handleSubmit() {
|
||||
progressPercent.value = 0
|
||||
try {
|
||||
const resultMsg = await props.submitHandler(file)
|
||||
visible.value = false
|
||||
validateError.value = ''
|
||||
selectedFile.value = null
|
||||
uploadRef.value?.clearFiles()
|
||||
const html = resultMsg == null || resultMsg === ''
|
||||
? '导入完成'
|
||||
: String(resultMsg)
|
||||
proxy.$alert("<div style='overflow:auto;overflow-x:hidden;max-height:70vh;padding:10px 20px 0;'>" + html + '</div>', '导入结果', { dangerouslyUseHTMLString: true })
|
||||
visible.value = false
|
||||
proxy.$modal.msgSuccess(plainText(resultMsg))
|
||||
emit('success')
|
||||
} catch (e) {
|
||||
const tip = (e && e.message) ? e.message : '导入失败'
|
||||
proxy.$modal.msgError(tip)
|
||||
proxy.$modal.msgError(plainText(tip) || '导入失败')
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
progressPercent.value = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
uploadRef.value.submit()
|
||||
uploadRef.value?.submit?.()
|
||||
}
|
||||
|
||||
defineExpose({ open })
|
||||
|
||||
@@ -13,11 +13,31 @@
|
||||
:disabled="(livePersist && !priceSetId) || pulling"
|
||||
@click="handlePullLibrary"
|
||||
>拉取产品列表</el-button>
|
||||
<el-dropdown
|
||||
v-if="showImportAction && !isModifyImport"
|
||||
trigger="click"
|
||||
@command="handleImportCommand"
|
||||
>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="(livePersist && !priceSetId) || pulling || (livePersist && buildModeDirty)"
|
||||
>
|
||||
{{ importActionLabel }}
|
||||
<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-button
|
||||
v-if="showImportAction"
|
||||
v-else-if="showImportAction"
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="(livePersist && !priceSetId) || pulling || buildModeDirty"
|
||||
:disabled="(livePersist && !priceSetId) || pulling || (livePersist && buildModeDirty)"
|
||||
@click="openImport"
|
||||
>{{ importActionLabel }}</el-button>
|
||||
</div>
|
||||
@@ -127,6 +147,32 @@
|
||||
@success="onImportSuccess"
|
||||
/>
|
||||
|
||||
<excel-import-dialog
|
||||
ref="countryImportRef"
|
||||
title="按国家导入"
|
||||
width="560px"
|
||||
:show-update-support="false"
|
||||
:import-desc="countryImportDesc"
|
||||
:before-submit="validateCountryImport"
|
||||
:submit-handler="runCountryImportAsync"
|
||||
@success="onImportSuccess"
|
||||
>
|
||||
<template #extra>
|
||||
<el-form label-width="90px">
|
||||
<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="progressDialogTitle"
|
||||
@@ -162,6 +208,7 @@ import {
|
||||
toDetailPayload
|
||||
} from '../../utils/priceSet'
|
||||
import { getPriceSet, listSetDetailByProduct, importSetDetailPricesAsync, pullSetDetailsFromLibraryAsync, previewPullFromLibrary, previewImportDetailPrices, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
|
||||
import { listRegionOptions } from '@/api/data/region'
|
||||
|
||||
const props = defineProps({
|
||||
details: { type: Array, required: true },
|
||||
@@ -219,6 +266,14 @@ const discTitle = ref('编辑价格')
|
||||
const discValue = ref(undefined)
|
||||
const discCode = ref('')
|
||||
const importRef = ref()
|
||||
const countryImportRef = ref()
|
||||
const importCountryCode = ref('')
|
||||
const regionOptions = ref([])
|
||||
const countryImportDesc = [
|
||||
'选择目的国家后上传专线成本价表(含「产品目录」),目录中的产品都会记到该国家。',
|
||||
'按产品+国家覆盖:仅替换已选国家的明细,其它产品/国家保留。',
|
||||
'邮编、赔偿标准等 sheet 不导入。仅套餐「Excel 导入建价」可用。'
|
||||
]
|
||||
const importProgressVisible = ref(false)
|
||||
const importProgressPercent = ref(0)
|
||||
const importProgressMessage = ref('')
|
||||
@@ -251,8 +306,8 @@ const importDesc = computed(() => {
|
||||
}
|
||||
return [
|
||||
'适用威狮B2C小包价表(含「产品目录」)。',
|
||||
'按产品+国家+分区(有则匹配)+重量段:命中则更新运费/挂号费,未命中则新增套餐明细。',
|
||||
'仅固定价套餐可导入。'
|
||||
'按产品+国家覆盖:Excel 中出现的产品+国家会替换已有同范围明细,其它产品/国家保留。',
|
||||
'仅固定价套餐「导入 Excel 建价」可用;不影响导入修改价格/拉取产品库。'
|
||||
]
|
||||
})
|
||||
|
||||
@@ -381,6 +436,22 @@ async function chooseBuildMode(mode) {
|
||||
let localKeySeq = 0
|
||||
|
||||
/** Apply preview products/details into parent reactive arrays (create defer mode). */
|
||||
function mergeLocalProducts(existing, incoming) {
|
||||
const map = new Map()
|
||||
for (const p of existing || []) {
|
||||
if (p && p.productCode) map.set(String(p.productCode).toUpperCase(), { ...p })
|
||||
}
|
||||
for (const p of incoming || []) {
|
||||
if (!p || !p.productCode) continue
|
||||
const key = String(p.productCode).toUpperCase()
|
||||
const prev = map.get(key) || {}
|
||||
const next = { ...prev, ...p }
|
||||
if (p.pefId == null && prev.pefId != null) next.pefId = prev.pefId
|
||||
map.set(key, next)
|
||||
}
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
function applyLocalDraft(products, details) {
|
||||
clearDetailCache()
|
||||
const hydrated = (details || []).map(d => {
|
||||
@@ -393,10 +464,12 @@ function applyLocalDraft(products, details) {
|
||||
return row
|
||||
})
|
||||
if (Array.isArray(props.details)) {
|
||||
props.details.splice(0, props.details.length, ...hydrated)
|
||||
props.details.splice(0, props.details.length)
|
||||
hydrated.forEach(row => props.details.push(row))
|
||||
}
|
||||
if (Array.isArray(props.products)) {
|
||||
props.products.splice(0, props.products.length, ...((products || []).map(p => ({ ...p }))))
|
||||
props.products.splice(0, props.products.length)
|
||||
;(products || []).forEach(p => props.products.push({ ...p }))
|
||||
}
|
||||
hydrated.forEach(d => {
|
||||
if (!d || !d.productCode) return
|
||||
@@ -405,74 +478,109 @@ function applyLocalDraft(products, details) {
|
||||
})
|
||||
}
|
||||
|
||||
async function openImport() {
|
||||
if (props.buildModeDirty) {
|
||||
function regionLabel(item) {
|
||||
const name = (item && (item.countryNameCn || item.countryNameEn)) || ''
|
||||
const code = (item && item.countryCode2) || ''
|
||||
return name && code ? name + ' (' + code + ')' : (name || code)
|
||||
}
|
||||
|
||||
function loadRegionOptions() {
|
||||
if (regionOptions.value.length) return
|
||||
listRegionOptions().then(res => {
|
||||
regionOptions.value = res.data || []
|
||||
})
|
||||
}
|
||||
|
||||
async function canOpenImport() {
|
||||
// Edit live: mode change must be saved before import. Create draft: import is local preview.
|
||||
if (props.livePersist && props.buildModeDirty) {
|
||||
proxy.$modal.msgWarning("请先点「完成」保存建价方式")
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (props.livePersist && !props.priceSetId) {
|
||||
proxy.$modal.msgWarning('缺少套餐ID')
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (!isFixedType(props.setType)) {
|
||||
proxy.$modal.msgWarning('仅固定价套餐支持导入')
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (props.livePersist) {
|
||||
const psvId = await resolvePsvId(true)
|
||||
if (!psvId) {
|
||||
proxy.$modal.msgWarning('缺少套餐版本,请先保存基本信息')
|
||||
return
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function openImport() {
|
||||
if (!(await canOpenImport())) return
|
||||
importRef.value && importRef.value.open()
|
||||
}
|
||||
|
||||
async function runImportAsync(uploadFile) {
|
||||
function handleImportCommand(cmd) {
|
||||
if (cmd === 'country') openCountryImport()
|
||||
else openImport()
|
||||
}
|
||||
|
||||
async function openCountryImport() {
|
||||
if (!(await canOpenImport())) return
|
||||
importCountryCode.value = ''
|
||||
loadRegionOptions()
|
||||
countryImportRef.value && countryImportRef.value.open()
|
||||
}
|
||||
|
||||
function validateCountryImport() {
|
||||
if (!importCountryCode.value) return '请先选择目的国家'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function runImportAsync(uploadFile, countryCode) {
|
||||
const raw = uploadFile && (uploadFile.raw || uploadFile)
|
||||
if (!raw) {
|
||||
throw new Error('请先选择文件')
|
||||
}
|
||||
progressDialogTitle.value = '正在导入价格'
|
||||
importProgressVisible.value = true
|
||||
importProgressPercent.value = 0
|
||||
importProgressMessage.value = '上传并解析中…'
|
||||
importProgressStatus.value = undefined
|
||||
try {
|
||||
if (!props.livePersist) {
|
||||
importProgressMessage.value = '正在解析价表…'
|
||||
importProgressPercent.value = 30
|
||||
const res = await previewImportDetailPrices(raw, {
|
||||
updateOnly: isModifyImport.value,
|
||||
details: isModifyImport.value ? (props.details || []) : undefined
|
||||
})
|
||||
const data = res.data || {}
|
||||
if (data.pefId != null) {
|
||||
emit('update:pefId', data.pefId)
|
||||
}
|
||||
const products = isModifyImport.value ? (props.products || []) : (data.products || [])
|
||||
applyLocalDraft(products, data.details || [])
|
||||
importProgressPercent.value = 100
|
||||
importProgressStatus.value = 'success'
|
||||
return data.message || '导入完成'
|
||||
// Do not open a second el-dialog over ExcelImportDialog: closing the overlay
|
||||
// click-throughs into el-upload and EP crashes (inputRef.value.value = '').
|
||||
// Excel build-price (IMPORT mode): merge by product+country. Modify-price / other modes unchanged.
|
||||
const replaceByProductCountry = !isModifyImport.value || !!countryCode
|
||||
if (!props.livePersist) {
|
||||
const res = await previewImportDetailPrices(raw, {
|
||||
updateOnly: isModifyImport.value && !countryCode,
|
||||
replaceByProductCountry,
|
||||
countryCode: countryCode || undefined,
|
||||
details: props.details || []
|
||||
})
|
||||
const data = res.data || {}
|
||||
if (data.pefId != null) {
|
||||
emit('update:pefId', data.pefId)
|
||||
}
|
||||
const { task } = await importSetDetailPricesAsync(
|
||||
raw,
|
||||
{ priceSetId: props.priceSetId, psvId: await resolvePsvId(false), updateOnly: isModifyImport.value },
|
||||
applyBuildProgress
|
||||
)
|
||||
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
|
||||
const products = replaceByProductCountry
|
||||
? mergeLocalProducts(props.products, data.products || [])
|
||||
: (isModifyImport.value ? (props.products || []) : (data.products || []))
|
||||
applyLocalDraft(products, data.details || [])
|
||||
return data.message || '导入完成'
|
||||
}
|
||||
const { task } = await importSetDetailPricesAsync(
|
||||
raw,
|
||||
{
|
||||
priceSetId: props.priceSetId,
|
||||
psvId: await resolvePsvId(false),
|
||||
updateOnly: isModifyImport.value && !countryCode,
|
||||
replaceByProductCountry,
|
||||
countryCode: countryCode || undefined
|
||||
}
|
||||
)
|
||||
return (task && task.message) ? task.message : '导入完成'
|
||||
}
|
||||
|
||||
async function runCountryImportAsync(uploadFile) {
|
||||
if (!importCountryCode.value) {
|
||||
throw new Error('请先选择目的国家')
|
||||
}
|
||||
return runImportAsync(uploadFile, importCountryCode.value)
|
||||
}
|
||||
|
||||
function applyBuildProgress(t) {
|
||||
|
||||
@@ -316,14 +316,14 @@ export function useDetailCascade(refData) {
|
||||
// Do NOT prefetch /surcharge/byProduct here: applySetData hydrates every detail row
|
||||
// on step-1 load; use SALES rels on 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)) {
|
||||
// Excel preview / DB bulk already has country + weight: normalize locally.
|
||||
// Do not fire /countries per detail row (import can be hundreds of rows).
|
||||
if (row.productCountryId != null
|
||||
|| ((row.weightMin != null || row.weightMax != null) && (row.countryCode || row.countryName))) {
|
||||
if (row.weightMin != null || row.weightMax != null) {
|
||||
row.weightLabel = formatWeightLabel(row.weightMin, row.weightMax)
|
||||
}
|
||||
row._countryKey = countryKeyOf(row)
|
||||
// Keep package prices in fv*; product base is stamped later from product_country.
|
||||
return row
|
||||
}
|
||||
refData.ensureCountries(row.productCode).then(rows => {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
:set-type="form.basic.setType"
|
||||
:price-build-mode="form.version.priceBuildMode"
|
||||
@update:price-build-mode="onPriceBuildModeUpdate"
|
||||
@update:pef-id="v => { draftPefId.value = v }"
|
||||
@update:pef-id="onPefIdUpdate"
|
||||
:version-no="form.version.versionNo"
|
||||
:version-status="form.version.status"
|
||||
:ref-data="refData"
|
||||
@@ -338,6 +338,10 @@ function onPsvIdUpdate(psvId) {
|
||||
if (psvId != null && psvId !== '') form.version.psvId = psvId
|
||||
}
|
||||
|
||||
function onPefIdUpdate(pefId) {
|
||||
draftPefId.value = pefId
|
||||
}
|
||||
|
||||
/** Step2 chose PULL / IMPORT: edit live-saves mode; create only local + optional preview pull. */
|
||||
function onPriceBuildModeUpdate(mode) {
|
||||
if (!mode) return
|
||||
|
||||
Reference in New Issue
Block a user