修改套餐价格

This commit is contained in:
wuweihua
2026-10-16 10:43:45 +08:00
parent 622d2a4c40
commit 2689138b6a
9 changed files with 572 additions and 277 deletions
@@ -349,7 +349,8 @@ public class BmsProductServiceImpl implements IBmsProductService
}
List<BmsRegion> euRegions = loadEuRegions(regionByNameCn);
Map<String, PrcZone> zoneByCountryAndCode = loadZoneByCountryAndCode();
// Pre-check: same product cannot have duplicate country + weight band in Excel
// Pre-check: any price sheet with duplicate country+weight fails the whole import.
assertAllPriceSheetsNoDupCountryWeight(workbook, regionByNameCn, euRegions, zoneByCountryAndCode);
assertImportProductsNoDupCountryWeight(workbook, products, regionByNameCn, euRegions,
zoneByCountryAndCode);
@@ -632,6 +633,8 @@ public class BmsProductServiceImpl implements IBmsProductService
Map<String, BmsRegion> regionByNameCn = loadRegionByNameCn();
List<BmsRegion> euRegions = loadEuRegions(regionByNameCn);
Map<String, PrcZone> zoneByCountryAndCode = loadZoneByCountryAndCode();
// Fail whole import when any price sheet has duplicate country+weight (incl. unmatched sheets).
assertAllPriceSheetsNoDupCountryWeight(workbook, regionByNameCn, euRegions, zoneByCountryAndCode);
Set<String> unmatchedCountries = new HashSet<>();
Map<String, List<BmsProductCountry>> out = new LinkedHashMap<>();
for (BmsProduct product : products)
@@ -1166,6 +1169,12 @@ public class BmsProductServiceImpl implements IBmsProductService
{
score = 80;
}
else if (StringUtils.isNotEmpty(nameKey) && sheetNameBase(nameKey).equals(sheetNameBase(key))
&& sheetNameBase(nameKey).length() >= 2)
{
// e.g. catalog name suffix differs from sheet (tax/cargo label)
score = 70;
}
else if (StringUtils.isNotEmpty(codeKey) && key.contains(codeKey))
{
score = 60;
@@ -1192,6 +1201,21 @@ public class BmsProductServiceImpl implements IBmsProductService
return out;
}
/** Base name before last '-' (catalog/sheet suffix may differ). */
private static String sheetNameBase(String normalizedKey)
{
if (StringUtils.isEmpty(normalizedKey))
{
return "";
}
int idx = normalizedKey.lastIndexOf('-');
if (idx <= 0)
{
return normalizedKey;
}
return normalizedKey.substring(0, idx);
}
private List<BmsRegion> loadEuRegions(Map<String, BmsRegion> regionByNameCn)
{
List<BmsRegion> list = new ArrayList<>();
@@ -2119,6 +2143,51 @@ public class BmsProductServiceImpl implements IBmsProductService
}
}
/**
* Scan every price sheet (not only catalog-matched ones).
* Duplicate country+weight on any sheet fails the whole import
* (e.g. same country twice for different last-mile carriers).
*/
private void assertAllPriceSheetsNoDupCountryWeight(Workbook workbook,
Map<String, BmsRegion> regionByNameCn, List<BmsRegion> euRegions,
Map<String, PrcZone> zoneByCountryAndCode)
{
if (workbook == null)
{
return;
}
List<String> tips = new ArrayList<String>();
Set<String> unmatchedCountries = new HashSet<String>();
for (int i = 0; i < workbook.getNumberOfSheets(); i++)
{
String sheetName = workbook.getSheetName(i);
if (shouldSkipCountryPriceSheet(sheetName))
{
continue;
}
Sheet sheet = workbook.getSheetAt(i);
if (sheet == null || findHeaderCell(sheet, "\u56fd\u5bb6") == null)
{
continue;
}
BmsProduct stub = new BmsProduct();
stub.setProductCode(sheetName);
stub.setProductName(sheetName);
List<BmsProductCountry> rows = parseProductSheet(sheet, stub,
regionByNameCn, euRegions, zoneByCountryAndCode, unmatchedCountries);
String tip = buildDuplicateCountryWeightTip(stub, rows);
if (StringUtils.isNotEmpty(tip))
{
tips.add(tip);
}
}
if (!tips.isEmpty())
{
throw new ServiceException(MessageUtils.message("price.product.import.dupCountry",
String.join("<br/>", tips)));
}
}
/**
* Workbook pre-check for full import: reject before any write when a product sheet
* has duplicate country + weight band (same zone) rows.
@@ -3018,6 +3018,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
/**
* Draft only: when priceBuildMode changes (PULL/IMPORT), delete this version's
* product / detail / SALES surcharge rel rows. Same mode or non-draft: no-op.
* IMPORT: after wipe, copy products+details from latest prior version (if any).
*/
private void clearDraftVersionChildrenIfBuildModeChanged(PrcPriceSetVersion db, PrcPriceSetVersion incoming)
{
@@ -3043,9 +3044,80 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
deleteSalesRelsByPsvId(psvId);
detailMapper.deletePrcPriceSetDetailByPsvId(psvId);
setProductMapper.deleteByPsvId(psvId);
if ("IMPORT".equalsIgnoreCase(newMode))
{
copyLatestPriorVersionChildren(db.getPriceSetId(), psvId);
}
}
/** Prefer same effective_time, else draft, else first version of the set. */
/**
* Copy products + details from latest prior version into the draft (Excel build-price base).
* Prefer EFFECT, then PENDING, then highest versionSeq.
*/
private void copyLatestPriorVersionChildren(Long priceSetId, Long targetPsvId)
{
Long srcPsvId = findLatestPriorPsvId(priceSetId, targetPsvId);
if (srcPsvId == null || targetPsvId == null)
{
return;
}
setProductMapper.copyFromVersion(srcPsvId, targetPsvId);
int detailRows = detailMapper.copyDetailsFromVersion(srcPsvId, targetPsvId);
if (detailRows > 0)
{
setProductMapper.bindDetailPspByPsvId(targetPsvId);
}
}
private Long findLatestPriorPsvId(Long priceSetId, Long excludePsvId)
{
if (priceSetId == null)
{
return null;
}
PrcPriceSetVersion q = new PrcPriceSetVersion();
q.setPriceSetId(priceSetId);
List<PrcPriceSetVersion> list = versionMapper.selectPrcPriceSetVersionList(q);
if (list == null || list.isEmpty())
{
return null;
}
PrcPriceSetVersion effect = null;
PrcPriceSetVersion pending = null;
PrcPriceSetVersion best = null;
for (PrcPriceSetVersion v : list)
{
if (v == null || v.getPsvId() == null)
{
continue;
}
if (excludePsvId != null && excludePsvId.equals(v.getPsvId()))
{
continue;
}
if (VER_EFFECT.equals(v.getStatus()) && effect == null)
{
effect = v;
}
else if (VER_PENDING.equals(v.getStatus()) && pending == null)
{
pending = v;
}
if (best == null || (v.getVersionSeq() != null && best.getVersionSeq() != null
&& v.getVersionSeq().intValue() > best.getVersionSeq().intValue())
|| (best.getVersionSeq() == null && v.getVersionSeq() != null))
{
best = v;
}
}
PrcPriceSetVersion pick = effect != null ? effect : (pending != null ? pending : best);
return pick == null ? null : pick.getPsvId();
}
/**
* Prefer same effective_time, else an existing draft.
* Never fall back to EFFECT/EXPIRED — that would overwrite a live version when creating a new one.
*/
private PrcPriceSetVersion findReusableVersion(Long priceSetId, Date effectiveTime)
{
PrcPriceSetVersion q = new PrcPriceSetVersion();
@@ -3072,7 +3144,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
return v;
}
}
return list.get(0);
return null;
}
/**
@@ -151,6 +151,20 @@ function reloadSavedProducts() {
})
}
async function reloadProductsAfterImportModeSave() {
const id = props.priceSetId
if (!id) return
const res = await getPriceSet(id)
const data = res.data || {}
const versions = data.versions || []
const qPsv = props.psvId || version.psvId
const byQuery = qPsv != null ? versions.find(v => String(v.psvId) === String(qPsv)) : null
const current = byQuery || data.version || versions[0] || {}
products.value = (current.products || []).map(p => ({ ...p }))
savedProductsSnapshot.value = products.value.map(p => ({ ...p }))
details.value = []
}
async function finish() {
if (!props.priceSetId) {
close()
@@ -178,12 +192,17 @@ async function finish() {
}
await savePriceSetAsync(payload)
savedPriceBuildMode.value = version.priceBuildMode
savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p }))
await nextTick()
if (version.priceBuildMode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) {
await detailsRef.value.pullAllFromLibrary({ silent: true })
savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p }))
proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,已重新拉取产品列表")
} else if (version.priceBuildMode === 'IMPORT') {
// Backend copies latest prior version products/details into this draft
await reloadProductsAfterImportModeSave()
proxy.$modal.msgSuccess("建价方式已保存,已带出上一版产品,可导入 Excel 按产品+国家覆盖或新增")
} else {
savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p }))
proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,可导入 Excel 建价")
}
close()
@@ -203,6 +203,7 @@ import {
isFixedType,
isImportBuildMode,
isPullBuildMode,
pickLatestPriorVersion,
setTypeLabel,
surchargeAppliesToRow,
toDetailPayload
@@ -306,8 +307,8 @@ const importDesc = computed(() => {
}
return [
'适用威狮B2C小包价表(含「产品目录」)。',
'按产品+国家覆盖:Excel 中出现的产品+国家会替换已有同范围明细,其它产品/国家保留。',
'仅固定价套餐「导入 Excel 建价」可用;不影响导入修改价格/拉取产品库。'
'先带出上一版全部产品;导入按「产品+国家」匹配:命中则覆盖该国家明细,未命中则新增;其它产品/国家保留。',
'同一产品下重复国家+重量段将整单失败。仅固定价套餐「导入 Excel 建价」可用。'
]
})
@@ -324,7 +325,7 @@ const showImportAction = computed(() => hasBuildMode.value && isFixedType(props.
const emptyTip = computed(() => {
if (props.livePersist) return '暂无产品'
if (isPullBuildMode(props.priceBuildMode)) return '暂无产品,请先返回上一步重新拉取或等待拉取完成'
if (isImportBuildMode(props.priceBuildMode)) return '暂无产品,请点击右上角导入 Excel 建价'
if (isImportBuildMode(props.priceBuildMode)) return '暂无上一版产品,请点击右上角导入 Excel 建价(按产品+国家覆盖或新增)'
return '暂无产品,请先选择创建价格方式'
})
@@ -373,6 +374,41 @@ function clearLocalDraft() {
editGroup.value = null
}
/**
* Excel build-price: preload products + details from latest prior version
* so import can upsert by product+country.
*/
async function loadLatestPriorVersionDraft() {
if (!props.priceSetId) return false
pulling.value = true
try {
const res = await getPriceSet(props.priceSetId)
const data = res.data || {}
const versions = data.versions || []
const prior = pickLatestPriorVersion(versions, props.psvId || resolvedPsvId.value)
if (!prior || prior.psvId == null) return false
const products = (prior.products || [])
.filter(p => p && p.productCode)
.map(p => {
const row = { ...p }
delete row.pspId
delete row.psvId
delete row.priceSetId
return row
})
if (!products.length) return false
const chunks = await Promise.all(products.map(p =>
listSetDetailByProduct(prior.psvId, p.productCode)
.then(r => r.data || [])
.catch(() => [])
))
applyLocalDraft(products, chunks.flat())
return true
} finally {
pulling.value = false
}
}
async function loadProductDetails(productCode, force) {
if (!productCode) return []
if (!force && detailByProduct[productCode]) return detailByProduct[productCode]
@@ -987,7 +1023,7 @@ async function removeProduct(code) {
proxy.$modal.msgSuccess('已移除产品明细')
}
defineExpose({ pullAllFromLibrary, clearLocalDraft })
defineExpose({ pullAllFromLibrary, clearLocalDraft, loadLatestPriorVersionDraft })
</script>
<style scoped>
@@ -7,67 +7,64 @@
@close="close"
>
<div class="drawer-body" v-loading="loading">
<div v-show="!detailOpen" class="product-pane">
<div class="toolbar">
<el-select
v-model="query.productKeyword"
filterable
clearable
:placeholder="phProduct"
:loading="optionsLoading"
style="width: 200px"
>
<el-option v-for="p in optionProducts" :key="p.code" :label="p.label" :value="p.code">
<span class="opt-name">{{ p.name }}</span>
<span class="opt-code">{{ p.code }}</span>
</el-option>
</el-select>
<el-select
v-if="isFixed"
v-model="query.countryKeyword"
filterable
clearable
:placeholder="phCountry"
:loading="optionsLoading"
style="width: 160px"
>
<el-option v-for="c in optionCountries" :key="c.key" :label="c.label" :value="c.value">
<span class="opt-name">{{ c.name }}</span>
<span class="opt-code">{{ c.code }}</span>
</el-option>
</el-select>
<el-select
v-if="isFixed"
v-model="query.zoneKeyword"
filterable
clearable
:placeholder="phZone"
:loading="optionsLoading"
style="width: 160px"
>
<el-option v-for="z in optionZones" :key="z.key" :label="z.label" :value="z.value">
<span class="opt-name">{{ z.name }}</span>
<span class="opt-code">{{ z.code }}</span>
</el-option>
</el-select>
<el-button type="primary" @click="search">查询</el-button>
<el-button @click="reset">重置</el-button>
<el-input v-model="productKeyword" clearable :placeholder="phSearch" style="width: 220px" />
<el-button type="primary" @click="noop">查询</el-button>
<el-button @click="productKeyword = ''">重置</el-button>
</div>
<el-table :data="rows" border height="calc(100vh - 220px)" empty-text="暂无明细">
<el-table-column :label="colProduct" min-width="180">
<el-alert type="info" :closable="false" show-icon class="mb12" :title="hint" />
<el-empty v-if="!filteredProducts.length" :description="emptyProduct" :image-size="72" />
<el-table v-else :data="filteredProducts" border height="calc(100vh - 280px)" @row-click="openProductDetail">
<el-table-column :label="colCode" min-width="100">
<template #default="{ row }"><b>{{ row.productCode }}</b></template>
</el-table-column>
<el-table-column :label="colName" prop="productName" min-width="140" show-overflow-tooltip />
<el-table-column :label="colLevel" prop="serviceLevel" width="90" align="center" />
<el-table-column :label="colCargo" min-width="140" align="center" show-overflow-tooltip>
<template #default="{ row }">
<div class="cell-product">
<span class="p-code">{{ row.productCode }}</span>
<span class="p-name">{{ row.productName || '' }}</span>
</div>
<el-tag v-if="row.cargoAttr" type="primary" size="small" effect="plain">{{ row.cargoAttr }}</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column :label="colRegion" prop="destRegion" width="100" align="center" />
<el-table-column :label="colAction" width="110" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click.stop="openProductDetail(row)">查看明细</el-button>
</template>
</el-table-column>
</el-table>
<div class="product-count">{{ productCountText }}</div>
</div>
<div v-show="detailOpen" class="detail-pane">
<div class="toolbar">
<el-button link type="primary" @click="backToProducts">
<el-icon><arrow-left /></el-icon>
返回产品
</el-button>
<span class="detail-title">{{ detailTitleText }}</span>
<el-input
v-if="isFixed"
v-model="detailKeyword"
clearable
:placeholder="phDetailSearch"
style="width: 200px; margin-left: auto"
/>
</div>
<el-table
v-loading="detailLoading"
:data="displayDetailRows"
border
height="calc(100vh - 260px)"
:empty-text="emptyDetail"
:span-method="spanMethod"
>
<template v-if="isFixed">
<el-table-column :label="colCountry" min-width="120">
<el-table-column :label="colCountry" min-width="100">
<template #default="{ row }">{{ row.countryName || row.countryCode || '-' }}</template>
</el-table-column>
<el-table-column :label="colZone" min-width="110">
<el-table-column :label="colZone" min-width="90">
<template #default="{ row }">{{ row.zoneName || row.zoneCode || '-' }}</template>
</el-table-column>
<el-table-column :label="colWeight" prop="weightLabel" min-width="120" />
@@ -103,46 +100,36 @@
</template>
</el-table-column>
</template>
<el-table-column v-else :label="colDisc" min-width="120" align="center">
<template #default="{ row }">
<el-tag v-if="row.discountValue == null" size="small" type="info" effect="plain">默认折扣</el-tag>
<span v-else class="pct">{{ row.discountValue }}%</span>
</template>
</el-table-column>
<el-table-column :label="colSc" min-width="200">
<template #default="{ row }">
<surcharge-tags :list="row.surcharges || []" :empty-text="noneSc" />
</template>
</el-table-column>
<el-table-column :label="colRound" min-width="100" align="center">
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
</el-table-column>
</el-table>
<div class="pager">
<pagination
v-show="total > 0"
:total="total"
v-model:page="query.pageNum"
v-model:limit="query.pageSize"
@pagination="load"
/>
</div>
</div>
<template #footer>
<el-button v-if="detailOpen" @click="backToProducts">返回产品</el-button>
<el-button @click="close">关闭</el-button>
</template>
</el-drawer>
</template>
<script setup name="SetVersionDetailDrawer">
import { ArrowLeft } from '@element-plus/icons-vue'
import SurchargeTags from '../SurchargeTags.vue'
import { isFixedType } from '../../utils/priceSet'
import { listSetDetail, listSetDetailOptions } from '@/api/price/set'
import { listSetDetailByProduct, listSetDetailOptions } from '@/api/price/set'
const props = defineProps({
modelValue: { type: Boolean, default: false },
@@ -154,61 +141,25 @@ const emit = defineEmits(['update:modelValue'])
const { bms_round_mode } = useDict('bms_round_mode')
const loading = ref(false)
const rows = ref([])
const total = ref(0)
const query = reactive({
pageNum: 1,
pageSize: 20,
productKeyword: '',
countryKeyword: '',
zoneKeyword: ''
})
const productKeyword = ref('')
const products = ref([])
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailKeyword = ref('')
const detailRows = ref([])
const activeProduct = ref(null)
// 下拉选项:当前版本内去重后的 产品/国家/分区
const optionsLoading = ref(false)
const optionProducts = ref([])
const optionCountries = ref([])
const optionZones = ref([])
function loadOptions() {
const psvId = props.version && props.version.psvId
if (!psvId) {
optionProducts.value = []
optionCountries.value = []
optionZones.value = []
return
}
optionsLoading.value = true
listSetDetailOptions(psvId).then(res => {
const data = res.data || {}
optionProducts.value = (data.products || []).map(p => ({
code: p.code,
name: p.name || p.code,
label: (p.name && p.name !== p.code) ? `${p.code} ${p.name}` : p.code
}))
optionCountries.value = (data.countries || []).map(c => ({
key: c.code || c.name,
code: c.code || '-',
name: c.name || c.code,
value: c.code || c.name, // 有代码按代码搜,否则按名称搜(后端 code/name 双 LIKE)
label: (c.name && c.code) ? `${c.name} ${c.code}` : (c.name || c.code)
}))
optionZones.value = (data.zones || []).map(z => ({
key: z.code || z.name,
code: z.code || '-',
name: z.name || z.code,
value: z.code || z.name,
label: (z.name && z.code) ? `${z.name} ${z.code}` : (z.name || z.code)
}))
}).finally(() => {
optionsLoading.value = false
})
}
const phProduct = '产品'
const phCountry = '国家'
const phZone = '分区'
const colProduct = '产品'
const phSearch = '产品代码/名称'
const phDetailSearch = '搜索国家/分区'
const hint = '点击产品行查看国家/重量段明细(仅查看)'
const emptyProduct = '暂无产品'
const emptyDetail = '暂无明细'
const colCode = '产品代码'
const colName = '产品名称'
const colLevel = '时效等级'
const colCargo = '走货属性'
const colRegion = '目的区域'
const colAction = '操作'
const colCountry = '国家'
const colZone = '分区'
const colWeight = '重量段'
@@ -224,6 +175,92 @@ const title = computed(() => {
const v = props.version || {}
return (v.versionNo || '-') + ' · 版本明细'
})
const detailTitleText = computed(() => {
const p = activeProduct.value || {}
return (p.productCode || '') + ' · ' + (p.productName || '') + ' · 国家服务价格'
})
const filteredProducts = computed(() => {
const kw = (productKeyword.value || '').trim().toLowerCase()
return (products.value || []).filter(p => {
if (!p || !p.productCode) return false
if (!kw) return true
const blob = [p.productCode, p.productName].filter(Boolean).join(' ').toLowerCase()
return blob.includes(kw)
})
})
/** Footer: "共 N 个产品" (show filtered vs total when searching). */
const productCountText = computed(() => {
const n = filteredProducts.value.length
const total = (products.value || []).length
if (n === total) {
return '\u5171 ' + n + ' \u4e2a\u4ea7\u54c1'
}
return '\u5171 ' + n + ' \u4e2a\u4ea7\u54c1\uff08\u5168\u90e8 ' + total + ' \u4e2a\uff09'
})
function countryKey(row) {
return String((row && (row.countryCode || row.countryName)) || '').toUpperCase()
}
function zoneKey(row) {
return String((row && (row.zoneCode || row.zoneName)) || '')
}
const displayDetailRows = computed(() => {
const kw = (detailKeyword.value || '').trim().toLowerCase()
let list = [...(detailRows.value || [])]
if (kw) {
list = list.filter(r => {
if (!r) return false
const blob = [r.countryName, r.countryCode, r.zoneName, r.zoneCode, r.weightLabel]
.filter(Boolean).join(' ').toLowerCase()
return blob.includes(kw)
})
}
list.sort((a, b) => {
const ca = countryKey(a)
const cb = countryKey(b)
if (ca !== cb) return ca.localeCompare(cb)
const za = zoneKey(a)
const zb = zoneKey(b)
if (za !== zb) return za.localeCompare(zb)
return String((a && a.weightLabel) || '').localeCompare(String((b && b.weightLabel) || ''))
})
return list
})
function buildSpans(rows, keyOf) {
const spans = new Array(rows.length).fill(1)
let i = 0
while (i < rows.length) {
let j = i + 1
const key = keyOf(rows[i])
while (j < rows.length && keyOf(rows[j]) === key) j++
spans[i] = j - i
for (let k = i + 1; k < j; k++) spans[k] = 0
i = j
}
return spans
}
const countrySpans = computed(() => buildSpans(displayDetailRows.value, countryKey))
const zoneSpans = computed(() => buildSpans(displayDetailRows.value, r => countryKey(r) + '|' + zoneKey(r)))
function spanOf(spans, rowIndex) {
const rowspan = spans[rowIndex] || 0
return rowspan > 0 ? { rowspan, colspan: 1 } : { rowspan: 0, colspan: 0 }
}
function spanMethod({ columnIndex, rowIndex }) {
if (!isFixed.value) return { rowspan: 1, colspan: 1 }
if (columnIndex === 0) return spanOf(countrySpans.value, rowIndex)
if (columnIndex === 1) return spanOf(zoneSpans.value, rowIndex)
return { rowspan: 1, colspan: 1 }
}
function noop() {}
function hasBaseFreight(row) {
return row && row.baseFreightPrice != null && row.baseFreightPrice !== ''
@@ -237,7 +274,6 @@ function formatBase(v, precision) {
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 || '四舍五入')
@@ -247,52 +283,73 @@ function close() {
emit('update:modelValue', false)
}
function search() {
query.pageNum = 1
load()
function backToProducts() {
detailOpen.value = false
activeProduct.value = null
detailRows.value = []
detailKeyword.value = ''
}
function reset() {
query.productKeyword = ''
query.countryKeyword = ''
query.zoneKeyword = ''
query.pageNum = 1
load()
function normalizeProducts(list) {
return (list || [])
.filter(p => p && (p.productCode || p.code))
.map(p => ({
productCode: p.productCode || p.code,
productName: p.productName || p.name || p.productCode || p.code,
serviceLevel: p.serviceLevel,
cargoAttr: p.cargoAttr,
destRegion: p.destRegion,
taxMode: p.taxMode
}))
}
function load() {
const psvId = props.version && props.version.psvId
async function loadProducts() {
const v = props.version || {}
const psvId = v.psvId
if (!psvId) {
rows.value = []
total.value = 0
products.value = []
return
}
loading.value = true
listSetDetail({
psvId,
productKeyword: query.productKeyword || undefined,
countryKeyword: isFixed.value ? (query.countryKeyword || undefined) : undefined,
zoneKeyword: isFixed.value ? (query.zoneKeyword || undefined) : undefined,
pageNum: query.pageNum,
pageSize: query.pageSize
}).then(res => {
rows.value = res.rows || []
total.value = res.total || 0
}).finally(() => {
try {
const fromVersion = normalizeProducts(v.products)
if (fromVersion.length) {
products.value = fromVersion
return
}
const res = await listSetDetailOptions(psvId)
const data = res.data || {}
products.value = normalizeProducts(data.products || [])
} finally {
loading.value = false
})
}
}
async function openProductDetail(row) {
if (!row || !row.productCode) return
const psvId = props.version && props.version.psvId
if (!psvId) return
activeProduct.value = row
detailOpen.value = true
detailKeyword.value = ''
detailLoading.value = true
try {
const res = await listSetDetailByProduct(psvId, row.productCode)
detailRows.value = res.data || []
} catch (e) {
detailRows.value = []
} finally {
detailLoading.value = false
}
}
watch(
() => [props.modelValue, props.version && props.version.psvId],
([open]) => {
if (open) {
query.pageNum = 1
query.productKeyword = ''
query.countryKeyword = ''
query.zoneKeyword = ''
loadOptions()
load()
productKeyword.value = ''
backToProducts()
loadProducts()
}
}
)
@@ -312,29 +369,16 @@ watch(
flex-wrap: wrap;
align-items: center;
}
.pager {
margin-top: 12px;
}
.cell-product {
display: flex;
align-items: baseline;
gap: 6px;
}
.p-code {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
.mb12 { margin-bottom: 12px; }
.product-count {
margin-top: 10px;
text-align: right;
color: #606266;
background: #f4f4f5;
border-radius: 3px;
padding: 0 6px;
font-size: 13px;
}
.p-name { color: #303133; }
.opt-name { margin-right: 8px; }
.opt-code {
float: right;
color: var(--el-text-color-secondary);
font-size: 12px;
font-family: Consolas, Monaco, monospace;
.detail-title {
font-weight: 600;
color: #303133;
}
.hl { color: #1890ff; font-weight: 600; }
.reg { color: #d48806; font-weight: 600; }
@@ -347,4 +391,7 @@ watch(
.price-tip:hover {
border-bottom-color: #c0c4cc;
}
:deep(.el-table__row) {
cursor: pointer;
}
</style>
@@ -69,13 +69,6 @@
@click="emit('remove', row)"
v-hasPermi="['price:set:edit']"
>删除</el-button>
<el-button
v-if="String(row.status) === '1' && !hasDraft"
link
type="primary"
@click="emit('copy-version', row)"
v-hasPermi="['price:set:add']"
>复制为新版本</el-button>
</template>
</el-table-column>
</el-table>
@@ -91,7 +84,7 @@ const props = defineProps({
versions: { type: Array, default: () => [] },
modelValue: { type: [Number, String], default: undefined }
})
const emit = defineEmits(['update:modelValue', 'copy-version', 'create-version', 'open-details', 'view-import-attach', 'activate', 'reschedule', 'edit', 'remove'])
const emit = defineEmits(['update:modelValue', 'create-version', 'open-details', 'view-import-attach', 'activate', 'reschedule', 'edit', 'remove'])
const title = '套餐版本'
const colNo = '版本号'
+73 -9
View File
@@ -72,7 +72,7 @@ import {
toCustomerPayload,
toDetailPayload
} from './utils/priceSet'
import { getPriceSet, savePriceSetAsync } from '@/api/price/set'
import { getPriceSet, savePriceSetAsync, createPriceSetVersion, deletePriceSetVersion } from '@/api/price/set'
const { proxy } = getCurrentInstance()
const route = useRoute()
@@ -202,12 +202,19 @@ async function finishEdit() {
delete payload.details
await runSaveWithProgress(payload)
savedPriceBuildMode.value = form.version.priceBuildMode
savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p }))
await nextTick()
if (form.version.priceBuildMode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) {
await detailsRef.value.pullAllFromLibrary({ silent: true })
}
savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p }))
proxy.$modal.msgSuccess("建价方式已保存")
} else if (form.version.priceBuildMode === 'IMPORT') {
// Backend copied prior version into this draft; refresh product list from current psv
await refreshProductsFromCurrentVersion()
proxy.$modal.msgSuccess("建价方式已保存,已带出上一版产品")
} else {
savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p }))
proxy.$modal.msgSuccess("建价方式已保存")
}
} catch (e) {
return
} finally {
@@ -243,6 +250,12 @@ async function finishEdit() {
}
creating.value = true
try {
if (newVersionMode.value && form.version.psvId == null && form.priceSetId) {
const verRes = await createPriceSetVersion(form.priceSetId)
if (verRes.data != null) {
form.version.psvId = verRes.data
}
}
const payload = buildPayload({ forceDetails: true })
const priceSetId = await runSaveWithProgress(payload)
form.priceSetId = priceSetId
@@ -296,7 +309,7 @@ function buildPayload(opts = {}) {
defaultDiscount: undefined,
priceSetNote: basic.priceSetNote,
version: {
psvId: copyMode.value ? undefined : version.psvId,
psvId: (copyMode.value || (newVersionMode.value && version.psvId == null)) ? undefined : version.psvId,
versionNo: version.versionNo,
priceBuildMode: version.priceBuildMode || undefined
}
@@ -323,7 +336,30 @@ function buildPayload(opts = {}) {
return payload
}
function goBack() {
/**
* New-version flow creates an empty draft as soon as user clicks "新建价格版本".
* If user cancels without finishing (no build mode / no details on server), remove that orphan draft.
*/
async function tryDeleteEmptyNewVersionDraft() {
if (!newVersionMode.value || !form.priceSetId || form.version.psvId == null) return
const psvId = form.version.psvId
try {
const res = await getPriceSet(form.priceSetId)
const versions = (res.data || {}).versions || []
const v = versions.find(x => x && String(x.psvId) === String(psvId))
if (!v || String(v.status) !== '0') return
const noMode = !v.priceBuildMode
const noDetails = !(Number(v.detailCount) > 0) && !(v.details || []).length
if (noMode && noDetails) {
await deletePriceSetVersion(psvId)
}
} catch (e) {
// ignore cleanup errors; still navigate back
}
}
async function goBack() {
await tryDeleteEmptyNewVersionDraft()
const obj = (newVersionMode.value || copyMode.value) && form.priceSetId
? { path: '/customer/setDetail', query: { priceSetId: form.priceSetId } }
: { path: '/customer/set' }
@@ -383,7 +419,20 @@ function onPriceBuildModeUpdate(mode) {
}).catch(() => {})
})
} else if (mode === 'IMPORT') {
nextTick(() => {
const loader = detailsRef.value && detailsRef.value.loadLatestPriorVersionDraft
if (!loader) {
proxy.$modal.msgSuccess("请导入 Excel 建价")
return
}
loader().then(ok => {
proxy.$modal.msgSuccess(ok
? "已带出上一版全部产品,导入 Excel 按产品+国家覆盖或新增"
: "暂无上一版产品,请导入 Excel 建价")
}).catch(() => {
proxy.$modal.msgSuccess("请导入 Excel 建价")
})
})
}
}
@@ -401,6 +450,20 @@ function reloadSavedProductsForCreate() {
})
}
async function refreshProductsFromCurrentVersion() {
if (!form.priceSetId) return
const res = await getPriceSet(form.priceSetId)
const data = res.data || {}
const versions = data.versions || []
const qPsv = form.version.psvId
const byQuery = qPsv != null ? versions.find(v => String(v.psvId) === String(qPsv)) : null
const current = byQuery || data.version || versions[0] || {}
const list = (current.products || []).map(p => ({ ...p }))
form.products.splice(0, form.products.length, ...list)
form.details.splice(0, form.details.length)
savedProductsSnapshot.value = list.map(p => ({ ...p }))
}
function applySetData(data, asCopy, asNewVersion) {
form.priceSetId = data.priceSetId
Object.assign(form.basic, {
@@ -426,12 +489,13 @@ function applySetData(data, asCopy, asNewVersion) {
form.details = []
form.products = []
} else if (asNewVersion) {
// Pre-created empty draft from detail page; same step2 UX as create package
form.version.psvId = current.psvId
form.version.versionNo = current.versionNo || nextVersionNo(versions)
// Draft is created on finish only. Resume only when query has psvId (legacy empty draft).
const resume = qPsv != null ? byQuery : null
form.version.psvId = resume ? resume.psvId : undefined
form.version.versionNo = (resume && resume.versionNo) || nextVersionNo(versions)
form.version.effectiveTime = undefined
form.version.expireTime = undefined
form.version.status = current.status || '0'
form.version.status = (resume && resume.status) || '0'
form.version.priceBuildMode = undefined
draftPefId.value = undefined
savedPriceBuildMode.value = undefined
+3 -26
View File
@@ -23,7 +23,6 @@
<set-version-table
v-model="currentPsvId"
:versions="set.versions || []"
@copy-version="onCopyVersion"
@create-version="onCreateVersion"
@open-details="onOpenVersionDetails"
@view-import-attach="onViewImportAttach"
@@ -87,7 +86,7 @@ import SetVersionExcelFileDrawer from './components/detail/SetVersionExcelFileDr
import PriceEditPanel from './components/create/PriceEditPanel.vue'
import { usePriceSetRefData } from './composables/usePriceSetRefData'
import {
getPriceSet, disablePriceSet, enablePriceSet, activatePriceSetVersion, copyPriceSetVersion, createPriceSetVersion, deletePriceSetVersion
getPriceSet, disablePriceSet, enablePriceSet, activatePriceSetVersion, deletePriceSetVersion
} from '@/api/price/set'
import { isDraftVersion, isPendingVersion } from './utils/priceSet'
@@ -379,33 +378,11 @@ function onCreateVersion() {
proxy.$modal.msgWarning('已有草稿版本,请先启动或删除后再新建')
return
}
createPriceSetVersion(priceSetId).then(res => {
const newPsvId = res.data
proxy.$modal.msgSuccess('已创建草稿版本,请继续编辑产品价格')
// Do not insert draft until user clicks finish on the build-price page.
router.push({
path: '/customer/setCreate',
query: { priceSetId, psvId: newPsvId, step: '2', newVersion: '1' }
query: { priceSetId, step: '2', newVersion: '1' }
})
}).catch(() => {})
}
function onCopyVersion(row) {
if (!row || row.psvId == null) return
const hasDraft = (set.value.versions || []).some(v => v && String(v.status) === '0')
if (hasDraft) {
proxy.$modal.msgWarning('已有草稿版本,请先启动或删除后再复制')
return
}
proxy.$modal.confirm(
'确认复制版本 ' + (row.versionNo || '') + '为新草稿?将新增版本并拷贝全部明细。'
).then(() => copyPriceSetVersion(row.psvId)).then(res => {
proxy.$modal.msgSuccess('已复制为新版本')
const newPsvId = res.data
load()
if (newPsvId != null) {
editPsvId.value = newPsvId
editVisible.value = true
}
}).catch(() => {})
}
function onRemoveVersion(row) {
if (!row || row.psvId == null) return
@@ -108,6 +108,24 @@ export function nextVersionNo(versions) {
return 'V' + (max + 1)
}
/**
* Latest prior version for Excel build-price preload (exclude current draft).
* Prefer EFFECT, then PENDING, then highest versionSeq.
*/
export function pickLatestPriorVersion(versions, excludePsvId) {
const list = (versions || []).filter(v => {
if (!v || v.psvId == null) return false
if (excludePsvId != null && String(v.psvId) === String(excludePsvId)) return false
return true
})
if (!list.length) return null
const effect = list.find(v => String(v.status) === VER_STATUS.EFFECT)
if (effect) return effect
const pending = list.find(v => String(v.status) === VER_STATUS.PENDING)
if (pending) return pending
return list.slice().sort((a, b) => (Number(b.versionSeq) || 0) - (Number(a.versionSeq) || 0))[0]
}
/** Package status label */
export function setStatusLabel(set) {
if (set && set.expired) return '已过期'