修改产品套餐

This commit is contained in:
wuweihua
2026-09-29 23:07:38 +08:00
parent 38f371742a
commit 52d0a13d83
25 changed files with 1229 additions and 351 deletions
+9
View File
@@ -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({
+36 -2
View File
@@ -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) {
+62 -10
View File
@@ -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;
+9 -18
View File
@@ -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 }))
}