Files
BMS/bms-front/src/views/price/set/components/create/StepDetails.vue
T
2026-09-16 17:14:19 +08:00

499 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>