修改产品套餐
This commit is contained in:
@@ -9,6 +9,15 @@ export function quoteBilling(data) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Products from customer's bound effective package (trial dropdown) */
|
||||
export function listQuoteBoundProducts(customerCode, bizDate) {
|
||||
return request({
|
||||
url: '/price/billing/quote/products',
|
||||
method: 'get',
|
||||
params: { customerCode, bizDate }
|
||||
})
|
||||
}
|
||||
|
||||
/** Historical quote list */
|
||||
export function listQuote(query) {
|
||||
return request({
|
||||
|
||||
@@ -172,6 +172,15 @@ export function listSetDetail(query) {
|
||||
})
|
||||
}
|
||||
|
||||
/** All detail rows for one product under a version (no pagination). */
|
||||
export function listSetDetailByProduct(psvId, productCode) {
|
||||
return request({
|
||||
url: '/price/set/detail/byProduct',
|
||||
method: 'get',
|
||||
params: { psvId, productCode }
|
||||
})
|
||||
}
|
||||
|
||||
/** 版本明细搜索下拉选项:按版本去重后的 产品/国家/分区 */
|
||||
export function listSetDetailOptions(psvId) {
|
||||
return request({
|
||||
@@ -213,6 +222,31 @@ export async function importSetDetailPricesAsync(file, query, onProgress) {
|
||||
if (!taskId) {
|
||||
throw new Error('missing import taskId')
|
||||
}
|
||||
return pollPriceSetSaveTask(taskId, onProgress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Async pull product library into package version details; poll until done.
|
||||
* @param {{ priceSetId: string|number, psvId?: string|number, force?: boolean }} query
|
||||
* @param {(task: object) => void} [onProgress]
|
||||
*/
|
||||
export async function pullSetDetailsFromLibraryAsync(query, onProgress) {
|
||||
const params = new URLSearchParams()
|
||||
if (query && query.priceSetId != null) params.set('priceSetId', query.priceSetId)
|
||||
if (query && query.psvId != null && query.psvId !== '') params.set('psvId', query.psvId)
|
||||
params.set('force', query && query.force ? 'true' : 'false')
|
||||
const submitRes = await request({
|
||||
url: '/price/set/detail/pullFromLibrary?' + params.toString(),
|
||||
method: 'post'
|
||||
})
|
||||
const taskId = pickSaveTaskId(submitRes)
|
||||
if (!taskId) {
|
||||
throw new Error('missing pull taskId')
|
||||
}
|
||||
return pollPriceSetSaveTask(taskId, onProgress)
|
||||
}
|
||||
|
||||
async function pollPriceSetSaveTask(taskId, onProgress) {
|
||||
const started = Date.now()
|
||||
const maxMs = 30 * 60 * 1000
|
||||
while (Date.now() - started < maxMs) {
|
||||
@@ -225,13 +259,13 @@ export async function importSetDetailPricesAsync(file, query, onProgress) {
|
||||
return { task }
|
||||
}
|
||||
if (task.status === 'FAIL') {
|
||||
const err = new Error(task.error || task.message || 'import failed')
|
||||
const err = new Error(task.error || task.message || 'task failed')
|
||||
err.task = task
|
||||
throw err
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 800))
|
||||
}
|
||||
throw new Error('import timeout')
|
||||
throw new Error('task timeout')
|
||||
}
|
||||
|
||||
export function updateSetDetail(data) {
|
||||
|
||||
@@ -6,22 +6,24 @@
|
||||
<template #header><span>试算参数</span></template>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item label="客户" prop="customerCode">
|
||||
<customer-select v-model="form.customerCode" value-key="customerCode" placeholder="请选择客户" />
|
||||
<div class="form-hint">与套餐绑定客户一致;未绑定套餐时报错</div>
|
||||
<customer-select v-model="form.customerCode" value-key="customerCode" placeholder="请选择客户" @change="handleCustomerChange" />
|
||||
<div class="form-hint">产品列表来自该客户绑定套餐;未绑定或套餐无产品时为空</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品" prop="productCode">
|
||||
<el-select
|
||||
v-model="form.productCode"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="请选择产品"
|
||||
:placeholder="productPlaceholder"
|
||||
style="width: 100%"
|
||||
:disabled="!form.customerCode"
|
||||
:loading="productLoading"
|
||||
@change="handleProductChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in productOptions"
|
||||
:key="item.productCode"
|
||||
:label="item.productName + ' (' + item.productCode + ')'"
|
||||
:label="(item.productName || item.productCode) + ' (' + item.productCode + ')'"
|
||||
:value="item.productCode"
|
||||
/>
|
||||
</el-select>
|
||||
@@ -262,8 +264,8 @@
|
||||
</template>
|
||||
|
||||
<script setup name="BillingQuote">
|
||||
import { quoteBilling, listQuote, getQuote } from '@/api/price/billing'
|
||||
import { listProduct, listProductCountries } from '@/api/price/product'
|
||||
import { quoteBilling, listQuoteBoundProducts, listQuote, getQuote } from '@/api/price/billing'
|
||||
import { listProductCountries } from '@/api/price/product'
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const { bms_fee_type, bms_currency } = useDict('bms_fee_type', 'bms_currency')
|
||||
@@ -346,12 +348,62 @@ const rules = {
|
||||
weight: [{ required: true, message: '请输入计费重量', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
function loadProducts() {
|
||||
listProduct({ pageNum: 1, pageSize: 500, status: '1' }).then(res => {
|
||||
productOptions.value = res.rows || []
|
||||
const productLoading = ref(false)
|
||||
const productPlaceholder = computed(() => {
|
||||
if (!form.customerCode) return '请先选择客户'
|
||||
if (productLoading.value) return '加载中…'
|
||||
if (!(productOptions.value || []).length) return '该客户绑定套餐暂无产品'
|
||||
return '请选择产品'
|
||||
})
|
||||
|
||||
function clearProductCascade() {
|
||||
form.productCode = undefined
|
||||
form.countryCode = undefined
|
||||
form.destPostcode = undefined
|
||||
countryOptions.value = []
|
||||
countryRows.value = []
|
||||
productOptions.value = []
|
||||
}
|
||||
|
||||
function handleCustomerChange(code) {
|
||||
clearProductCascade()
|
||||
result.value = null
|
||||
errorMsg.value = ''
|
||||
if (!code) return
|
||||
loadProductsByCustomer(code)
|
||||
}
|
||||
|
||||
function loadProductsByCustomer(customerCode) {
|
||||
if (!customerCode) {
|
||||
productOptions.value = []
|
||||
return Promise.resolve([])
|
||||
}
|
||||
productLoading.value = true
|
||||
return listQuoteBoundProducts(customerCode, form.bizDate || undefined).then(res => {
|
||||
productOptions.value = res.data || []
|
||||
return productOptions.value
|
||||
}).catch(() => {
|
||||
productOptions.value = []
|
||||
return []
|
||||
}).finally(() => {
|
||||
productLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => form.bizDate, () => {
|
||||
if (!form.customerCode) return
|
||||
const keep = form.productCode
|
||||
loadProductsByCustomer(form.customerCode).then(list => {
|
||||
if (keep && !(list || []).some(p => p.productCode === keep)) {
|
||||
form.productCode = undefined
|
||||
form.countryCode = undefined
|
||||
form.destPostcode = undefined
|
||||
countryOptions.value = []
|
||||
countryRows.value = []
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function uniqueCountries(list) {
|
||||
const map = new Map()
|
||||
;(list || []).forEach(item => {
|
||||
@@ -430,6 +482,7 @@ function resetForm() {
|
||||
form.destPostcode = undefined
|
||||
form.weight = undefined
|
||||
form.bizDate = undefined
|
||||
productOptions.value = []
|
||||
countryOptions.value = []
|
||||
countryRows.value = []
|
||||
result.value = null
|
||||
@@ -539,7 +592,6 @@ function sourceTagType(source) {
|
||||
return ''
|
||||
}
|
||||
|
||||
loadProducts()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
border
|
||||
size="small"
|
||||
:span-method="spanMethod"
|
||||
:row-class-name="rowClass"
|
||||
>
|
||||
<el-table-column :label="colCountry" min-width="100">
|
||||
<template #default="{ row }">{{ countryLabel(row) }}</template>
|
||||
@@ -27,16 +26,6 @@
|
||||
<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">
|
||||
@@ -99,7 +88,6 @@
|
||||
|
||||
<script setup name="PriceEditDrawer">
|
||||
import SurchargeCountCell from './SurchargeCountCell.vue'
|
||||
import { isDetailPriceChanged } from '../../utils/priceSet'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
@@ -109,8 +97,6 @@ const props = defineProps({
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'pick-surcharge', 'row-change', 'surcharge-change'])
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const keyword = ref('')
|
||||
const editingKey = ref('')
|
||||
const committing = ref(false)
|
||||
@@ -121,13 +107,11 @@ 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 = '附加费'
|
||||
@@ -159,20 +143,6 @@ function startEdit(row, field) {
|
||||
})
|
||||
}
|
||||
|
||||
function isBelowFloor(value, floor) {
|
||||
if (value == null || value === '' || floor == null || floor === '') return false
|
||||
const n = Number(value)
|
||||
const f = Number(floor)
|
||||
if (Number.isNaN(n) || Number.isNaN(f)) return false
|
||||
return n + 1e-8 < f
|
||||
}
|
||||
|
||||
function belowFloorMessage(field, floor) {
|
||||
const name = field === 'fv' ? '\u5957\u9910\u8fd0\u8d39' : '\u5957\u9910\u6302\u53f7\u8d39'
|
||||
const prec = field === 'fv' ? 4 : 2
|
||||
return name + '\u4e0d\u80fd\u4f4e\u4e8e\u5bfc\u5165\u57fa\u51c6\u4ef7 ' + formatNum(floor, prec)
|
||||
}
|
||||
|
||||
async function commitEdit(row, field) {
|
||||
if (committing.value) return
|
||||
if (editingKey.value !== cellKey(row, field)) return
|
||||
@@ -180,16 +150,6 @@ async function commitEdit(row, field) {
|
||||
editingKey.value = ''
|
||||
try {
|
||||
if (!row) return
|
||||
// Temporarily allow freight / registration below imported base price.
|
||||
// if (field === 'fv') {
|
||||
// const floor = row._floorFreight
|
||||
// const value = row.fvValue
|
||||
// if (isBelowFloor(value, floor)) {
|
||||
// row.fvValue = row._editPrevFv
|
||||
// proxy.$modal.msgError(belowFloorMessage(field, floor))
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
if (!row.psdId) return
|
||||
emit('row-change', row)
|
||||
} finally {
|
||||
@@ -271,10 +231,6 @@ function spanOf(spans, rowIndex) {
|
||||
return rowspan > 0 ? { rowspan, colspan: 1 } : { rowspan: 0, colspan: 0 }
|
||||
}
|
||||
|
||||
function rowClass({ row }) {
|
||||
return isDetailPriceChanged(row) ? 'changed-row' : ''
|
||||
}
|
||||
|
||||
function close() {
|
||||
editingKey.value = ''
|
||||
emit('update:modelValue', false)
|
||||
@@ -345,13 +301,7 @@ watch(() => props.modelValue, v => {
|
||||
min-width: 64px;
|
||||
text-align: right;
|
||||
}
|
||||
.base-price {
|
||||
color: #909399;
|
||||
}
|
||||
.edit-ico {
|
||||
padding: 0 2px !important;
|
||||
}
|
||||
:deep(.changed-row > td) {
|
||||
background: #fffbe6 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<step-details
|
||||
v-if="loaded"
|
||||
:details="details"
|
||||
:products="products"
|
||||
:price-set-id="priceSetId"
|
||||
:psv-id="version.psvId"
|
||||
:set-type="setType"
|
||||
@@ -34,7 +35,7 @@
|
||||
<script setup name="PriceEditPanel">
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
import StepDetails from './StepDetails.vue'
|
||||
import { usePriceSetRefData, useDetailCascade } from '../../composables/usePriceSetRefData'
|
||||
import { usePriceSetRefData } from '../../composables/usePriceSetRefData'
|
||||
import { getPriceSet } from '@/api/price/set'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -46,13 +47,13 @@ const emit = defineEmits(['close'])
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const refData = usePriceSetRefData()
|
||||
const cascade = useDetailCascade(refData)
|
||||
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
const setType = ref('FIXED')
|
||||
const version = reactive({ psvId: undefined, versionNo: 'V1', effectiveTime: undefined })
|
||||
const details = ref([])
|
||||
const products = ref([])
|
||||
|
||||
const title = computed(() => '编辑产品价格 · ' + (version.versionNo || ''))
|
||||
|
||||
@@ -76,8 +77,8 @@ function load() {
|
||||
version.versionNo = current.versionNo || 'V1'
|
||||
version.effectiveTime = current.effectiveTime
|
||||
|
||||
const src = (current.details && current.details.length) ? current.details : (data.details || [])
|
||||
details.value = src.map(d => cascade.hydrateRow(d, setType.value))
|
||||
details.value = []
|
||||
products.value = (current.products || []).map(p => ({ ...p }))
|
||||
loaded.value = true
|
||||
}).finally(() => {
|
||||
loading.value = false
|
||||
|
||||
@@ -9,16 +9,40 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="showBuildActions" class="build-choice">
|
||||
<p class="build-choice__tip">暂无套餐销售价,请选择一种建价方式</p>
|
||||
<div class="build-choice__cards">
|
||||
<button
|
||||
type="button"
|
||||
class="build-card build-card--pull"
|
||||
:disabled="!priceSetId"
|
||||
@click="handlePullLibrary"
|
||||
>
|
||||
<div class="build-card__title">拉取产品列表</div>
|
||||
<div class="build-card__desc">按产品库基础价生成本版本套餐销售价</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="build-card build-card--import"
|
||||
:disabled="!priceSetId || !isFixedType(setType)"
|
||||
@click="openImport"
|
||||
>
|
||||
<div class="build-card__title">导入 Excel 建价</div>
|
||||
<div class="build-card__desc">上传威狮B2C小包价表,直接生成套餐销售价</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="filters">
|
||||
<el-input v-model="query.keyword" clearable :placeholder="phKeyword" style="width: 180px" />
|
||||
<el-select v-model="query.taxMode" clearable :placeholder="phTax" style="width: 140px">
|
||||
<el-option v-for="d in bms_tax_mode" :key="d.value" :label="d.label" :value="d.value" />
|
||||
</el-select>
|
||||
<el-button size="small" @click="noop">查询</el-button>
|
||||
<el-button size="small" type="primary" plain :disabled="!priceSetId || !isFixedType(setType)" @click="openImport">导入修改价格</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredProducts" border empty-text="暂无产品价格明细">
|
||||
<el-table :data="filteredProducts" border>
|
||||
<el-table-column :label="colCode" min-width="80">
|
||||
<template #default="{ row }"><b>{{ row.productCode }}</b></template>
|
||||
</el-table-column>
|
||||
@@ -39,11 +63,12 @@
|
||||
<el-table-column :label="colAction" width="220" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openPrice(row)">编辑价格</el-button>
|
||||
<el-button link type="primary" @click="openSurcharge(row)">附加费</el-button>
|
||||
<el-button link type="primary" @click="openSurchargeForProduct(row)">附加费</el-button>
|
||||
<el-button link type="danger" @click="removeProduct(row.productCode)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<surcharge-picker-dialog
|
||||
v-model="scVisible"
|
||||
@@ -78,7 +103,7 @@
|
||||
|
||||
<excel-import-dialog
|
||||
ref="importRef"
|
||||
title="导入修改套餐价格"
|
||||
title="导入 Excel 建价"
|
||||
width="560px"
|
||||
:show-update-support="false"
|
||||
:import-desc="importDesc"
|
||||
@@ -88,7 +113,7 @@
|
||||
|
||||
<el-dialog
|
||||
v-model="importProgressVisible"
|
||||
title="正在导入价格"
|
||||
:title="progressDialogTitle"
|
||||
width="420px"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="false"
|
||||
@@ -117,10 +142,11 @@ import {
|
||||
surchargeAppliesToRow,
|
||||
toDetailPayload
|
||||
} from '../../utils/priceSet'
|
||||
import { getPriceSet, importSetDetailPricesAsync, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
|
||||
import { getPriceSet, listSetDetailByProduct, importSetDetailPricesAsync, pullSetDetailsFromLibraryAsync, updateSetDetail, updateSetDetailSurchargeAsync, delSetDetail } from '@/api/price/set'
|
||||
|
||||
const props = defineProps({
|
||||
details: { type: Array, required: true },
|
||||
products: { type: Array, default: () => [] },
|
||||
priceSetId: { type: [Number, String], default: undefined },
|
||||
psvId: { type: [Number, String], default: undefined },
|
||||
setType: { type: String, default: 'FIXED' },
|
||||
@@ -170,33 +196,52 @@ const importProgressVisible = ref(false)
|
||||
const importProgressPercent = ref(0)
|
||||
const importProgressMessage = ref('')
|
||||
const importProgressStatus = ref(undefined)
|
||||
const progressDialogTitle = ref('正在导入价格')
|
||||
const importDesc = [
|
||||
'适用威狮B2C小包价表(含「产品目录」)。',
|
||||
'按产品+国家+分区(有则匹配)+重量段匹配已有套餐明细,仅更新运费/挂号费。',
|
||||
'仅固定价套餐可导入;未匹配行不会新增明细。'
|
||||
'按产品+国家+分区(有则匹配)+重量段:命中则更新运费/挂号费,未命中则新增套餐明细。',
|
||||
'仅固定价套餐可导入;可与「拉取产品列表」二选一或叠加使用。'
|
||||
]
|
||||
|
||||
const typeHint = computed(() => '· 当前:' + setTypeLabel(props.setType))
|
||||
|
||||
const productGroups = computed(() => {
|
||||
const map = new Map()
|
||||
for (const d of props.details) {
|
||||
if (!d.productCode) continue
|
||||
if (!map.has(d.productCode)) {
|
||||
const meta = props.refData.productMetaOf(d.productCode) || {}
|
||||
map.set(d.productCode, {
|
||||
productCode: d.productCode,
|
||||
productName: d.productName || meta.name || d.productCode,
|
||||
serviceLevel: meta.serviceLevel,
|
||||
cargoAttr: meta.cargoAttr,
|
||||
destRegion: meta.destRegion,
|
||||
taxMode: meta.taxMode,
|
||||
rows: []
|
||||
})
|
||||
/** Show pull/import when this version has no set-product rows yet. */
|
||||
const showBuildActions = computed(() => !(props.products || []).some(p => p && p.productCode))
|
||||
|
||||
/** productCode -> detail rows (loaded on demand) */
|
||||
const detailByProduct = reactive({})
|
||||
|
||||
function clearDetailCache() {
|
||||
Object.keys(detailByProduct).forEach(k => delete detailByProduct[k])
|
||||
}
|
||||
|
||||
async function loadProductDetails(productCode, force) {
|
||||
if (!productCode) return []
|
||||
if (!force && detailByProduct[productCode]) return detailByProduct[productCode]
|
||||
if (!props.psvId) return []
|
||||
const res = await listSetDetailByProduct(props.psvId, productCode)
|
||||
const rows = (res.data || []).map(d => hydrateRow(d, props.setType))
|
||||
detailByProduct[productCode] = rows
|
||||
// keep parent details array in sync for any legacy callers
|
||||
if (Array.isArray(props.details)) {
|
||||
for (let i = props.details.length - 1; i >= 0; i--) {
|
||||
if (props.details[i] && props.details[i].productCode === productCode) props.details.splice(i, 1)
|
||||
}
|
||||
map.get(d.productCode).rows.push(d)
|
||||
props.details.push(...rows)
|
||||
}
|
||||
return [...map.values()]
|
||||
return rows
|
||||
}
|
||||
|
||||
const productGroups = computed(() => {
|
||||
return (props.products || []).filter(p => p && p.productCode).map(p => ({
|
||||
productCode: p.productCode,
|
||||
productName: p.productName || p.productCode,
|
||||
serviceLevel: p.serviceLevel,
|
||||
cargoAttr: p.cargoAttr,
|
||||
destRegion: p.destRegion,
|
||||
taxMode: p.taxMode,
|
||||
rows: detailByProduct[p.productCode] || []
|
||||
}))
|
||||
})
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
@@ -231,6 +276,7 @@ async function runImportAsync(uploadFile) {
|
||||
if (!raw) {
|
||||
throw new Error('请先选择文件')
|
||||
}
|
||||
progressDialogTitle.value = '正在导入价格'
|
||||
importProgressVisible.value = true
|
||||
importProgressPercent.value = 0
|
||||
importProgressMessage.value = '上传并解析中…'
|
||||
@@ -239,24 +285,7 @@ async function runImportAsync(uploadFile) {
|
||||
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'
|
||||
}
|
||||
}
|
||||
applyBuildProgress
|
||||
)
|
||||
importProgressPercent.value = 100
|
||||
importProgressStatus.value = 'success'
|
||||
@@ -272,79 +301,113 @@ async function runImportAsync(uploadFile) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyBuildProgress(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 === 'pulling library') importProgressMessage.value = '正在拉取产品库…'
|
||||
else if (m === 'finishing') importProgressMessage.value = '收尾中…'
|
||||
else if (m === 'queued') importProgressMessage.value = '排队中…'
|
||||
else if (m.indexOf('importing ') === 0) importProgressMessage.value = '正在导入产品 ' + m.slice('importing '.length)
|
||||
else if (m.indexOf('saving details ') === 0) importProgressMessage.value = '正在保存明细 ' + m.slice('saving details '.length)
|
||||
else if (m === 'details exist, skip pull') importProgressMessage.value = '明细已存在,已跳过拉取'
|
||||
else if (m === 'saving surcharges') 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'
|
||||
}
|
||||
}
|
||||
|
||||
async function onImportSuccess() {
|
||||
await reloadDetailsFromServer()
|
||||
}
|
||||
|
||||
async function reloadDetailsFromServer() {
|
||||
if (!props.priceSetId) return
|
||||
try {
|
||||
clearDetailCache()
|
||||
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 (Array.isArray(props.details)) {
|
||||
props.details.splice(0, props.details.length)
|
||||
}
|
||||
if (Array.isArray(props.products)) {
|
||||
props.products.splice(0, props.products.length, ...(current.products || []).map(p => ({ ...p })))
|
||||
}
|
||||
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)
|
||||
const rows = await loadProductDetails(editCode.value, true)
|
||||
editRows.value = rows
|
||||
editGroup.value = productGroups.value.find(p => p.productCode === editCode.value) || null
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
/** enter step 3: pull all library products + country prices (skip already added) */
|
||||
async function handlePullLibrary() {
|
||||
if (!props.priceSetId) {
|
||||
proxy.$modal.msgWarning('缺少套餐ID')
|
||||
return
|
||||
}
|
||||
const hasProducts = (props.products || []).some(p => p && p.productCode)
|
||||
let force = false
|
||||
if (hasProducts) {
|
||||
try {
|
||||
await proxy.$modal.confirm('当前版本已有明细,拉取将清空并按产品库重建,是否继续?')
|
||||
force = true
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
}
|
||||
await pullAllFromLibrary({ force, silent: false })
|
||||
}
|
||||
|
||||
/** Backend pull product/product_country into prc_price_set_detail (async). */
|
||||
async function pullAllFromLibrary(opts = {}) {
|
||||
const silent = !!opts.silent
|
||||
const replace = !!opts.replace
|
||||
if (!(props.refData.productOptions.value || []).length) {
|
||||
await props.refData.loadProducts()
|
||||
}
|
||||
const products = props.refData.productOptions.value || []
|
||||
if (!products.length) {
|
||||
if (!silent) proxy.$modal.msgWarning('产品库无可用产品')
|
||||
const force = !!opts.force || !!opts.replace
|
||||
if (!props.priceSetId) {
|
||||
if (!silent) proxy.$modal.msgWarning('缺少套餐ID')
|
||||
return { added: 0 }
|
||||
}
|
||||
if (replace) {
|
||||
props.details.splice(0, props.details.length)
|
||||
}
|
||||
const used = new Set((props.details || []).map(d => d.productCode).filter(Boolean))
|
||||
const toAdd = products.filter(p => !used.has(p.value))
|
||||
if (!toAdd.length) return { added: 0 }
|
||||
|
||||
progressDialogTitle.value = '正在拉取产品列表'
|
||||
importProgressVisible.value = true
|
||||
importProgressPercent.value = 0
|
||||
importProgressMessage.value = '排队中…'
|
||||
importProgressStatus.value = undefined
|
||||
pulling.value = true
|
||||
let added = 0
|
||||
try {
|
||||
// parallel fetch countries/surcharges, then push rows
|
||||
await Promise.all(toAdd.map(async (p) => {
|
||||
const code = p.value
|
||||
const name = p.name || props.refData.productNameOf(code)
|
||||
await props.refData.ensureSurcharges(code)
|
||||
if (isDiscountType(props.setType)) {
|
||||
const row = createEmptyDetailRow()
|
||||
row.productCode = code
|
||||
row.productName = name
|
||||
row.surcharges = props.refData.surchargesOf(row)
|
||||
props.details.push(row)
|
||||
added++
|
||||
return
|
||||
}
|
||||
const open = await props.refData.ensureCountries(code, props.effectiveTime)
|
||||
if (!open.length) return
|
||||
open.forEach(item => {
|
||||
const row = countryToDetail(item, code, name)
|
||||
row.surcharges = props.refData.surchargesOf(row)
|
||||
props.details.push(row)
|
||||
})
|
||||
added++
|
||||
}))
|
||||
const { task } = await pullSetDetailsFromLibraryAsync(
|
||||
{ priceSetId: props.priceSetId, psvId: props.psvId, force },
|
||||
applyBuildProgress
|
||||
)
|
||||
importProgressPercent.value = 100
|
||||
importProgressStatus.value = 'success'
|
||||
await reloadDetailsFromServer()
|
||||
if (!silent) {
|
||||
if (added > 0) proxy.$modal.msgSuccess('已从产品库全量拉取 ' + added + ' 个产品')
|
||||
else proxy.$modal.msgWarning('未拉取到可展示的产品价格明细,请检查产品库国家配置')
|
||||
proxy.$modal.msgSuccess((task && task.message) ? task.message : '拉取完成')
|
||||
}
|
||||
return { added: (props.products || []).length }
|
||||
} catch (e) {
|
||||
importProgressStatus.value = 'exception'
|
||||
const tip = (e && e.task && e.task.error) || (e && e.message) || '拉取失败'
|
||||
importProgressMessage.value = tip
|
||||
if (!silent) proxy.$modal.msgError(tip)
|
||||
await new Promise(resolve => setTimeout(resolve, 600))
|
||||
return { added: 0 }
|
||||
} finally {
|
||||
pulling.value = false
|
||||
importProgressVisible.value = false
|
||||
}
|
||||
return { added }
|
||||
}
|
||||
|
||||
function countryToDetail(item, productCode, productName) {
|
||||
@@ -362,10 +425,6 @@ function countryToDetail(item, productCode, productName) {
|
||||
row.productCountryId = item.detailId
|
||||
const baseFv = item.freightPrice != null ? Number(item.freightPrice) : undefined
|
||||
const baseReg = item.registrationFee != null ? Number(item.registrationFee) : undefined
|
||||
row._baseFreight = baseFv
|
||||
row._baseReg = baseReg
|
||||
row._floorFreight = baseFv
|
||||
row._floorReg = baseReg
|
||||
row.fvValue = baseFv
|
||||
row.fvRegistrationFee = baseReg
|
||||
row.effectiveDate = item.effectiveDate
|
||||
@@ -374,22 +433,39 @@ function countryToDetail(item, productCode, productName) {
|
||||
return hydrateRow(row, props.setType)
|
||||
}
|
||||
|
||||
function openPrice(row) {
|
||||
async function openPrice(row) {
|
||||
if (isDiscountType(props.setType)) {
|
||||
discCode.value = row.productCode
|
||||
discTitle.value = row.productCode + ' · ' + (row.productName || '') + ' · 产品折扣'
|
||||
const hit = (props.details || []).find(d => d.productCode === row.productCode)
|
||||
discValue.value = hit && hit.discountValue != null ? Number(hit.discountValue) : undefined
|
||||
try {
|
||||
const rows = await loadProductDetails(row.productCode)
|
||||
const hit = rows[0]
|
||||
discValue.value = hit && hit.discountValue != null ? Number(hit.discountValue) : undefined
|
||||
} catch (e) {
|
||||
discValue.value = undefined
|
||||
}
|
||||
discVisible.value = true
|
||||
return
|
||||
}
|
||||
editCode.value = row.productCode
|
||||
editName.value = row.productName || ''
|
||||
editRows.value = row.rows || []
|
||||
editGroup.value = row
|
||||
Promise.resolve(stampPriceFloors(editRows.value, row.productCode)).finally(() => {
|
||||
try {
|
||||
editRows.value = await loadProductDetails(row.productCode)
|
||||
priceVisible.value = true
|
||||
})
|
||||
} catch (e) {
|
||||
proxy.$modal.msgError((e && e.message) ? e.message : '加载产品价格失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** list-row surcharge: load that product details first */
|
||||
async function openSurchargeForProduct(row) {
|
||||
try {
|
||||
const rows = await loadProductDetails(row.productCode)
|
||||
await openSurcharge({ ...row, rows })
|
||||
} catch (e) {
|
||||
proxy.$modal.msgError((e && e.message) ? e.message : '加载产品明细失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 抽屉里点附加费:带行 = 行级(只改该行,国家+重量段),不带 = 产品级(覆盖全部明细行) */
|
||||
@@ -437,47 +513,6 @@ async function onRowPriceChange(row) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyImportedFloor(row, hit) {
|
||||
if (!row || !hit) return
|
||||
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) {
|
||||
if (!rows || !rows.length || !row) return null
|
||||
if (row.productCountryId != null) {
|
||||
const byId = rows.find(item => Number(item.detailId) === Number(row.productCountryId))
|
||||
if (byId) return byId
|
||||
}
|
||||
return rows.find(item => {
|
||||
const sameCountry = (row.countryCode && item.countryCode
|
||||
&& 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
|
||||
}) || null
|
||||
}
|
||||
|
||||
function stampPriceFloors(rows, productCode) {
|
||||
if (!props.refData || !productCode || !rows || !rows.length) return Promise.resolve()
|
||||
return props.refData.ensureCountries(productCode, props.effectiveTime).then(list => {
|
||||
rows.forEach(row => applyImportedFloor(row, findImportedCountry(list, row)))
|
||||
})
|
||||
}
|
||||
|
||||
async function saveDiscount() {
|
||||
const hit = (props.details || []).find(d => d.productCode === discCode.value)
|
||||
if (hit) {
|
||||
@@ -607,16 +642,25 @@ function onSurchargeConfirm(picked) {
|
||||
}
|
||||
|
||||
async function removeProduct(code) {
|
||||
const ids = props.details.filter(d => d.productCode === code && d.psdId).map(d => d.psdId)
|
||||
if (ids.length) {
|
||||
try {
|
||||
try {
|
||||
const rows = await loadProductDetails(code)
|
||||
const ids = rows.filter(d => d && d.psdId).map(d => d.psdId)
|
||||
if (ids.length) {
|
||||
await delSetDetail(ids.join(','))
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
delete detailByProduct[code]
|
||||
if (Array.isArray(props.details)) {
|
||||
for (let i = props.details.length - 1; i >= 0; i--) {
|
||||
if (props.details[i] && props.details[i].productCode === code) props.details.splice(i, 1)
|
||||
}
|
||||
}
|
||||
for (let i = props.details.length - 1; i >= 0; i--) {
|
||||
if (props.details[i].productCode === code) props.details.splice(i, 1)
|
||||
if (Array.isArray(props.products)) {
|
||||
for (let i = props.products.length - 1; i >= 0; i--) {
|
||||
if (props.products[i] && props.products[i].productCode === code) props.products.splice(i, 1)
|
||||
}
|
||||
}
|
||||
proxy.$modal.msgSuccess('已移除产品明细')
|
||||
}
|
||||
@@ -647,6 +691,58 @@ defineExpose({ pullAllFromLibrary })
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.build-choice {
|
||||
padding: 48px 24px 56px;
|
||||
text-align: center;
|
||||
}
|
||||
.build-choice__tip {
|
||||
margin: 0 0 28px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
.build-choice__cards {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.build-card {
|
||||
width: 280px;
|
||||
min-height: 120px;
|
||||
padding: 24px 20px;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.build-card:hover:not(:disabled) {
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.build-card:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.build-card--pull:hover:not(:disabled) {
|
||||
border-color: #67c23a;
|
||||
}
|
||||
.build-card--import:hover:not(:disabled) {
|
||||
border-color: #409eff;
|
||||
}
|
||||
.build-card__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.build-card--pull .build-card__title { color: #67c23a; }
|
||||
.build-card--import .build-card__title { color: #409eff; }
|
||||
.build-card__desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #909399;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
v-show="active === 1"
|
||||
ref="detailsRef"
|
||||
:details="form.details"
|
||||
:products="form.products"
|
||||
:price-set-id="form.priceSetId"
|
||||
:psv-id="form.version.psvId"
|
||||
:set-type="form.basic.setType"
|
||||
@@ -49,7 +50,7 @@
|
||||
<script setup name="PriceSetCreate">
|
||||
import StepBasicInfo from './components/create/StepBasicInfo.vue'
|
||||
import StepDetails from './components/create/StepDetails.vue'
|
||||
import { usePriceSetRefData, useDetailCascade } from './composables/usePriceSetRefData'
|
||||
import { usePriceSetRefData } from './composables/usePriceSetRefData'
|
||||
import {
|
||||
createEmptyBasicInfo,
|
||||
nextVersionNo,
|
||||
@@ -62,7 +63,6 @@ const { proxy } = getCurrentInstance()
|
||||
const route = useRoute()
|
||||
|
||||
const refData = usePriceSetRefData()
|
||||
const cascade = useDetailCascade(refData)
|
||||
|
||||
const active = ref(0)
|
||||
const saving = ref(false)
|
||||
@@ -85,6 +85,7 @@ const form = reactive({
|
||||
basic: createEmptyBasicInfo(),
|
||||
version: { psvId: undefined, versionNo: 'V1', effectiveTime: undefined, expireTime: undefined },
|
||||
details: [],
|
||||
products: [],
|
||||
customers: []
|
||||
})
|
||||
|
||||
@@ -140,30 +141,24 @@ async function runSaveWithProgress(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Step1 -> Step2: create package prices from library when needed */
|
||||
/** Step1 -> Step2: create empty draft version; pull/import on Step 2 */
|
||||
async function goStep2() {
|
||||
const validBasic = await basicRef.value.validate()
|
||||
if (!validBasic) return
|
||||
|
||||
const hasPersistedDetails = (form.details || []).some(d => d && d.psdId)
|
||||
if (form.priceSetId && form.version.psvId && hasPersistedDetails) {
|
||||
if (form.priceSetId && form.version.psvId) {
|
||||
active.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
form.details.splice(0, form.details.length)
|
||||
const priceSetId = await runSaveWithProgress({
|
||||
...buildPayload(),
|
||||
details: [],
|
||||
pullFromLibrary: 'Y'
|
||||
})
|
||||
const priceSetId = await runSaveWithProgress(buildPayload())
|
||||
form.priceSetId = priceSetId
|
||||
const fresh = await getPriceSet(priceSetId)
|
||||
applySetData(fresh.data || {}, false)
|
||||
active.value = 1
|
||||
proxy.$modal.msgSuccess('已创建套餐价格,可在第 2 步编辑')
|
||||
proxy.$modal.msgSuccess('已创建空套餐版本,请在第 2 步拉取或导入价格')
|
||||
} catch (e) {
|
||||
// surfaced
|
||||
} finally {
|
||||
@@ -269,12 +264,8 @@ function applySetData(data, asCopy) {
|
||||
form.version.effectiveTime = current.effectiveTime
|
||||
form.version.expireTime = current.expireTime
|
||||
}
|
||||
const srcDetails = (current.details && current.details.length) ? current.details : (data.details || [])
|
||||
form.details = srcDetails.map(d => {
|
||||
const row = cascade.hydrateRow(d, form.basic.setType)
|
||||
if (asCopy) row.psdId = undefined
|
||||
return row
|
||||
})
|
||||
form.details = []
|
||||
form.products = asCopy ? [] : ((current.products || []).map(p => ({ ...p })))
|
||||
form.customers = (data.customers || []).map(c => ({ ...c }))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user