Files
BMS/bms-front/src/views/price/set/components/create/StepDetails.vue
T
2026-11-05 15:38:45 +08:00

1218 lines
43 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 v-if="hasBuildMode" class="card-actions">
<el-button
v-if="showPullAction"
size="small"
:disabled="(livePersist && !priceSetId) || pulling"
@click="handlePullLibrary"
>拉取产品列表</el-button>
<el-dropdown
v-if="showImportAction && !isModifyImport && showCountryImport"
trigger="click"
@command="handleImportCommand"
>
<el-button
type="primary"
size="small"
:disabled="(livePersist && !priceSetId) || pulling || (livePersist && buildModeDirty)"
>
{{ importActionLabel }}
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="all">全部导入(多国家价表)</el-dropdown-item>
<el-dropdown-item command="country">按国家导入(单国专线成本价)</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button
v-else-if="showImportAction"
type="primary"
size="small"
:disabled="(livePersist && !priceSetId) || pulling || (livePersist && buildModeDirty)"
@click="openImport"
>{{ importActionLabel }}</el-button>
</div>
</div>
</template>
<!-- Exclusive quote: only Excel fresh build; hide the mode picker and default IMPORT_FRESH -->
<div v-if="!onlyImportFresh" class="build-choice">
<p class="build-choice__tip">创建价格方式</p>
<div class="build-choice__cards">
<button
type="button"
class="build-card build-card--pull"
:class="{ selected: isPullBuildMode(priceBuildMode) }"
:disabled="!canChooseBuildMode"
@click="chooseBuildMode('PULL')"
>
<div class="build-card__title">按产品价建价</div>
<div class="build-card__desc">按产品库基础价生成套餐产品列表,可再导入 Excel 修改价格</div>
</button>
<button
v-if="showImportInherit"
type="button"
class="build-card build-card--import"
:class="{ selected: isImportBuildMode(priceBuildMode) }"
:disabled="!canChooseBuildMode || !isFixedType(setType)"
@click="chooseBuildMode('IMPORT')"
>
<div class="build-card__title">按上一版 Excel 建价</div>
<div class="build-card__desc">延用以前的产品价格,带出上一版后再导入 Excel 按产品+国家覆盖或新增</div>
</button>
<button
type="button"
class="build-card build-card--import-fresh"
:class="{ selected: isImportFreshBuildMode(priceBuildMode) }"
:disabled="!canChooseBuildMode || !isFixedType(setType)"
@click="chooseBuildMode('IMPORT_FRESH')"
>
<div class="build-card__title">Excel 新建价</div>
<div class="build-card__desc">{{ importFreshCardDesc }}</div>
</button>
</div>
</div>
<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-empty
v-if="!(filteredProducts && filteredProducts.length)"
:description="emptyTip"
:image-size="80"
/>
<el-table v-else :data="filteredProducts" border>
<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="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="openSurchargeForProduct(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"
@surcharge-change="onRowSurchargeChange"
@pick-surcharge="openSurchargeFromDrawer"
/>
<excel-import-dialog
ref="importRef"
:title="importDialogTitle"
width="560px"
:show-update-support="false"
:import-desc="importDesc"
:file-limit="5"
template-file-code="SALES_ALL"
template-file-name="销售价多国导入模板"
:submit-handler="runImportAsync"
@success="onImportSuccess"
/>
<excel-import-dialog
ref="countryImportRef"
title="按国家导入"
width="560px"
:show-update-support="false"
:import-desc="countryImportDesc"
:file-limit="5"
template-file-code="SALES_COUNTRY"
template-file-name="销售价单国导入模板"
:before-submit="validateCountryImport"
:submit-handler="runCountryImportAsync"
@success="onImportSuccess"
>
<template #extra>
<el-form label-width="90px">
<el-form-item label="目的国家" required>
<el-select v-model="importCountryCode" filterable clearable placeholder="请选择国家" style="width: 100%">
<el-option
v-for="item in regionOptions"
:key="item.countryCode2"
:label="regionLabel(item)"
:value="item.countryCode2"
/>
</el-select>
</el-form-item>
</el-form>
</template>
</excel-import-dialog>
<el-dialog
v-model="importProgressVisible"
:title="progressDialogTitle"
width="420px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
append-to-body
>
<div class="import-progress">
<el-progress :percentage="importProgressPercent" :status="importProgressStatus" />
<div class="import-progress-msg">{{ importProgressMessage }}</div>
</div>
</el-dialog>
</el-card>
</template>
<script setup name="StepDetails">
import ExcelImportDialog from '@/components/ExcelImportDialog'
import PriceEditDrawer from './PriceEditDrawer.vue'
import SurchargePickerDialog from './SurchargePickerDialog.vue'
import { useDetailCascade } from '../../composables/usePriceSetRefData'
import {
createEmptyDetailRow,
formatWeightLabel,
isBuildModeLockedVersion,
isDiscountType,
isFixedType,
isExcelBuildMode,
isImportBuildMode,
isImportFreshBuildMode,
isPullBuildMode,
pickLatestPriorVersion,
setTypeLabel,
surchargeAppliesToRow,
toDetailPayload
} from '../../utils/priceSet'
import { usePriceSetModule } from '../../composables/usePriceSetModule'
import { listRegionOptions } from '@/api/data/region'
const mod = usePriceSetModule()
const { api } = mod
const onlyImportFresh = computed(() => !!mod.onlyImportFresh)
const showCountryImport = computed(() => !mod.exclusiveQuote)
const props = defineProps({
details: { type: Array, required: true },
products: { type: Array, default: () => [] },
priceSetId: { type: [Number, String], default: undefined },
psvId: { type: [Number, String], default: undefined },
setType: { type: String, default: 'FIXED' },
priceBuildMode: { type: String, default: undefined },
effectiveTime: { type: String, default: undefined },
versionNo: { type: String, default: '' },
refData: { type: Object, required: true },
headerTitle: { type: String, default: '编辑产品价格' },
/** true=edit live DB; false=create local draft until finish */
livePersist: { type: Boolean, default: true },
/** Version status: draft can change build mode; started versions lock it */
versionStatus: { type: [String, Number], default: undefined },
/** True when build mode changed locally and not saved yet */
buildModeDirty: { type: Boolean, default: false },
/** False while parent still loading package data — avoid first click being wiped by applySetData */
buildModeReady: { type: Boolean, default: true },
/**
* Show「按上一版 Excel 建价」(IMPORT). Hidden for brand-new package create
* (no prior version); keep for newVersion / copy / edit.
*/
showImportInherit: { type: Boolean, default: true }
})
const emit = defineEmits(['update:psvId', 'update:priceBuildMode', 'update:pefId'])
const { proxy } = getCurrentInstance()
const { bms_tax_mode } = useDict('bms_tax_mode')
const { hydrateRow } = useDetailCascade(props.refData)
const loadingText = '正在全量拉取产品价格…'
const phKeyword = '产品代码/名称'
const phTax = '全部税模式'
const colCode = '产品代码'
const colName = '产品名称'
const colLevel = '时效等级'
const colCargo = '走货属性'
const colRegion = '目的区域'
// const colTax = '税模式'
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 importRef = ref()
const countryImportRef = ref()
const importCountryCode = ref('')
const regionOptions = ref([])
const countryImportDesc = [
'选择目的国家后上传专线成本价表(含「产品目录」),目录中的产品都会记到该国家。',
'按产品+国家覆盖:仅替换已选国家的明细,其它产品/国家保留。',
'邮编、赔偿标准等 sheet 不导入。仅套餐 Excel 建价方式可用。'
]
const importProgressVisible = ref(false)
const importProgressPercent = ref(0)
const importProgressMessage = ref('')
const importProgressStatus = ref(undefined)
const progressDialogTitle = ref('正在导入价格')
const hasBuildMode = computed(() => {
if (isPullBuildMode(props.priceBuildMode) || isExcelBuildMode(props.priceBuildMode)) return true
// Legacy / already has products: treat as build mode ready
return (props.products || []).some(p => p && p.productCode)
})
/** Started (non-draft) versions: show cards but disable switching */
const buildModeLocked = computed(() => isBuildModeLockedVersion(props.versionStatus))
const choosingMode = ref(false)
const canChooseBuildMode = computed(() =>
!!props.buildModeReady && !buildModeLocked.value && !choosingMode.value
)
const isModifyImport = computed(() => {
if (isPullBuildMode(props.priceBuildMode)) return true
if (isExcelBuildMode(props.priceBuildMode)) return false
return (props.products || []).some(p => p && p.productCode)
})
const importActionLabel = computed(() =>
isModifyImport.value ? '导入修改价格' : '导入 Excel 建价'
)
const importDialogTitle = computed(() => importActionLabel.value)
const importFreshCardDesc = computed(() => onlyImportFresh.value
? '仅支持大客折扣报价表(目的地/运费/操作费),导入后直接生成专属报价明细'
: '不延用以前的产品价格,不带出上一版,上传 Excel 直接生成套餐销售价'
)
const importDesc = computed(() => {
if (onlyImportFresh.value) {
return [
'\u4e13\u5c5e\u62a5\u4ef7\u4ec5\u652f\u6301\u5927\u5ba2\u6298\u6263\u62a5\u4ef7\u8868\uff08\u542b\u300c\u4ea7\u54c1\u76ee\u5f55\u300d\u4e0e\u5404\u56fd\u4ef7\u8868\uff09\u3002',
'\u8868\u5934\u4e3a\u76ee\u7684\u5730 / \u91cd\u91cf\u6bb5 / \u8fd0\u8d39(CNY/KG) / \u64cd\u4f5c\u8d39(CNY/PCS)\uff0c\u4e0e\u666e\u901a\u5957\u9910 Excel \u6a21\u677f\u4e0d\u540c\u3002',
'\u4ec5\u300cExcel \u65b0\u5efa\u4ef7\u300d\uff1a\u4e0d\u62c9\u53d6\u4ea7\u54c1\u5e93\u3001\u4e0d\u5ef6\u7528\u4e0a\u4e00\u7248\uff0c\u5bfc\u5165\u540e\u76f4\u63a5\u751f\u6210\u9500\u552e\u4ef7\u660e\u7ec6\u3002'
]
}
if (isModifyImport.value) {
return [
'适用威狮B2C小包价表(含「产品目录」)。',
'按产品+国家+分区(有则匹配)+重量段匹配已有套餐明细,仅更新运费/挂号费。',
'仅固定价套餐可导入;未匹配行不会新增明细。'
]
}
if (isImportFreshBuildMode(props.priceBuildMode)) {
return [
'适用威狮B2C小包价表(含「产品目录」)。',
'不延用以前的产品价格;导入按「产品+国家」写入套餐销售价,直接生成产品明细。',
'同一产品下重复国家+重量段将整单失败。仅固定价套餐「Excel 新建价」可用。'
]
}
return [
'适用威狮B2C小包价表(含「产品目录」)。',
'延用以前的产品价格:先带出上一版全部产品;导入按「产品+国家」匹配:命中则覆盖该国家明细,未命中则新增;其它产品/国家保留。',
'同一产品下重复国家+重量段将整单失败。仅固定价套餐「按上一版 Excel 建价」可用。'
]
})
const typeHint = computed(() => {
if (isPullBuildMode(props.priceBuildMode)) return '· 按产品价建价'
if (isImportBuildMode(props.priceBuildMode)) return '· 按上一版 Excel 建价'
if (isImportFreshBuildMode(props.priceBuildMode)) return '· Excel 新建价'
if (hasBuildMode.value) return '· 当前:' + setTypeLabel(props.setType)
return '· 请选择创建价格方式'
})
// PULL: hide pull btn (already pulled), show modify-import.
// IMPORT / IMPORT_FRESH: show create-import.
const showPullAction = computed(() => false)
const showImportAction = computed(() => hasBuildMode.value && isFixedType(props.setType))
const emptyTip = computed(() => {
if (props.livePersist) return '暂无产品'
if (isPullBuildMode(props.priceBuildMode)) return '暂无产品,请先返回上一步重新拉取或等待拉取完成'
if (isImportBuildMode(props.priceBuildMode)) return '暂无上一版产品,请点击右上角导入 Excel 建价(按产品+国家覆盖或新增)'
if (isImportFreshBuildMode(props.priceBuildMode)) return '暂无产品,请点击右上角导入 Excel 建价'
return '暂无产品,请先选择创建价格方式'
})
/** Resolve version id: editing prop/local first; never replace with package EFFECT version. */
const resolvedPsvId = ref(props.psvId)
watch(() => props.psvId, v => { if (v != null && v !== '') resolvedPsvId.value = v })
function knownPsvId() {
if (props.psvId != null && props.psvId !== '') return props.psvId
if (resolvedPsvId.value != null && resolvedPsvId.value !== '') return resolvedPsvId.value
return null
}
async function resolvePsvId(force) {
const known = knownPsvId()
// Keep the version being edited. force must NOT switch to data.version (EFFECT).
if (known != null && !force) {
resolvedPsvId.value = known
return known
}
if (!props.priceSetId) return known
const res = await api.getPriceSet(props.priceSetId)
const data = res.data || {}
const versions = data.versions || []
if (known != null) {
const still = versions.find(v => v && String(v.psvId) === String(known))
if (still && still.psvId != null) {
resolvedPsvId.value = still.psvId
return still.psvId
}
}
// Only when no editing psvId (e.g. brand-new draft): fall back to package current.
const fromData = (data.version && data.version.psvId) ? data.version : null
const current = fromData || versions[0] || {}
if (current.psvId != null) {
resolvedPsvId.value = current.psvId
emit('update:psvId', current.psvId)
}
return resolvedPsvId.value || null
}
/** productCode -> detail rows (loaded on demand) */
const detailByProduct = reactive({})
function clearDetailCache() {
Object.keys(detailByProduct).forEach(k => delete detailByProduct[k])
}
/** Clear local product/detail draft when create wizard switches build mode. */
function clearLocalDraft() {
clearDetailCache()
if (Array.isArray(props.details)) {
props.details.splice(0, props.details.length)
}
if (Array.isArray(props.products)) {
props.products.splice(0, props.products.length)
}
priceVisible.value = false
editCode.value = ''
editName.value = ''
editRows.value = []
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 api.getPriceSet(props.priceSetId)
const data = res.data || {}
const versions = data.versions || []
// Only exclude the draft being edited. Do NOT use resolvedPsvId — it may
// point at EFFECT after resolvePsvId() and wrongly skip the real prior.
const prior = pickLatestPriorVersion(versions, props.psvId)
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 detailRes = await api.listSetDetailByVersion(prior.psvId)
applyLocalDraft(products, detailRes.data || [])
return true
} finally {
pulling.value = false
}
}
async function loadProductDetails(productCode, force) {
if (!productCode) return []
if (!force && detailByProduct[productCode]) return detailByProduct[productCode]
if (!props.livePersist) {
const rows = (props.details || []).filter(d => d && d.productCode === productCode)
detailByProduct[productCode] = rows
return rows
}
const psvId = await resolvePsvId(false)
if (!psvId) return []
const res = await api.listSetDetailByProduct(psvId, productCode)
const rows = (res.data || []).map(d => hydrateRow(d, props.setType))
detailByProduct[productCode] = rows
// keep parent details array in sync for any legacy callers
if (Array.isArray(props.details)) {
for (let i = props.details.length - 1; i >= 0; i--) {
if (props.details[i] && props.details[i].productCode === productCode) props.details.splice(i, 1)
}
props.details.push(...rows)
}
return rows
}
const productGroups = computed(() => {
return (props.products || []).filter(p => p && p.productCode).map(p => ({
productCode: p.productCode,
productName: p.productName || p.productCode,
serviceLevel: p.serviceLevel,
cargoAttr: p.cargoAttr,
destRegion: p.destRegion,
taxMode: p.taxMode,
rows: detailByProduct[p.productCode] || []
}))
})
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() {}
async function chooseBuildMode(mode) {
if (!mode || !canChooseBuildMode.value) return
if (mode === props.priceBuildMode) return
choosingMode.value = true
try {
emit('update:priceBuildMode', mode)
} finally {
choosingMode.value = false
}
}
let localKeySeq = 0
/** Apply preview products/details into parent reactive arrays (create defer mode). */
function mergeLocalProducts(existing, incoming) {
const map = new Map()
for (const p of existing || []) {
if (p && p.productCode) map.set(String(p.productCode).toUpperCase(), { ...p })
}
for (const p of incoming || []) {
if (!p || !p.productCode) continue
const key = String(p.productCode).toUpperCase()
const prev = map.get(key) || {}
const next = { ...prev, ...p }
map.set(key, next)
}
return Array.from(map.values())
}
function applyLocalDraft(products, details) {
clearDetailCache()
const hydrated = (details || []).map(d => {
const row = hydrateRow({ ...d }, props.setType)
row.psdId = undefined
row.pspId = undefined
row.priceSetId = undefined
row.psvId = undefined
row._localKey = 'L' + (++localKeySeq)
return row
})
if (Array.isArray(props.details)) {
props.details.splice(0, props.details.length)
hydrated.forEach(row => props.details.push(row))
}
if (Array.isArray(props.products)) {
props.products.splice(0, props.products.length)
;(products || []).forEach(p => props.products.push({ ...p }))
}
hydrated.forEach(d => {
if (!d || !d.productCode) return
if (!detailByProduct[d.productCode]) detailByProduct[d.productCode] = []
detailByProduct[d.productCode].push(d)
})
}
function regionLabel(item) {
const name = (item && (item.countryNameCn || item.countryNameEn)) || ''
const code = (item && item.countryCode2) || ''
return name && code ? name + ' (' + code + ')' : (name || code)
}
function loadRegionOptions() {
if (regionOptions.value.length) return
listRegionOptions().then(res => {
regionOptions.value = res.data || []
})
}
async function canOpenImport() {
// Edit live: mode change must be saved before import. Create draft: import is local preview.
if (props.livePersist && props.buildModeDirty) {
proxy.$modal.msgWarning("请先点「完成」保存建价方式")
return false
}
if (props.livePersist && !props.priceSetId) {
proxy.$modal.msgWarning('缺少套餐ID')
return false
}
if (!isFixedType(props.setType)) {
proxy.$modal.msgWarning('仅固定价套餐支持导入')
return false
}
if (props.livePersist) {
// Use the version being edited (props.psvId). Do not force-refresh to EFFECT.
const psvId = await resolvePsvId(false)
if (!psvId) {
proxy.$modal.msgWarning('缺少套餐版本,请先保存基本信息')
return false
}
}
return true
}
watch(
() => [onlyImportFresh.value, props.priceBuildMode, props.buildModeReady],
() => {
if (!onlyImportFresh.value || !props.buildModeReady) return
if (isImportFreshBuildMode(props.priceBuildMode)) return
if (buildModeLocked.value) return
emit('update:priceBuildMode', 'IMPORT_FRESH')
},
{ immediate: true }
)
async function openImport() {
if (!(await canOpenImport())) return
importRef.value && importRef.value.open()
}
function handleImportCommand(cmd) {
if (cmd === 'country') openCountryImport()
else openImport()
}
async function openCountryImport() {
if (!(await canOpenImport())) return
importCountryCode.value = ''
loadRegionOptions()
countryImportRef.value && countryImportRef.value.open()
}
function validateCountryImport() {
if (!importCountryCode.value) return '请先选择目的国家'
return ''
}
function unwrapUploadFiles(uploadFile) {
const list = Array.isArray(uploadFile) ? uploadFile : [uploadFile]
const out = []
for (const item of list) {
const raw = item && (item.raw || item)
if (raw) out.push(raw)
}
return out
}
async function runImportAsync(uploadFile, countryCode) {
const files = unwrapUploadFiles(uploadFile)
if (!files.length) {
throw new Error('请先选择文件')
}
const replaceByProductCountry = !isModifyImport.value || !!countryCode
if (!props.livePersist) {
const res = await api.previewImportDetailPrices(files, {
updateOnly: isModifyImport.value && !countryCode,
replaceByProductCountry,
countryCode: countryCode || undefined,
details: props.details || []
})
const data = res.data || {}
const ids = Array.isArray(data.pefIds) ? data.pefIds : (data.pefId != null ? [data.pefId] : [])
ids.forEach(id => emit('update:pefId', id))
const products = replaceByProductCountry
? mergeLocalProducts(props.products, data.products || [])
: (isModifyImport.value ? (props.products || []) : (data.products || []))
applyLocalDraft(products, data.details || [])
return data.message || '导入完成'
}
const { task } = await api.importSetDetailPricesAsync(
files,
{
priceSetId: props.priceSetId,
psvId: await resolvePsvId(false),
updateOnly: isModifyImport.value && !countryCode,
replaceByProductCountry,
countryCode: countryCode || undefined
}
)
return (task && task.message) ? task.message : '导入完成'
}
async function runCountryImportAsync(uploadFile) {
if (!importCountryCode.value) {
throw new Error('请先选择目的国家')
}
return runImportAsync(uploadFile, importCountryCode.value)
}
function applyBuildProgress(t) {
importProgressPercent.value = Math.min(100, Number(t.percent) || 0)
if (t.status === 'PENDING') importProgressMessage.value = '排队中…'
else if (t.status === 'RUNNING') {
const m = t.message || ''
if (m === 'parsing excel') importProgressMessage.value = '正在解析价表…'
else if (m === 'matching details') importProgressMessage.value = '正在匹配并更新价格…'
else if (m === 'pulling library') importProgressMessage.value = '正在拉取产品库…'
else if (m === 'finishing') importProgressMessage.value = '收尾中…'
else if (m === 'queued') importProgressMessage.value = '排队中…'
else if (m.indexOf('importing ') === 0) importProgressMessage.value = '正在导入产品 ' + m.slice('importing '.length)
else if (m.indexOf('saving details ') === 0) importProgressMessage.value = '正在保存明细 ' + m.slice('saving details '.length)
else if (m === 'details exist, skip pull') importProgressMessage.value = '明细已存在,已跳过拉取'
else if (m === 'saving surcharges') importProgressMessage.value = '正在保存附加费…'
else importProgressMessage.value = m || '处理中…'
} else if (t.status === 'SUCCESS') {
importProgressMessage.value = t.message || '完成'
importProgressStatus.value = 'success'
} else if (t.status === 'FAIL') {
importProgressMessage.value = t.error || t.message || '失败'
importProgressStatus.value = 'exception'
}
}
async function onImportSuccess() {
if (!props.livePersist) return
await reloadDetailsFromServer()
}
async function reloadDetailsFromServer() {
if (!props.priceSetId) return
try {
clearDetailCache()
const res = await api.getPriceSet(props.priceSetId)
const data = res.data || {}
const versions = data.versions || []
const qPsv = resolvedPsvId.value || props.psvId
const byQuery = qPsv != null ? versions.find(v => String(v.psvId) === String(qPsv)) : null
const fromData = (data.version && data.version.psvId) ? data.version : null
const current = byQuery || fromData || versions[0] || {}
if (current.psvId != null) {
resolvedPsvId.value = current.psvId
emit('update:psvId', current.psvId)
}
if (Array.isArray(props.details)) {
props.details.splice(0, props.details.length)
}
if (Array.isArray(props.products)) {
props.products.splice(0, props.products.length, ...(current.products || []).map(p => ({ ...p })))
}
if (priceVisible.value && editCode.value) {
const rows = await loadProductDetails(editCode.value, true)
editRows.value = rows
editGroup.value = productGroups.value.find(p => p.productCode === editCode.value) || null
}
} catch (e) {}
}
async function handlePullLibrary() {
if (props.livePersist && !props.priceSetId) {
proxy.$modal.msgWarning('缺少套餐ID')
return
}
const hasProducts = (props.products || []).some(p => p && p.productCode)
let force = false
if (hasProducts) {
try {
await proxy.$modal.confirm('当前版本已有明细,拉取将清空并按产品库重建,是否继续?')
force = true
} catch (e) {
return
}
}
await pullAllFromLibrary({ force, silent: false })
}
/** Pull product library: live -> async DB; create defer -> preview into local state. */
async function pullAllFromLibrary(opts = {}) {
const silent = !!opts.silent
const force = !!opts.force || !!opts.replace
if (props.livePersist && !props.priceSetId) {
if (!silent) proxy.$modal.msgWarning('缺少套餐ID')
return { added: 0 }
}
progressDialogTitle.value = '正在拉取产品列表'
importProgressVisible.value = true
importProgressPercent.value = 0
importProgressMessage.value = '排队中…'
importProgressStatus.value = undefined
pulling.value = true
try {
if (!props.livePersist) {
importProgressMessage.value = '正在拉取产品库…'
importProgressPercent.value = 40
const res = await api.previewPullFromLibrary()
const data = res.data || {}
applyLocalDraft(data.products || [], data.details || [])
importProgressPercent.value = 100
importProgressStatus.value = 'success'
if (!silent) {
proxy.$modal.msgSuccess(data.message || '拉取完成')
}
return { added: (props.products || []).length }
}
const { task } = await api.pullSetDetailsFromLibraryAsync(
{ priceSetId: props.priceSetId, psvId: await resolvePsvId(false), force },
applyBuildProgress
)
importProgressPercent.value = 100
importProgressStatus.value = 'success'
await reloadDetailsFromServer()
if (!silent) {
proxy.$modal.msgSuccess((task && task.message) ? task.message : '拉取完成')
}
return { added: (props.products || []).length }
} catch (e) {
importProgressStatus.value = 'exception'
const tip = (e && e.task && e.task.error) || (e && e.message) || '拉取失败'
importProgressMessage.value = tip
if (!silent) proxy.$modal.msgError(tip)
await new Promise(resolve => setTimeout(resolve, 600))
return { added: 0 }
} finally {
pulling.value = false
importProgressVisible.value = false
}
}
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.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)
}
async function openPrice(row) {
if (isDiscountType(props.setType)) {
discCode.value = row.productCode
discTitle.value = row.productCode + ' · ' + (row.productName || '') + ' · 产品折扣'
try {
const rows = await loadProductDetails(row.productCode)
const hit = rows[0]
discValue.value = hit && hit.discountValue != null ? Number(hit.discountValue) : undefined
} catch (e) {
discValue.value = undefined
}
discVisible.value = true
return
}
editCode.value = row.productCode
editName.value = row.productName || ''
editGroup.value = row
try {
editRows.value = await loadProductDetails(row.productCode)
priceVisible.value = true
} catch (e) {
proxy.$modal.msgError((e && e.message) ? e.message : '加载产品价格失败')
}
}
/** list-row surcharge: load that product details first */
async function openSurchargeForProduct(row) {
try {
const rows = await loadProductDetails(row.productCode)
await openSurcharge({ ...row, rows })
} catch (e) {
proxy.$modal.msgError((e && e.message) ? e.message : '加载产品明细失败')
}
}
/** 抽屉里点附加费:带行 = 行级(只改该行,国家+重量段),不带 = 产品级(覆盖全部明细行) */
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 (edit mode only) */
async function persistDetail(row, withSurcharge) {
if (!props.livePersist) return
if (!row || !row.psdId || !props.priceSetId) return
const payload = toDetailPayload(row)
payload.priceSetId = props.priceSetId
if (!withSurcharge) delete payload.surcharges
await api.updateSetDetail(payload)
}
/** drawer hover remove surcharge -> write snapshot for this row */
async function onRowSurchargeChange(row) {
if (!row) return
if (!props.livePersist) {
proxy.$modal.msgSuccess('附加费已移除(完成时统一落库)')
return
}
if (!row.psdId) return
try {
await persistDetail(row, true)
proxy.$modal.msgSuccess('附加费已移除')
} catch (e) {
proxy.$modal.msgError((e && e.message) ? e.message : '附加费移除失败')
}
}
/** drawer field change -> immediate DB update (edit) or local only (create) */
async function onRowPriceChange(row) {
if (!row) return
if (!props.livePersist) {
proxy.$modal.msgSuccess('价格已更新(完成时统一落库)')
return
}
if (!row.psdId) {
proxy.$modal.msgWarning('明细尚未落库,请先点「完成」保存后再改价')
return
}
const prevFv = row._editPrevFv
const prevReg = row._editPrevReg
try {
await persistDetail(row, false)
proxy.$modal.msgSuccess('价格已更新')
} catch (e) {
if (prevFv !== undefined) row.fvValue = prevFv
if (prevReg !== undefined) row.fvRegistrationFee = prevReg
proxy.$modal.msgError((e && e.message) ? e.message : '价格更新失败')
}
}
async function saveDiscount() {
const hit = (props.details || []).find(d => d.productCode === discCode.value)
if (hit) {
hit.discountValue = discValue.value
if (!props.livePersist) {
proxy.$modal.msgSuccess('折扣已更新(完成时统一落库)')
} else {
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
}
const okText = ids.size
? (skipped > 0
? `已纳入 ${applied} 项附加费${scopeText};${skipped} 项因适用国家不匹配被跳过`
: `已纳入 ${ids.size} 项附加费${scopeText}`)
: `已清空附加费${scopeText}`
scVisible.value = false
if (!props.livePersist) {
proxy.$modal.msgSuccess(okText + '(完成时统一落库)')
return
}
const payload = targets.filter(r => r.psdId).map(r => toDetailPayload(r)).map(p => {
p.priceSetId = props.priceSetId
return p
})
if (!payload.length) {
proxy.$modal.msgSuccess(okText)
return
}
proxy.$modal.msgSuccess(okText + ',正在后台写入…')
api.updateSetDetailSurchargeAsync(payload).then(() => {
proxy.$modal.msgSuccess(okText + ',已写入数据库')
}).catch(e => {
proxy.$modal.msgError((e && e.message) ? e.message : '附加费后台写入失败')
})
}
async function removeProduct(code) {
if (props.livePersist) {
try {
const rows = await loadProductDetails(code)
const ids = rows.filter(d => d && d.psdId).map(d => d.psdId)
if (ids.length) {
await api.delSetDetail(ids.join(','))
}
} catch (e) {
return
}
}
delete detailByProduct[code]
if (Array.isArray(props.details)) {
for (let i = props.details.length - 1; i >= 0; i--) {
if (props.details[i] && props.details[i].productCode === code) props.details.splice(i, 1)
}
}
if (Array.isArray(props.products)) {
for (let i = props.products.length - 1; i >= 0; i--) {
if (props.products[i] && props.products[i].productCode === code) props.products.splice(i, 1)
}
}
proxy.$modal.msgSuccess('已移除产品明细')
}
defineExpose({ pullAllFromLibrary, clearLocalDraft, loadLatestPriorVersionDraft })
</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; }
.import-progress {
padding: 8px 4px 0;
}
.import-progress-msg {
margin-top: 12px;
color: #606266;
font-size: 13px;
word-break: break-all;
}
.build-choice {
padding: 8px 0 16px;
text-align: left;
}
.build-choice__tip {
margin: 0 0 12px;
color: #606266;
font-size: 13px;
font-weight: 600;
}
.build-choice__cards {
display: flex;
justify-content: flex-start;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.build-card {
width: 260px;
min-height: 88px;
padding: 14px 16px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
cursor: pointer;
text-align: left;
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
}
.build-card.selected {
border-color: #1890ff;
background: #e6f7ff;
}
.build-card:hover:not(:disabled) {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
}
.build-card:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.build-card--pull:hover:not(:disabled) { border-color: #67c23a; }
.build-card--import:hover:not(:disabled) { border-color: #409eff; }
.build-card--import-fresh:hover:not(:disabled) { border-color: #e6a23c; }
.build-card__title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 10px;
}
.build-card--pull .build-card__title { color: #67c23a; }
.build-card--import .build-card__title { color: #409eff; }
.build-card--import-fresh .build-card__title { color: #e6a23c; }
.build-card__desc {
font-size: 13px;
line-height: 1.6;
color: #909399;
}
.card-actions {
flex: none;
display: flex;
align-items: center;
gap: 8px;
}
.filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.unit {
margin-left: 8px;
color: #909399;
}
</style>