套餐明细附加费显示

This commit is contained in:
wuweihua
2026-09-08 14:28:09 +08:00
parent 520fc32a4e
commit 49b1d2c321
4 changed files with 254 additions and 12 deletions
@@ -54,9 +54,9 @@ public class SurchargeController extends BaseController
} }
/** /**
* 查询指定产品已关联的附加费(非分页,供产品列表抽屉使用) * 查询指定产品已关联的附加费(非分页,供产品列表抽屉、套餐明细展示使用)
*/ */
@PreAuthorize("@ss.hasPermi('price:surcharge:query')") @PreAuthorize("@ss.hasPermi('price:surcharge:query') or @ss.hasPermi('price:set:query') or @ss.hasPermi('price:set:add')")
@GetMapping("/byProduct/{productCode}") @GetMapping("/byProduct/{productCode}")
public AjaxResult listByProduct(@PathVariable String productCode) public AjaxResult listByProduct(@PathVariable String productCode)
{ {
+3 -3
View File
@@ -1,5 +1,5 @@
-- ---------------------------- -- ----------------------------
-- BMS price set: 客户中心 · 价格套餐管理 -- BMS price set: 套餐管理 · 价格套餐管理
-- charset: utf8mb4 -- charset: utf8mb4
-- menu_id: 2200-2210 -- menu_id: 2200-2210
-- 相对 PRD《价格表优化建表.sql》差异: -- 相对 PRD《价格表优化建表.sql》差异:
@@ -77,9 +77,9 @@ CREATE TABLE IF NOT EXISTS prc_customer_price_set (
CONSTRAINT fk_customer_price_set FOREIGN KEY (price_set_id) REFERENCES prc_price_set (price_set_id) ON DELETE RESTRICT CONSTRAINT fk_customer_price_set FOREIGN KEY (price_set_id) REFERENCES prc_price_set (price_set_id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户套餐表-客户某时间段内使用的套餐'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户套餐表-客户某时间段内使用的套餐';
-- 客户中心目录 -- 套餐管理目录
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
SELECT 2200, N'客户中心', 0, 8, 'customer', NULL, '', '', 1, 0, 'M', '0', '0', '', 'peoples', 'admin', sysdate(), N'客户中心目录' SELECT 2200, N'套餐管理', 0, 8, 'customer', NULL, '', '', 1, 0, 'M', '0', '0', '', 'peoples', 'admin', sysdate(), N'套餐管理目录'
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2200); FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 2200);
INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) INSERT INTO sys_menu (menu_id, menu_name, parent_id, order_num, path, component, query, route_name, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark)
+117 -4
View File
@@ -77,7 +77,7 @@
:closable="false" :closable="false"
show-icon show-icon
class="mb12" class="mb12"
title="FIXED 模式:可不填明细(计费取 bms_product_country 默认运费/挂号费);有明细时按产品×国家×重量段覆盖固定价。" title="FIXED 模式:可不填明细(计费取 bms_product_country 默认运费/挂号费);有明细时按产品×国家×重量段覆盖固定价。附加费来自产品关联,只读展示。"
/> />
<el-alert <el-alert
v-else v-else
@@ -157,6 +157,41 @@
<el-input-number v-model="row.discountValue" :min="1" :max="99" :precision="2" controls-position="right" style="width: 100%" placeholder="空=默认折扣" /> <el-input-number v-model="row.discountValue" :min="1" :max="99" :precision="2" controls-position="right" style="width: 100%" placeholder="空=默认折扣" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="附加费" min-width="220">
<template #default="{ row }">
<div v-if="!row.productCode" class="muted">-</div>
<el-popover
v-else-if="surchargesOf(row).length"
placement="top"
:width="400"
trigger="hover"
>
<template #default>
<div class="sc-pop-list">
<div v-for="s in surchargesOf(row)" :key="s.relId || s.scId" class="sc-pop-item">
<div class="sc-pop-name">{{ s.scName }}</div>
<div class="sc-pop-meta">
{{ chargeTypeLabel(s.chargeType) }} · {{ formatChargeValue(s) }}
· {{ surchargeScopeLabel(s) }}
</div>
</div>
</div>
</template>
<template #reference>
<div class="sc-tags">
<el-tag
v-for="s in surchargesOf(row)"
:key="s.relId || s.scId"
size="small"
type="danger"
effect="plain"
>{{ s.scName }} {{ formatChargeValue(s) }}</el-tag>
</div>
</template>
</el-popover>
<span v-else class="muted">无</span>
</template>
</el-table-column>
<el-table-column label="取值方式" min-width="130"> <el-table-column label="取值方式" min-width="130">
<template #default="{ row }"> <template #default="{ row }">
<el-select v-model="row.roundMode" placeholder="取值方式" style="width: 100%"> <el-select v-model="row.roundMode" placeholder="取值方式" style="width: 100%">
@@ -253,9 +288,10 @@
<script setup name="PriceSetCreate"> <script setup name="PriceSetCreate">
import { getPriceSet, savePriceSet, validatePriceSet } from '@/api/price/set' import { getPriceSet, savePriceSet, validatePriceSet } from '@/api/price/set'
import { listProduct, listProductCountries } from '@/api/price/product' import { listProduct, listProductCountries } from '@/api/price/product'
import { listSurchargeByProduct } from '@/api/price/surcharge'
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
const { bms_round_mode } = useDict('bms_round_mode') const { bms_round_mode, bms_charge_type } = useDict('bms_round_mode', 'bms_charge_type')
const route = useRoute() const route = useRoute()
const active = ref(0) const active = ref(0)
const saving = ref(false) const saving = ref(false)
@@ -264,6 +300,9 @@ const checkItems = ref([])
const productOptions = ref([]) const productOptions = ref([])
/** productCode -> bms_product_country rows */ /** productCode -> bms_product_country rows */
const countryCache = reactive({}) const countryCache = reactive({})
/** productCode -> 有效附加费关联 */
const surchargeCache = reactive({})
const surchargePending = {}
const form = reactive({ const form = reactive({
priceSetId: undefined, priceSetId: undefined,
@@ -329,6 +368,54 @@ function ensureCountries(productCode) {
}) })
} }
function ensureSurcharges(productCode) {
if (!productCode) return Promise.resolve([])
if (surchargeCache[productCode]) return Promise.resolve(surchargeCache[productCode])
if (surchargePending[productCode]) return surchargePending[productCode]
surchargePending[productCode] = listSurchargeByProduct(productCode).then(res => {
const rows = (res.data || []).filter(item => String(item.ssCode || '').toLowerCase() !== 'of')
surchargeCache[productCode] = rows
return rows
}).catch(() => {
surchargeCache[productCode] = []
return []
}).finally(() => {
delete surchargePending[productCode]
})
return surchargePending[productCode]
}
function surchargesOf(row) {
if (!row || !row.productCode) return []
const all = surchargeCache[row.productCode] || []
return all.filter(s => {
if (s.countryCode && row.countryCode && String(s.countryCode).toUpperCase() !== String(row.countryCode).toUpperCase()) {
return false
}
if (s.detailId == null) return true
if (row.productCountryId != null && Number(s.detailId) === Number(row.productCountryId)) return true
return false
})
}
function chargeTypeLabel(val) {
const found = (bms_charge_type.value || []).find(c => c.value === val)
return found ? found.label : (val || '-')
}
function formatChargeValue(row) {
if (row.chargeValue == null) return '-'
const v = Number(row.chargeValue)
if (row.chargeType === 'RATE') return (v * 100).toFixed(2) + '%'
return v + ' ' + (row.currency || 'CNY')
}
function surchargeScopeLabel(s) {
if (s.detailId == null) return '产品级'
const w = s.weightMin != null && s.weightMax != null ? `${s.weightMin}-${s.weightMax}kg` : '-'
return `${s.countryName || '-'} · ${w}`
}
function countryOptionsOf(row) { function countryOptionsOf(row) {
const rows = countryCache[row.productCode] || [] const rows = countryCache[row.productCode] || []
const map = new Map() const map = new Map()
@@ -411,8 +498,9 @@ function onProductChange(row) {
const found = productOptions.value.find(p => p.value === row.productCode) const found = productOptions.value.find(p => p.value === row.productCode)
row.productName = found ? found.name : row.productCode row.productName = found ? found.name : row.productCode
clearCascadeAfterProduct(row) clearCascadeAfterProduct(row)
if (!row.productCode || form.setType !== 'FIXED') return if (!row.productCode) return
ensureCountries(row.productCode) ensureSurcharges(row.productCode)
if (form.setType === 'FIXED') ensureCountries(row.productCode)
} }
function onCountryChange(row) { function onCountryChange(row) {
@@ -443,6 +531,7 @@ function hydrateDetailRow(d) {
_countryKey: countryKeyOf(d) _countryKey: countryKeyOf(d)
} }
// normalize historical weight labels to min≤W≤max when possible // normalize historical weight labels to min≤W≤max when possible
if (row.productCode) ensureSurcharges(row.productCode)
if (row.productCode && form.setType === 'FIXED') { if (row.productCode && form.setType === 'FIXED') {
ensureCountries(row.productCode).then(rows => { ensureCountries(row.productCode).then(rows => {
if (!row.weightLabel) return if (!row.weightLabel) return
@@ -660,4 +749,28 @@ onMounted(() => {
text-align: center; text-align: center;
padding: 8px 0 24px; padding: 8px 0 24px;
} }
.muted {
color: #c0c4cc;
}
.sc-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
cursor: pointer;
}
.sc-pop-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.sc-pop-item {
line-height: 1.4;
}
.sc-pop-name {
font-weight: 600;
}
.sc-pop-meta {
font-size: 12px;
color: #909399;
}
</style> </style>
+132 -3
View File
@@ -76,6 +76,40 @@
<el-table-column v-if="set.setType === 'DISCOUNT'" label="产品折扣" min-width="110"> <el-table-column v-if="set.setType === 'DISCOUNT'" label="产品折扣" min-width="110">
<template #default="{ row }">{{ row.discountValue != null ? row.discountValue + '%' : '默认折扣' }}</template> <template #default="{ row }">{{ row.discountValue != null ? row.discountValue + '%' : '默认折扣' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="附加费" min-width="220">
<template #default="{ row }">
<el-popover
v-if="surchargesOf(row).length"
placement="top"
:width="400"
trigger="hover"
>
<template #default>
<div class="sc-pop-list">
<div v-for="s in surchargesOf(row)" :key="s.relId || s.scId" class="sc-pop-item">
<div class="sc-pop-name">{{ s.scName }}</div>
<div class="sc-pop-meta">
{{ chargeTypeLabel(s.chargeType) }} · {{ formatChargeValue(s) }}
· {{ surchargeScopeLabel(s) }}
</div>
</div>
</div>
</template>
<template #reference>
<div class="sc-tags">
<el-tag
v-for="s in surchargesOf(row)"
:key="s.relId || s.scId"
size="small"
type="danger"
effect="plain"
>{{ s.scName }} {{ formatChargeValue(s) }}</el-tag>
</div>
</template>
</el-popover>
<span v-else class="muted">无</span>
</template>
</el-table-column>
<el-table-column label="取值方式" min-width="110" align="center"> <el-table-column label="取值方式" min-width="110" align="center">
<template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template> <template #default="{ row }">{{ roundModeLabel(row.roundMode) }}</template>
</el-table-column> </el-table-column>
@@ -183,6 +217,18 @@
<el-option v-for="d in bms_round_mode" :key="d.value" :label="d.label" :value="d.value" /> <el-option v-for="d in bms_round_mode" :key="d.value" :label="d.label" :value="d.value" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item v-if="detailForm.productCode" label="附加费">
<div v-if="detailSurcharges.length" class="sc-tags">
<el-tag
v-for="s in detailSurcharges"
:key="s.relId || s.scId"
size="small"
type="danger"
effect="plain"
>{{ s.scName }} {{ formatChargeValue(s) }}</el-tag>
</div>
<span v-else class="muted">该产品暂无匹配附加费</span>
</el-form-item>
<!-- <el-form-item label="关联 pr_id" prop="prId"> <!-- <el-form-item label="关联 pr_id" prop="prId">
<el-input v-model="detailForm.prId" placeholder="可选" /> <el-input v-model="detailForm.prId" placeholder="可选" />
</el-form-item> --> </el-form-item> -->
@@ -226,15 +272,18 @@ import {
addSetCustomer, updateSetCustomer, delSetCustomer addSetCustomer, updateSetCustomer, delSetCustomer
} from '@/api/price/set' } from '@/api/price/set'
import { listProduct, listProductCountries } from '@/api/price/product' import { listProduct, listProductCountries } from '@/api/price/product'
import { listSurchargeByProduct } from '@/api/price/surcharge'
import { parseTime } from '@/utils/ruoyi' import { parseTime } from '@/utils/ruoyi'
const { proxy } = getCurrentInstance() const { proxy } = getCurrentInstance()
const { bms_round_mode } = useDict('bms_round_mode') const { bms_round_mode, bms_charge_type } = useDict('bms_round_mode', 'bms_charge_type')
const route = useRoute() const route = useRoute()
const loading = ref(false) const loading = ref(false)
const set = ref({}) const set = ref({})
const productOptions = ref([]) const productOptions = ref([])
const countryCache = reactive({}) const countryCache = reactive({})
const surchargeCache = reactive({})
const surchargePending = {}
const detailOpen = ref(false) const detailOpen = ref(false)
const detailTitle = ref('') const detailTitle = ref('')
@@ -287,6 +336,56 @@ function ensureCountries(productCode) {
}) })
} }
function ensureSurcharges(productCode) {
if (!productCode) return Promise.resolve([])
if (surchargeCache[productCode]) return Promise.resolve(surchargeCache[productCode])
if (surchargePending[productCode]) return surchargePending[productCode]
surchargePending[productCode] = listSurchargeByProduct(productCode).then(res => {
const rows = (res.data || []).filter(item => String(item.ssCode || '').toLowerCase() !== 'of')
surchargeCache[productCode] = rows
return rows
}).catch(() => {
surchargeCache[productCode] = []
return []
}).finally(() => {
delete surchargePending[productCode]
})
return surchargePending[productCode]
}
function surchargesOf(row) {
if (!row || !row.productCode) return []
const all = surchargeCache[row.productCode] || []
return all.filter(s => {
if (s.countryCode && row.countryCode && String(s.countryCode).toUpperCase() !== String(row.countryCode).toUpperCase()) {
return false
}
if (s.detailId == null) return true
if (row.productCountryId != null && Number(s.detailId) === Number(row.productCountryId)) return true
return false
})
}
function chargeTypeLabel(val) {
const found = (bms_charge_type.value || []).find(c => c.value === val)
return found ? found.label : (val || '-')
}
function formatChargeValue(row) {
if (row.chargeValue == null) return '-'
const v = Number(row.chargeValue)
if (row.chargeType === 'RATE') return (v * 100).toFixed(2) + '%'
return v + ' ' + (row.currency || 'CNY')
}
function surchargeScopeLabel(s) {
if (s.detailId == null) return '产品级'
const w = s.weightMin != null && s.weightMax != null ? `${s.weightMin}-${s.weightMax}kg` : '-'
return `${s.countryName || '-'} · ${w}`
}
const detailSurcharges = computed(() => surchargesOf(detailForm.value))
const detailCountryOptions = computed(() => { const detailCountryOptions = computed(() => {
const rows = countryCache[detailForm.value.productCode] || [] const rows = countryCache[detailForm.value.productCode] || []
const map = new Map() const map = new Map()
@@ -342,7 +441,9 @@ function onDetailProductChange() {
detailForm.value.productCountryId = undefined detailForm.value.productCountryId = undefined
detailForm.value.fvValue = undefined detailForm.value.fvValue = undefined
detailForm.value.fvRegistrationFee = undefined detailForm.value.fvRegistrationFee = undefined
if (detailForm.value.productCode && set.value.setType === 'FIXED') { if (!detailForm.value.productCode) return
ensureSurcharges(detailForm.value.productCode)
if (set.value.setType === 'FIXED') {
ensureCountries(detailForm.value.productCode) ensureCountries(detailForm.value.productCode)
} }
} }
@@ -397,6 +498,8 @@ function load() {
loading.value = true loading.value = true
getPriceSet(id).then(res => { getPriceSet(id).then(res => {
set.value = res.data || {} set.value = res.data || {}
const codes = [...new Set((set.value.details || []).map(d => d.productCode).filter(Boolean))]
codes.forEach(code => ensureSurcharges(code))
}).finally(() => { }).finally(() => {
loading.value = false loading.value = false
}) })
@@ -471,7 +574,9 @@ function openDetailDialog(row) {
} }
detailTitle.value = row ? '编辑明细' : '添加明细' detailTitle.value = row ? '编辑明细' : '添加明细'
detailOpen.value = true detailOpen.value = true
if (detailForm.value.productCode && set.value.setType === 'FIXED') { if (!detailForm.value.productCode) return
ensureSurcharges(detailForm.value.productCode)
if (set.value.setType === 'FIXED') {
ensureCountries(detailForm.value.productCode) ensureCountries(detailForm.value.productCode)
} }
} }
@@ -583,4 +688,28 @@ onMounted(() => {
color: #d48806; color: #d48806;
font-weight: 600; font-weight: 600;
} }
.muted {
color: #c0c4cc;
}
.sc-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
cursor: pointer;
}
.sc-pop-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.sc-pop-item {
line-height: 1.4;
}
.sc-pop-name {
font-weight: 600;
}
.sc-pop-meta {
font-size: 12px;
color: #909399;
}
</style> </style>