修改套餐价格

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); List<BmsRegion> euRegions = loadEuRegions(regionByNameCn);
Map<String, PrcZone> zoneByCountryAndCode = loadZoneByCountryAndCode(); 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, assertImportProductsNoDupCountryWeight(workbook, products, regionByNameCn, euRegions,
zoneByCountryAndCode); zoneByCountryAndCode);
@@ -632,6 +633,8 @@ public class BmsProductServiceImpl implements IBmsProductService
Map<String, BmsRegion> regionByNameCn = loadRegionByNameCn(); Map<String, BmsRegion> regionByNameCn = loadRegionByNameCn();
List<BmsRegion> euRegions = loadEuRegions(regionByNameCn); List<BmsRegion> euRegions = loadEuRegions(regionByNameCn);
Map<String, PrcZone> zoneByCountryAndCode = loadZoneByCountryAndCode(); 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<>(); Set<String> unmatchedCountries = new HashSet<>();
Map<String, List<BmsProductCountry>> out = new LinkedHashMap<>(); Map<String, List<BmsProductCountry>> out = new LinkedHashMap<>();
for (BmsProduct product : products) for (BmsProduct product : products)
@@ -1166,6 +1169,12 @@ public class BmsProductServiceImpl implements IBmsProductService
{ {
score = 80; 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)) else if (StringUtils.isNotEmpty(codeKey) && key.contains(codeKey))
{ {
score = 60; score = 60;
@@ -1192,6 +1201,21 @@ public class BmsProductServiceImpl implements IBmsProductService
return out; 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) private List<BmsRegion> loadEuRegions(Map<String, BmsRegion> regionByNameCn)
{ {
List<BmsRegion> list = new ArrayList<>(); 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 * Workbook pre-check for full import: reject before any write when a product sheet
* has duplicate country + weight band (same zone) rows. * 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 * Draft only: when priceBuildMode changes (PULL/IMPORT), delete this version's
* product / detail / SALES surcharge rel rows. Same mode or non-draft: no-op. * 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) private void clearDraftVersionChildrenIfBuildModeChanged(PrcPriceSetVersion db, PrcPriceSetVersion incoming)
{ {
@@ -3043,9 +3044,80 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
deleteSalesRelsByPsvId(psvId); deleteSalesRelsByPsvId(psvId);
detailMapper.deletePrcPriceSetDetailByPsvId(psvId); detailMapper.deletePrcPriceSetDetailByPsvId(psvId);
setProductMapper.deleteByPsvId(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) private PrcPriceSetVersion findReusableVersion(Long priceSetId, Date effectiveTime)
{ {
PrcPriceSetVersion q = new PrcPriceSetVersion(); PrcPriceSetVersion q = new PrcPriceSetVersion();
@@ -3072,7 +3144,7 @@ public class PrcPriceSetServiceImpl implements IPrcPriceSetService
return v; 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() { async function finish() {
if (!props.priceSetId) { if (!props.priceSetId) {
close() close()
@@ -178,12 +192,17 @@ async function finish() {
} }
await savePriceSetAsync(payload) await savePriceSetAsync(payload)
savedPriceBuildMode.value = version.priceBuildMode savedPriceBuildMode.value = version.priceBuildMode
savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p }))
await nextTick() await nextTick()
if (version.priceBuildMode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) { if (version.priceBuildMode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) {
await detailsRef.value.pullAllFromLibrary({ silent: true }) await detailsRef.value.pullAllFromLibrary({ silent: true })
savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p }))
proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,已重新拉取产品列表") 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 { } else {
savedProductsSnapshot.value = (products.value || []).map(p => ({ ...p }))
proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,可导入 Excel 建价") proxy.$modal.msgSuccess("建价方式已保存并清空旧明细,可导入 Excel 建价")
} }
close() close()
@@ -203,6 +203,7 @@ import {
isFixedType, isFixedType,
isImportBuildMode, isImportBuildMode,
isPullBuildMode, isPullBuildMode,
pickLatestPriorVersion,
setTypeLabel, setTypeLabel,
surchargeAppliesToRow, surchargeAppliesToRow,
toDetailPayload toDetailPayload
@@ -306,8 +307,8 @@ const importDesc = computed(() => {
} }
return [ return [
'适用威狮B2C小包价表(含「产品目录」)。', '适用威狮B2C小包价表(含「产品目录」)。',
'按产品+国家覆盖:Excel 中出现的产品+国家会替换已有同范围明细,其它产品/国家保留。', '先带出上一版全部产品;导入按「产品+国家」匹配:命中则覆盖该国家明细,未命中则新增;其它产品/国家保留。',
'仅固定价套餐「导入 Excel 建价」可用;不影响导入修改价格/拉取产品库。' '同一产品下重复国家+重量段将整单失败。仅固定价套餐「导入 Excel 建价」可用。'
] ]
}) })
@@ -324,7 +325,7 @@ const showImportAction = computed(() => hasBuildMode.value && isFixedType(props.
const emptyTip = computed(() => { const emptyTip = computed(() => {
if (props.livePersist) return '暂无产品' if (props.livePersist) return '暂无产品'
if (isPullBuildMode(props.priceBuildMode)) return '暂无产品,请先返回上一步重新拉取或等待拉取完成' if (isPullBuildMode(props.priceBuildMode)) return '暂无产品,请先返回上一步重新拉取或等待拉取完成'
if (isImportBuildMode(props.priceBuildMode)) return '暂无产品,请点击右上角导入 Excel 建价' if (isImportBuildMode(props.priceBuildMode)) return '暂无上一版产品,请点击右上角导入 Excel 建价(按产品+国家覆盖或新增)'
return '暂无产品,请先选择创建价格方式' return '暂无产品,请先选择创建价格方式'
}) })
@@ -373,6 +374,41 @@ function clearLocalDraft() {
editGroup.value = null 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) { async function loadProductDetails(productCode, force) {
if (!productCode) return [] if (!productCode) return []
if (!force && detailByProduct[productCode]) return detailByProduct[productCode] if (!force && detailByProduct[productCode]) return detailByProduct[productCode]
@@ -987,7 +1023,7 @@ async function removeProduct(code) {
proxy.$modal.msgSuccess('已移除产品明细') proxy.$modal.msgSuccess('已移除产品明细')
} }
defineExpose({ pullAllFromLibrary, clearLocalDraft }) defineExpose({ pullAllFromLibrary, clearLocalDraft, loadLatestPriorVersionDraft })
</script> </script>
<style scoped> <style scoped>
@@ -7,142 +7,129 @@
@close="close" @close="close"
> >
<div class="drawer-body" v-loading="loading"> <div class="drawer-body" v-loading="loading">
<div class="toolbar"> <div v-show="!detailOpen" class="product-pane">
<el-select <div class="toolbar">
v-model="query.productKeyword" <el-input v-model="productKeyword" clearable :placeholder="phSearch" style="width: 220px" />
filterable <el-button type="primary" @click="noop">查询</el-button>
clearable <el-button @click="productKeyword = ''">重置</el-button>
:placeholder="phProduct" </div>
:loading="optionsLoading" <el-alert type="info" :closable="false" show-icon class="mb12" :title="hint" />
style="width: 200px" <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-option v-for="p in optionProducts" :key="p.code" :label="p.label" :value="p.code"> <el-table-column :label="colCode" min-width="100">
<span class="opt-name">{{ p.name }}</span> <template #default="{ row }"><b>{{ row.productCode }}</b></template>
<span class="opt-code">{{ p.code }}</span> </el-table-column>
</el-option> <el-table-column :label="colName" prop="productName" min-width="140" show-overflow-tooltip />
</el-select> <el-table-column :label="colLevel" prop="serviceLevel" width="90" align="center" />
<el-select <el-table-column :label="colCargo" min-width="140" align="center" show-overflow-tooltip>
v-if="isFixed" <template #default="{ row }">
v-model="query.countryKeyword" <el-tag v-if="row.cargoAttr" type="primary" size="small" effect="plain">{{ row.cargoAttr }}</el-tag>
filterable <span v-else>-</span>
clearable </template>
:placeholder="phCountry" </el-table-column>
:loading="optionsLoading" <el-table-column :label="colRegion" prop="destRegion" width="100" align="center" />
style="width: 160px" <el-table-column :label="colAction" width="110" align="center" fixed="right">
> <template #default="{ row }">
<el-option v-for="c in optionCountries" :key="c.key" :label="c.label" :value="c.value"> <el-button link type="primary" @click.stop="openProductDetail(row)">查看明细</el-button>
<span class="opt-name">{{ c.name }}</span> </template>
<span class="opt-code">{{ c.code }}</span> </el-table-column>
</el-option> </el-table>
</el-select> <div class="product-count">{{ productCountText }}</div>
<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>
</div> </div>
<el-table :data="rows" border height="calc(100vh - 220px)" empty-text="暂无明细"> <div v-show="detailOpen" class="detail-pane">
<el-table-column :label="colProduct" min-width="180"> <div class="toolbar">
<template #default="{ row }"> <el-button link type="primary" @click="backToProducts">
<div class="cell-product"> <el-icon><arrow-left /></el-icon>
<span class="p-code">{{ row.productCode }}</span> 返回产品
<span class="p-name">{{ row.productName || '' }}</span> </el-button>
</div> <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="100">
<template #default="{ row }">{{ row.countryName || row.countryCode || '-' }}</template>
</el-table-column>
<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" />
<el-table-column :label="colFv" min-width="130" align="right">
<template #default="{ row }">
<el-tooltip placement="top" :disabled="!hasBaseFreight(row)">
<template #content>
<span>产品基础运费:{{ formatBase(row.baseFreightPrice, 4) }} RMB/KG</span>
</template>
<span class="price-tip">
<template v-if="row.fvValue == null">
<span class="unit">未覆盖</span>
</template>
<template v-else>
<span class="hl">{{ row.fvValue }}</span>
<span class="unit"> RMB/KG</span>
</template>
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="colReg" min-width="120" align="right">
<template #default="{ row }">
<el-tooltip placement="top" :disabled="!hasBaseReg(row)">
<template #content>
<span>产品基础挂号费:{{ formatBase(row.baseRegistrationFee, 2) }} RMB/票</span>
</template>
<span class="price-tip">
<span class="reg">{{ row.fvRegistrationFee }}</span>
<span class="unit"> RMB/票</span>
</span>
</el-tooltip>
</template>
</el-table-column>
</template> </template>
</el-table-column> <el-table-column v-else :label="colDisc" min-width="120" align="center">
<template v-if="isFixed">
<el-table-column :label="colCountry" min-width="120">
<template #default="{ row }">{{ row.countryName || row.countryCode || '-' }}</template>
</el-table-column>
<el-table-column :label="colZone" min-width="110">
<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="colFv" min-width="130" align="right">
<template #default="{ row }"> <template #default="{ row }">
<el-tooltip placement="top" :disabled="!hasBaseFreight(row)"> <el-tag v-if="row.discountValue == null" size="small" type="info" effect="plain">默认折扣</el-tag>
<template #content> <span v-else class="pct">{{ row.discountValue }}%</span>
<span>产品基础运费:{{ formatBase(row.baseFreightPrice, 4) }} RMB/KG</span>
</template>
<span class="price-tip">
<template v-if="row.fvValue == null">
<span class="unit">未覆盖</span>
</template>
<template v-else>
<span class="hl">{{ row.fvValue }}</span>
<span class="unit"> RMB/KG</span>
</template>
</span>
</el-tooltip>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="colReg" min-width="120" align="right"> <el-table-column :label="colSc" min-width="200">
<template #default="{ row }"> <template #default="{ row }">
<el-tooltip placement="top" :disabled="!hasBaseReg(row)"> <surcharge-tags :list="row.surcharges || []" :empty-text="noneSc" />
<template #content>
<span>产品基础挂号费:{{ formatBase(row.baseRegistrationFee, 2) }} RMB/票</span>
</template>
<span class="price-tip">
<span class="reg">{{ row.fvRegistrationFee }}</span>
<span class="unit"> RMB/票</span>
</span>
</el-tooltip>
</template> </template>
</el-table-column> </el-table-column>
</template> <el-table-column :label="colRound" min-width="100" align="center">
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
<el-table-column v-else :label="colDisc" min-width="120" align="center"> </el-table-column>
<template #default="{ row }"> </el-table>
<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>
</div> </div>
<template #footer> <template #footer>
<el-button v-if="detailOpen" @click="backToProducts">返回产品</el-button>
<el-button @click="close">关闭</el-button> <el-button @click="close">关闭</el-button>
</template> </template>
</el-drawer> </el-drawer>
</template> </template>
<script setup name="SetVersionDetailDrawer"> <script setup name="SetVersionDetailDrawer">
import { ArrowLeft } from '@element-plus/icons-vue'
import SurchargeTags from '../SurchargeTags.vue' import SurchargeTags from '../SurchargeTags.vue'
import { isFixedType } from '../../utils/priceSet' import { isFixedType } from '../../utils/priceSet'
import { listSetDetail, listSetDetailOptions } from '@/api/price/set' import { listSetDetailByProduct, listSetDetailOptions } from '@/api/price/set'
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
@@ -154,61 +141,25 @@ const emit = defineEmits(['update:modelValue'])
const { bms_round_mode } = useDict('bms_round_mode') const { bms_round_mode } = useDict('bms_round_mode')
const loading = ref(false) const loading = ref(false)
const rows = ref([]) const productKeyword = ref('')
const total = ref(0) const products = ref([])
const query = reactive({ const detailOpen = ref(false)
pageNum: 1, const detailLoading = ref(false)
pageSize: 20, const detailKeyword = ref('')
productKeyword: '', const detailRows = ref([])
countryKeyword: '', const activeProduct = ref(null)
zoneKeyword: ''
})
// 下拉选项:当前版本内去重后的 产品/国家/分区 const phSearch = '产品代码/名称'
const optionsLoading = ref(false) const phDetailSearch = '搜索国家/分区'
const optionProducts = ref([]) const hint = '点击产品行查看国家/重量段明细(仅查看)'
const optionCountries = ref([]) const emptyProduct = '暂无产品'
const optionZones = ref([]) const emptyDetail = '暂无明细'
const colCode = '产品代码'
function loadOptions() { const colName = '产品名称'
const psvId = props.version && props.version.psvId const colLevel = '时效等级'
if (!psvId) { const colCargo = '走货属性'
optionProducts.value = [] const colRegion = '目的区域'
optionCountries.value = [] const colAction = '操作'
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 colCountry = '国家' const colCountry = '国家'
const colZone = '分区' const colZone = '分区'
const colWeight = '重量段' const colWeight = '重量段'
@@ -224,6 +175,92 @@ const title = computed(() => {
const v = props.version || {} const v = props.version || {}
return (v.versionNo || '-') + ' · 版本明细' 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) { function hasBaseFreight(row) {
return row && row.baseFreightPrice != null && row.baseFreightPrice !== '' return row && row.baseFreightPrice != null && row.baseFreightPrice !== ''
@@ -237,7 +274,6 @@ function formatBase(v, precision) {
if (Number.isNaN(n)) return String(v) if (Number.isNaN(n)) return String(v)
return n.toFixed(precision) return n.toFixed(precision)
} }
function roundModeLabel(mode) { function roundModeLabel(mode) {
const hit = (bms_round_mode.value || []).find(d => d.value === mode) const hit = (bms_round_mode.value || []).find(d => d.value === mode)
return hit ? hit.label : (mode || '四舍五入') return hit ? hit.label : (mode || '四舍五入')
@@ -247,52 +283,73 @@ function close() {
emit('update:modelValue', false) emit('update:modelValue', false)
} }
function search() { function backToProducts() {
query.pageNum = 1 detailOpen.value = false
load() activeProduct.value = null
detailRows.value = []
detailKeyword.value = ''
} }
function reset() { function normalizeProducts(list) {
query.productKeyword = '' return (list || [])
query.countryKeyword = '' .filter(p => p && (p.productCode || p.code))
query.zoneKeyword = '' .map(p => ({
query.pageNum = 1 productCode: p.productCode || p.code,
load() productName: p.productName || p.name || p.productCode || p.code,
serviceLevel: p.serviceLevel,
cargoAttr: p.cargoAttr,
destRegion: p.destRegion,
taxMode: p.taxMode
}))
} }
function load() { async function loadProducts() {
const psvId = props.version && props.version.psvId const v = props.version || {}
const psvId = v.psvId
if (!psvId) { if (!psvId) {
rows.value = [] products.value = []
total.value = 0
return return
} }
loading.value = true loading.value = true
listSetDetail({ try {
psvId, const fromVersion = normalizeProducts(v.products)
productKeyword: query.productKeyword || undefined, if (fromVersion.length) {
countryKeyword: isFixed.value ? (query.countryKeyword || undefined) : undefined, products.value = fromVersion
zoneKeyword: isFixed.value ? (query.zoneKeyword || undefined) : undefined, return
pageNum: query.pageNum, }
pageSize: query.pageSize const res = await listSetDetailOptions(psvId)
}).then(res => { const data = res.data || {}
rows.value = res.rows || [] products.value = normalizeProducts(data.products || [])
total.value = res.total || 0 } finally {
}).finally(() => {
loading.value = false 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( watch(
() => [props.modelValue, props.version && props.version.psvId], () => [props.modelValue, props.version && props.version.psvId],
([open]) => { ([open]) => {
if (open) { if (open) {
query.pageNum = 1 productKeyword.value = ''
query.productKeyword = '' backToProducts()
query.countryKeyword = '' loadProducts()
query.zoneKeyword = ''
loadOptions()
load()
} }
} }
) )
@@ -312,29 +369,16 @@ watch(
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
} }
.pager { .mb12 { margin-bottom: 12px; }
margin-top: 12px; .product-count {
} margin-top: 10px;
.cell-product { text-align: right;
display: flex;
align-items: baseline;
gap: 6px;
}
.p-code {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
color: #606266; color: #606266;
background: #f4f4f5; font-size: 13px;
border-radius: 3px;
padding: 0 6px;
} }
.p-name { color: #303133; } .detail-title {
.opt-name { margin-right: 8px; } font-weight: 600;
.opt-code { color: #303133;
float: right;
color: var(--el-text-color-secondary);
font-size: 12px;
font-family: Consolas, Monaco, monospace;
} }
.hl { color: #1890ff; font-weight: 600; } .hl { color: #1890ff; font-weight: 600; }
.reg { color: #d48806; font-weight: 600; } .reg { color: #d48806; font-weight: 600; }
@@ -347,4 +391,7 @@ watch(
.price-tip:hover { .price-tip:hover {
border-bottom-color: #c0c4cc; border-bottom-color: #c0c4cc;
} }
:deep(.el-table__row) {
cursor: pointer;
}
</style> </style>
@@ -69,13 +69,6 @@
@click="emit('remove', row)" @click="emit('remove', row)"
v-hasPermi="['price:set:edit']" v-hasPermi="['price:set:edit']"
>删除</el-button> >删除</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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -91,7 +84,7 @@ const props = defineProps({
versions: { type: Array, default: () => [] }, versions: { type: Array, default: () => [] },
modelValue: { type: [Number, String], default: undefined } 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 title = '套餐版本'
const colNo = '版本号' const colNo = '版本号'
+74 -10
View File
@@ -72,7 +72,7 @@ import {
toCustomerPayload, toCustomerPayload,
toDetailPayload toDetailPayload
} from './utils/priceSet' } from './utils/priceSet'
import { getPriceSet, savePriceSetAsync } from '@/api/price/set' import { getPriceSet, savePriceSetAsync, createPriceSetVersion, deletePriceSetVersion } from '@/api/price/set'
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
const route = useRoute() const route = useRoute()
@@ -202,12 +202,19 @@ async function finishEdit() {
delete payload.details delete payload.details
await runSaveWithProgress(payload) await runSaveWithProgress(payload)
savedPriceBuildMode.value = form.version.priceBuildMode savedPriceBuildMode.value = form.version.priceBuildMode
savedProductsSnapshot.value = (form.products || []).map(p => ({ ...p }))
await nextTick() await nextTick()
if (form.version.priceBuildMode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) { if (form.version.priceBuildMode === 'PULL' && detailsRef.value && detailsRef.value.pullAllFromLibrary) {
await detailsRef.value.pullAllFromLibrary({ silent: true }) 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("建价方式已保存")
} }
proxy.$modal.msgSuccess("建价方式已保存")
} catch (e) { } catch (e) {
return return
} finally { } finally {
@@ -243,6 +250,12 @@ async function finishEdit() {
} }
creating.value = true creating.value = true
try { 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 payload = buildPayload({ forceDetails: true })
const priceSetId = await runSaveWithProgress(payload) const priceSetId = await runSaveWithProgress(payload)
form.priceSetId = priceSetId form.priceSetId = priceSetId
@@ -296,7 +309,7 @@ function buildPayload(opts = {}) {
defaultDiscount: undefined, defaultDiscount: undefined,
priceSetNote: basic.priceSetNote, priceSetNote: basic.priceSetNote,
version: { version: {
psvId: copyMode.value ? undefined : version.psvId, psvId: (copyMode.value || (newVersionMode.value && version.psvId == null)) ? undefined : version.psvId,
versionNo: version.versionNo, versionNo: version.versionNo,
priceBuildMode: version.priceBuildMode || undefined priceBuildMode: version.priceBuildMode || undefined
} }
@@ -323,7 +336,30 @@ function buildPayload(opts = {}) {
return payload 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 const obj = (newVersionMode.value || copyMode.value) && form.priceSetId
? { path: '/customer/setDetail', query: { priceSetId: form.priceSetId } } ? { path: '/customer/setDetail', query: { priceSetId: form.priceSetId } }
: { path: '/customer/set' } : { path: '/customer/set' }
@@ -383,7 +419,20 @@ function onPriceBuildModeUpdate(mode) {
}).catch(() => {}) }).catch(() => {})
}) })
} else if (mode === 'IMPORT') { } else if (mode === 'IMPORT') {
proxy.$modal.msgSuccess("请导入 Excel 建价") 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) { function applySetData(data, asCopy, asNewVersion) {
form.priceSetId = data.priceSetId form.priceSetId = data.priceSetId
Object.assign(form.basic, { Object.assign(form.basic, {
@@ -426,12 +489,13 @@ function applySetData(data, asCopy, asNewVersion) {
form.details = [] form.details = []
form.products = [] form.products = []
} else if (asNewVersion) { } else if (asNewVersion) {
// Pre-created empty draft from detail page; same step2 UX as create package // Draft is created on finish only. Resume only when query has psvId (legacy empty draft).
form.version.psvId = current.psvId const resume = qPsv != null ? byQuery : null
form.version.versionNo = current.versionNo || nextVersionNo(versions) form.version.psvId = resume ? resume.psvId : undefined
form.version.versionNo = (resume && resume.versionNo) || nextVersionNo(versions)
form.version.effectiveTime = undefined form.version.effectiveTime = undefined
form.version.expireTime = undefined form.version.expireTime = undefined
form.version.status = current.status || '0' form.version.status = (resume && resume.status) || '0'
form.version.priceBuildMode = undefined form.version.priceBuildMode = undefined
draftPefId.value = undefined draftPefId.value = undefined
savedPriceBuildMode.value = undefined savedPriceBuildMode.value = undefined
+6 -29
View File
@@ -23,7 +23,6 @@
<set-version-table <set-version-table
v-model="currentPsvId" v-model="currentPsvId"
:versions="set.versions || []" :versions="set.versions || []"
@copy-version="onCopyVersion"
@create-version="onCreateVersion" @create-version="onCreateVersion"
@open-details="onOpenVersionDetails" @open-details="onOpenVersionDetails"
@view-import-attach="onViewImportAttach" @view-import-attach="onViewImportAttach"
@@ -87,7 +86,7 @@ import SetVersionExcelFileDrawer from './components/detail/SetVersionExcelFileDr
import PriceEditPanel from './components/create/PriceEditPanel.vue' import PriceEditPanel from './components/create/PriceEditPanel.vue'
import { usePriceSetRefData } from './composables/usePriceSetRefData' import { usePriceSetRefData } from './composables/usePriceSetRefData'
import { import {
getPriceSet, disablePriceSet, enablePriceSet, activatePriceSetVersion, copyPriceSetVersion, createPriceSetVersion, deletePriceSetVersion getPriceSet, disablePriceSet, enablePriceSet, activatePriceSetVersion, deletePriceSetVersion
} from '@/api/price/set' } from '@/api/price/set'
import { isDraftVersion, isPendingVersion } from './utils/priceSet' import { isDraftVersion, isPendingVersion } from './utils/priceSet'
@@ -379,33 +378,11 @@ function onCreateVersion() {
proxy.$modal.msgWarning('已有草稿版本,请先启动或删除后再新建') proxy.$modal.msgWarning('已有草稿版本,请先启动或删除后再新建')
return return
} }
createPriceSetVersion(priceSetId).then(res => { // Do not insert draft until user clicks finish on the build-price page.
const newPsvId = res.data router.push({
proxy.$modal.msgSuccess('已创建草稿版本,请继续编辑产品价格') path: '/customer/setCreate',
router.push({ query: { priceSetId, step: '2', newVersion: '1' }
path: '/customer/setCreate', })
query: { priceSetId, psvId: newPsvId, 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) { function onRemoveVersion(row) {
if (!row || row.psvId == null) return if (!row || row.psvId == null) return
@@ -108,6 +108,24 @@ export function nextVersionNo(versions) {
return 'V' + (max + 1) 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 */ /** Package status label */
export function setStatusLabel(set) { export function setStatusLabel(set) {
if (set && set.expired) return '已过期' if (set && set.expired) return '已过期'