499 lines
17 KiB
Vue
499 lines
17 KiB
Vue
<template>
|
||
<el-card shadow="never" class="step-card" v-loading="pulling" :element-loading-text="loadingText">
|
||
<template #header>
|
||
<div class="card-head">
|
||
<span>
|
||
{{ headerTitle }}
|
||
<span class="sub">{{ typeHint }}</span>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
|
||
<el-alert type="info" :closable="false" show-icon class="mb12" :title="metaText" />
|
||
|
||
<div class="filters">
|
||
<el-input v-model="query.keyword" clearable :placeholder="phKeyword" style="width: 180px" />
|
||
<el-select v-model="query.taxMode" clearable :placeholder="phTax" style="width: 140px">
|
||
<el-option v-for="d in bms_tax_mode" :key="d.value" :label="d.label" :value="d.value" />
|
||
</el-select>
|
||
<el-button size="small" @click="noop">查询</el-button>
|
||
</div>
|
||
|
||
<el-table :data="filteredProducts" border empty-text="暂无产品价格明细">
|
||
<el-table-column :label="colCode" min-width="80">
|
||
<template #default="{ row }"><b>{{ row.productCode }}</b></template>
|
||
</el-table-column>
|
||
<el-table-column :label="colName" prop="productName" min-width="80" show-overflow-tooltip />
|
||
<el-table-column :label="colLevel" prop="serviceLevel" width="90" align="center" />
|
||
<el-table-column :label="colCargo" width="200" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.cargoAttr" type="primary" size="small" effect="plain">{{ row.cargoAttr }}</el-tag>
|
||
<span v-else>-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="colRegion" prop="destRegion" width="90" align="center" />
|
||
<el-table-column :label="colTax" width="90" align="center">
|
||
<template #default="{ row }">
|
||
<dict-tag :options="bms_tax_mode" :value="row.taxMode" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="colSurcharge" width="110" align="center">
|
||
<template #default="{ row }">
|
||
<surcharge-count-cell :list="productSurcharges(row)" @click="openSurcharge(row)" />
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column :label="colAction" width="220" align="center" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" @click="openPrice(row)">编辑价格</el-button>
|
||
<el-button link type="primary" @click="openSurcharge(row)">附加费</el-button>
|
||
<el-button link type="danger" @click="removeProduct(row.productCode)">移除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<surcharge-picker-dialog
|
||
v-model="scVisible"
|
||
:title="scTitle"
|
||
:list="scList"
|
||
:selected-ids="scPickedIds"
|
||
@confirm="onSurchargeConfirm"
|
||
/>
|
||
|
||
<el-dialog v-model="discVisible" :title="discTitle" width="420px" append-to-body>
|
||
<el-form label-width="100px">
|
||
<el-form-item :label="colDiscount">
|
||
<el-input-number v-model="discValue" :min="1" :max="99" :precision="2" controls-position="right" />
|
||
<span class="unit">%</span>
|
||
</el-form-item>
|
||
</el-form>
|
||
<template #footer>
|
||
<el-button @click="discVisible = false">取消</el-button>
|
||
<el-button type="primary" @click="saveDiscount">确定</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<price-edit-drawer
|
||
v-model="priceVisible"
|
||
:product-code="editCode"
|
||
:product-name="editName"
|
||
:rows="editRows"
|
||
@row-change="onRowPriceChange"
|
||
@pick-surcharge="openSurchargeFromDrawer"
|
||
/>
|
||
</el-card>
|
||
</template>
|
||
|
||
<script setup name="StepDetails">
|
||
import PriceEditDrawer from './PriceEditDrawer.vue'
|
||
import SurchargeCountCell from './SurchargeCountCell.vue'
|
||
import SurchargePickerDialog from './SurchargePickerDialog.vue'
|
||
import { useDetailCascade } from '../../composables/usePriceSetRefData'
|
||
import {
|
||
createEmptyDetailRow,
|
||
formatWeightLabel,
|
||
isDiscountType,
|
||
isFixedType,
|
||
setTypeLabel,
|
||
surchargeAppliesToRow,
|
||
toDetailPayload
|
||
} from '../../utils/priceSet'
|
||
import { updateSetDetail, delSetDetail } from '@/api/price/set'
|
||
|
||
const props = defineProps({
|
||
details: { type: Array, required: true },
|
||
priceSetId: { type: [Number, String], default: undefined },
|
||
setType: { type: String, default: 'FIXED' },
|
||
effectiveTime: { type: String, default: undefined },
|
||
versionNo: { type: String, default: '' },
|
||
refData: { type: Object, required: true }
|
||
})
|
||
|
||
const { proxy } = getCurrentInstance()
|
||
const { bms_tax_mode } = useDict('bms_tax_mode')
|
||
const { hydrateRow } = useDetailCascade(props.refData)
|
||
|
||
const loadingText = '正在全量拉取产品价格…'
|
||
const headerTitle = '第 2 步 · 编辑产品价格'
|
||
const phKeyword = '产品代码/名称'
|
||
const phTax = '全部税模式'
|
||
const colCode = '产品代码'
|
||
const colName = '产品名称'
|
||
const colLevel = '时效等级'
|
||
const colCargo = '走货属性'
|
||
const colRegion = '目的区域'
|
||
const colTax = '税模式'
|
||
const colSurcharge = '附加费'
|
||
const colAction = '操作'
|
||
const colDiscount = '产品折扣'
|
||
|
||
const query = reactive({ keyword: '', taxMode: undefined })
|
||
const priceVisible = ref(false)
|
||
const editCode = ref('')
|
||
const editName = ref('')
|
||
const editRows = ref([])
|
||
/** 抽屉当前对应的产品分组(抽屉内点附加费时用它打开勾选弹窗) */
|
||
const editGroup = ref(null)
|
||
const scVisible = ref(false)
|
||
const scTitle = ref('附加费')
|
||
const scList = ref([])
|
||
const scRows = ref([])
|
||
const scPickedIds = ref([])
|
||
/** 作用范围:null=产品级(覆盖全部明细行),否则=行级键(只写回国家+重量段那一行) */
|
||
const scScopeKey = ref(null)
|
||
const scScopeName = ref('')
|
||
const discVisible = ref(false)
|
||
const discTitle = ref('编辑价格')
|
||
const discValue = ref(undefined)
|
||
const discCode = ref('')
|
||
|
||
const typeHint = computed(() => '· 当前:' + setTypeLabel(props.setType))
|
||
|
||
const metaText = computed(() => {
|
||
const ver = props.versionNo || 'V?'
|
||
return ver + ' · 数据已落库,编辑价格/附加费会立即写入数据库。'
|
||
})
|
||
|
||
const productGroups = computed(() => {
|
||
const map = new Map()
|
||
for (const d of props.details) {
|
||
if (!d.productCode) continue
|
||
if (!map.has(d.productCode)) {
|
||
const meta = props.refData.productMetaOf(d.productCode) || {}
|
||
map.set(d.productCode, {
|
||
productCode: d.productCode,
|
||
productName: d.productName || meta.name || d.productCode,
|
||
serviceLevel: meta.serviceLevel,
|
||
cargoAttr: meta.cargoAttr,
|
||
destRegion: meta.destRegion,
|
||
taxMode: meta.taxMode,
|
||
rows: []
|
||
})
|
||
}
|
||
map.get(d.productCode).rows.push(d)
|
||
}
|
||
return [...map.values()]
|
||
})
|
||
|
||
const filteredProducts = computed(() => {
|
||
const kw = (query.keyword || '').trim().toLowerCase()
|
||
return productGroups.value.filter(p => {
|
||
if (query.taxMode && p.taxMode !== query.taxMode) return false
|
||
if (!kw) return true
|
||
const blob = [p.productCode, p.productName].filter(Boolean).join(' ').toLowerCase()
|
||
return blob.includes(kw)
|
||
})
|
||
})
|
||
|
||
const pulling = ref(false)
|
||
|
||
function noop() {}
|
||
|
||
/** 该产品所有明细行附加费的并集(按 scId 去重),列表列展示用 */
|
||
function productSurcharges(row) {
|
||
const seen = new Set()
|
||
const list = []
|
||
;((row && row.rows) || []).forEach(r => {
|
||
(r.surcharges || []).forEach(s => {
|
||
const key = s && s.scId != null ? String(s.scId) : (s && s.scName)
|
||
if (!key || seen.has(key)) return
|
||
seen.add(key)
|
||
list.push(s)
|
||
})
|
||
})
|
||
return list
|
||
}
|
||
|
||
/** enter step 3: pull all library products + country prices (skip already added) */
|
||
async function pullAllFromLibrary(opts = {}) {
|
||
const silent = !!opts.silent
|
||
const replace = !!opts.replace
|
||
if (!(props.refData.productOptions.value || []).length) {
|
||
await props.refData.loadProducts()
|
||
}
|
||
const products = props.refData.productOptions.value || []
|
||
if (!products.length) {
|
||
if (!silent) proxy.$modal.msgWarning('产品库无可用产品')
|
||
return { added: 0 }
|
||
}
|
||
if (replace) {
|
||
props.details.splice(0, props.details.length)
|
||
}
|
||
const used = new Set((props.details || []).map(d => d.productCode).filter(Boolean))
|
||
const toAdd = products.filter(p => !used.has(p.value))
|
||
if (!toAdd.length) return { added: 0 }
|
||
|
||
pulling.value = true
|
||
let added = 0
|
||
try {
|
||
// parallel fetch countries/surcharges, then push rows
|
||
await Promise.all(toAdd.map(async (p) => {
|
||
const code = p.value
|
||
const name = p.name || props.refData.productNameOf(code)
|
||
await props.refData.ensureSurcharges(code)
|
||
if (isDiscountType(props.setType)) {
|
||
const row = createEmptyDetailRow()
|
||
row.productCode = code
|
||
row.productName = name
|
||
row.surcharges = props.refData.surchargesOf(row)
|
||
props.details.push(row)
|
||
added++
|
||
return
|
||
}
|
||
const open = await props.refData.ensureCountries(code, props.effectiveTime)
|
||
if (!open.length) return
|
||
open.forEach(item => {
|
||
const row = countryToDetail(item, code, name)
|
||
row.surcharges = props.refData.surchargesOf(row)
|
||
props.details.push(row)
|
||
})
|
||
added++
|
||
}))
|
||
if (!silent) {
|
||
if (added > 0) proxy.$modal.msgSuccess('已从产品库全量拉取 ' + added + ' 个产品')
|
||
else proxy.$modal.msgWarning('未拉取到可展示的产品价格明细,请检查产品库国家配置')
|
||
}
|
||
} finally {
|
||
pulling.value = false
|
||
}
|
||
return { added }
|
||
}
|
||
|
||
function countryToDetail(item, productCode, productName) {
|
||
const row = createEmptyDetailRow()
|
||
row.productCode = productCode
|
||
row.productName = productName
|
||
row.countryCode = item.countryCode
|
||
row.countryName = item.countryName
|
||
row.zoneId = item.zoneId
|
||
row.zoneCode = item.zoneCode
|
||
row.zoneName = item.zoneName
|
||
row.weightMin = item.weightMin
|
||
row.weightMax = item.weightMax
|
||
row.weightLabel = formatWeightLabel(item.weightMin, item.weightMax)
|
||
row.productCountryId = item.detailId
|
||
const baseFv = item.freightPrice != null ? Number(item.freightPrice) : undefined
|
||
const baseReg = item.registrationFee != null ? Number(item.registrationFee) : undefined
|
||
row._baseFreight = baseFv
|
||
row._baseReg = baseReg
|
||
row.fvValue = baseFv
|
||
row.fvRegistrationFee = baseReg
|
||
row.effectiveDate = item.effectiveDate
|
||
row.expireDate = item.expireDate
|
||
row._countryKey = item.countryCode || (item.countryName ? 'N:' + item.countryName : undefined)
|
||
return hydrateRow(row, props.setType)
|
||
}
|
||
|
||
function openPrice(row) {
|
||
if (isDiscountType(props.setType)) {
|
||
discCode.value = row.productCode
|
||
discTitle.value = row.productCode + ' · ' + (row.productName || '') + ' · 产品折扣'
|
||
const hit = (props.details || []).find(d => d.productCode === row.productCode)
|
||
discValue.value = hit && hit.discountValue != null ? Number(hit.discountValue) : undefined
|
||
discVisible.value = true
|
||
return
|
||
}
|
||
editCode.value = row.productCode
|
||
editName.value = row.productName || ''
|
||
editRows.value = (row.rows || []).map(r => {
|
||
if (r._baseFreight == null && r.fvValue != null) r._baseFreight = Number(r.fvValue)
|
||
if (r._baseReg == null && r.fvRegistrationFee != null) r._baseReg = Number(r.fvRegistrationFee)
|
||
return r
|
||
})
|
||
editGroup.value = row
|
||
priceVisible.value = true
|
||
}
|
||
|
||
/** 抽屉里点附加费:带行 = 行级(只改该行,国家+重量段),不带 = 产品级(覆盖全部明细行) */
|
||
function openSurchargeFromDrawer(row) {
|
||
const group = editGroup.value || { productCode: editCode.value, rows: editRows.value }
|
||
if (row && (row.countryCode || row.countryName)) {
|
||
openSurcharge(group, rowScopeKey(row), rowScopeLabel(row))
|
||
} else {
|
||
openSurcharge(group)
|
||
}
|
||
}
|
||
|
||
|
||
/** persist one detail row to DB immediately */
|
||
async function persistDetail(row, withSurcharge) {
|
||
if (!row || !row.psdId || !props.priceSetId) return
|
||
const payload = toDetailPayload(row)
|
||
payload.priceSetId = props.priceSetId
|
||
if (!withSurcharge) delete payload.surcharges
|
||
await updateSetDetail(payload)
|
||
}
|
||
|
||
/** drawer field change -> immediate DB update */
|
||
async function onRowPriceChange(row) {
|
||
if (!row || !row.psdId) return
|
||
try {
|
||
await persistDetail(row, false)
|
||
proxy.$modal.msgSuccess('价格已更新')
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function saveDiscount() {
|
||
const hit = (props.details || []).find(d => d.productCode === discCode.value)
|
||
if (hit) {
|
||
hit.discountValue = discValue.value
|
||
try {
|
||
await persistDetail(hit, false)
|
||
proxy.$modal.msgSuccess('折扣已写入数据库')
|
||
} catch (e) {}
|
||
}
|
||
discVisible.value = false
|
||
}
|
||
|
||
/** 明细行的国家 key(与 countryToDetail 的 _countryKey 同口径,大写便于比较) */
|
||
function rowCountryKey(r) {
|
||
if (!r) return ''
|
||
return String(r.countryCode || (r.countryName ? 'N:' + r.countryName : '')).toUpperCase()
|
||
}
|
||
|
||
/** 明细行的行级唯一键(国家 + 分区 + 重量段),用于精确定位「只改这一行」 */
|
||
function rowScopeKey(r) {
|
||
if (!r) return ''
|
||
const c = rowCountryKey(r)
|
||
const z = String(r.zoneCode || r.zoneName || '')
|
||
const w = String(r.weightLabel || r.weightMin || r.weightMax || '')
|
||
return `${c}|${z}|${w}`
|
||
}
|
||
|
||
/** 行级作用域展示文案,如「荷兰 · 0.3-2kg」 */
|
||
function rowScopeLabel(r) {
|
||
if (!r) return ''
|
||
const c = r.countryName || r.countryCode || ''
|
||
const z = r.zoneName || r.zoneCode || ''
|
||
const w = r.weightLabel || ''
|
||
return [c, z, w].filter(Boolean).join(' · ')
|
||
}
|
||
|
||
/**
|
||
* 打开附加费勾选弹窗
|
||
* 列表 = 价格基础 → 附加费配置 主表全量(不要求产品已做关联);
|
||
* 勾选回显 = 作用范围内明细行当前附加费的并集;
|
||
* scopeKey 传行级键时只作用于该行(国家+重量段),不传则产品级全覆盖。
|
||
*/
|
||
async function openSurcharge(row, scopeKey, scopeName) {
|
||
await props.refData.ensureAllSurcharges()
|
||
scRows.value = row.rows || []
|
||
scScopeKey.value = scopeKey || null
|
||
const targets = scScopeKey.value
|
||
? scRows.value.filter(r => rowScopeKey(r) === scScopeKey.value)
|
||
: scRows.value
|
||
scScopeName.value = scopeName || (scScopeKey.value
|
||
? ((targets[0] && rowScopeLabel(targets[0])) || '')
|
||
: '')
|
||
scList.value = props.refData.allSurchargeConfigs()
|
||
const picked = new Set()
|
||
targets.forEach(r => {
|
||
(r.surcharges || []).forEach(s => {
|
||
if (s && s.scId != null) picked.add(String(s.scId))
|
||
})
|
||
})
|
||
scPickedIds.value = [...picked]
|
||
scTitle.value = row.productCode
|
||
+ (scScopeName.value ? ' · ' + scScopeName.value : '')
|
||
+ ' · 附加费'
|
||
scVisible.value = true
|
||
}
|
||
|
||
/**
|
||
* 勾选结果写回:行级只改该行(国家+重量段),产品级覆盖该产品全部明细行;同一附加费只留一条。
|
||
*
|
||
* 校验口径:附加费的 country_code 是它自身的计费适用国家(如「燃油附加费 RO」仅对罗马
|
||
* 尼亚计费),勾选挂载时【按目标明细行的国家做适用性校验】——国家码不匹配的附加费
|
||
* 不落到该行(该行计费时也不会命中它,见 TmsPriceQuoteServiceImpl.surchargeApplies),
|
||
* 并统计不匹配数提示用户。country_code 为空 = 全部国家,直接适用。
|
||
*/
|
||
function onSurchargeConfirm(picked) {
|
||
const ids = new Set((picked || []).map(s => String(s.scId)))
|
||
const targets = scScopeKey.value
|
||
? scRows.value.filter(r => rowScopeKey(r) === scScopeKey.value)
|
||
: scRows.value
|
||
let applied = 0
|
||
let skipped = 0
|
||
targets.forEach(r => {
|
||
const seen = new Set()
|
||
const list = []
|
||
scList.value.forEach(rel => {
|
||
const key = String(rel.scId)
|
||
if (!ids.has(key) || seen.has(key)) return
|
||
if (!surchargeAppliesToRow(rel, r)) {
|
||
skipped++
|
||
return
|
||
}
|
||
seen.add(key)
|
||
list.push(rel)
|
||
})
|
||
r.surcharges = list
|
||
applied += list.length
|
||
})
|
||
const scopeText = scScopeName.value ? `(仅 ${scScopeName.value})` : ''
|
||
if (!scList.value.length) {
|
||
proxy.$modal.msgWarning('附加费配置列表为空,请先到「价格基础 → 附加费配置」维护')
|
||
return
|
||
}
|
||
if (ids.size && !applied) {
|
||
proxy.$modal.msgWarning('已勾选但未生效:这些附加费配置了适用国家/重量段,与当前明细行不匹配')
|
||
return
|
||
}
|
||
if (skipped > 0) {
|
||
Promise.all(targets.filter(r => r.psdId).map(r => persistDetail(r, true))).finally(() => {
|
||
proxy.$modal.msgWarning(`已纳入 ${applied} 项附加费${scopeText};${skipped} 项因适用国家不匹配被跳过(已写库)`)
|
||
})
|
||
return
|
||
}
|
||
const okText = ids.size ? `已纳入 ${ids.size} 项附加费${scopeText}` : `已清空附加费${scopeText}`
|
||
Promise.all(targets.filter(r => r.psdId).map(r => persistDetail(r, true))).then(() => {
|
||
proxy.$modal.msgSuccess(okText + ',已写入数据库')
|
||
}).catch(() => {
|
||
proxy.$modal.msgSuccess(okText)
|
||
})
|
||
}
|
||
|
||
async function removeProduct(code) {
|
||
const ids = props.details.filter(d => d.productCode === code && d.psdId).map(d => d.psdId)
|
||
if (ids.length) {
|
||
try {
|
||
await delSetDetail(ids.join(','))
|
||
} catch (e) {
|
||
return
|
||
}
|
||
}
|
||
for (let i = props.details.length - 1; i >= 0; i--) {
|
||
if (props.details[i].productCode === code) props.details.splice(i, 1)
|
||
}
|
||
proxy.$modal.msgSuccess('已移除产品明细')
|
||
}
|
||
|
||
defineExpose({ pullAllFromLibrary })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
.sub {
|
||
margin-left: 8px;
|
||
color: #909399;
|
||
font-weight: normal;
|
||
font-size: 13px;
|
||
}
|
||
.mb12 { margin-bottom: 12px; }
|
||
.filters {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 12px;
|
||
}
|
||
.unit {
|
||
margin-left: 8px;
|
||
color: #909399;
|
||
}
|
||
</style>
|