修改套餐价格
This commit is contained in:
+70
-1
@@ -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.
|
||||||
|
|||||||
+74
-2
@@ -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 = '版本号'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 '已过期'
|
||||||
|
|||||||
Reference in New Issue
Block a user