重构产品价和红线价功能

This commit is contained in:
wuweihua
2026-10-18 14:16:27 +08:00
parent db6f5e882a
commit 60d4614695
84 changed files with 6753 additions and 2843 deletions
@@ -0,0 +1,160 @@
<!-- start catalog version: pick effective hour -->
<template>
<el-dialog v-model="visible" :title="dialogTitle" width="460px" append-to-body @closed="onClosed">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item :label="labNo">
<span>{{ form.versionNo || '-' }}</span>
</el-form-item>
<el-form-item :label="labEff" prop="effectiveTime">
<el-date-picker
v-model="form.effectiveTime"
type="datetime"
format="YYYY-MM-DD HH:00"
value-format="YYYY-MM-DD HH:00:00"
popper-class="activate-hour-picker"
:placeholder="ph"
:disabled-date="disabledDate"
:disabled-hours="disabledHours"
style="width: 100%"
@change="onTimeChange"
/>
</el-form-item>
<div class="activate-hint">{{ hint }}</div>
</el-form>
<template #footer>
<el-button @click="visible = false">{{ btnCancel }}</el-button>
<el-button type="primary" :loading="submitting" @click="submit">{{ btnOk }}</el-button>
</template>
</el-dialog>
</template>
<script setup>
const props = defineProps({
loadVersions: { type: Function, required: true },
activateFn: { type: Function, required: true }
})
const emit = defineEmits(['success'])
const { proxy } = getCurrentInstance()
const visible = ref(false)
const submitting = ref(false)
const formRef = ref()
const versions = ref([])
const prevEff = ref()
const form = reactive({ psvId: undefined, versionNo: '', versionSeq: 0, effectiveTime: undefined })
const dialogTitle = '启动版本'
const labNo = '版本号'
const labEff = '生效时间'
const ph = '请选择生效时间'
const btnCancel = '取消'
const btnOk = '确认启动'
const beforePrev = '生效时间不能早于上一版本的生效时间'
const hint = computed(() => {
const base = '若生效时间晚于当前,版本将进入待生效;到时自动生效并使上一版本失效。'
return prevEff.value ? (base + '不得早于上一版:' + prevEff.value + '。') : base
})
const rules = {
effectiveTime: [
{ required: true, message: ph, trigger: 'change' },
{
validator: (_r, v, cb) => {
if (!v || !prevEff.value) return cb()
if (toMs(v) < toMs(prevEff.value)) return cb(new Error(beforePrev))
cb()
},
trigger: 'change'
}
]
}
function pad2(n) { return String(n).padStart(2, '0') }
function formatHour(d) {
if (!d || Number.isNaN(d.getTime())) return undefined
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) + ' ' + pad2(d.getHours()) + ':00:00'
}
function normalizeToHour(val) {
if (val == null || val === '') return val
if (val instanceof Date) return formatHour(val)
const s = String(val).trim().replace('T', ' ')
const m = s.match(/^(\d{4}-\d{2}-\d{2})[ ](\d{2})/)
if (m) return m[1] + ' ' + m[2] + ':00:00'
return formatHour(new Date(s.replace(/-/g, '/'))) || val
}
function toMs(val) {
const n = normalizeToHour(val)
if (!n) return NaN
return new Date(String(n).replace(/-/g, '/')).getTime()
}
function findPrev(row) {
const cur = Number(row && row.versionSeq) || 0
let prev = null
for (const v of versions.value || []) {
if (!v || v.psvId == null || String(v.psvId) === String(row.psvId)) continue
const vs = Number(v.versionSeq) || 0
if (vs >= cur || !v.effectiveTime) continue
if (!prev || vs > (Number(prev.versionSeq) || 0)) prev = v
}
return prev ? normalizeToHour(prev.effectiveTime) : undefined
}
function pickDefault(prev) {
const now = formatHour(new Date())
if (!prev) return now
return toMs(now) >= toMs(prev) ? now : prev
}
function disabledDate(date) {
if (!prevEff.value || !date) return false
const prevDay = new Date(String(prevEff.value).replace(/-/g, '/'))
prevDay.setHours(0, 0, 0, 0)
const d = new Date(date)
d.setHours(0, 0, 0, 0)
return d.getTime() < prevDay.getTime()
}
function disabledHours() {
if (!prevEff.value) return []
const prevD = new Date(String(prevEff.value).replace(/-/g, '/'))
const curD = form.effectiveTime
? new Date(String(normalizeToHour(form.effectiveTime)).replace(/-/g, '/'))
: new Date()
if (prevD.getFullYear() !== curD.getFullYear() || prevD.getMonth() !== curD.getMonth() || prevD.getDate() !== curD.getDate()) return []
const hours = []
for (let h = 0; h < prevD.getHours(); h++) hours.push(h)
return hours
}
function onTimeChange(val) {
form.effectiveTime = normalizeToHour(val)
formRef.value && formRef.value.validateField('effectiveTime')
}
async function open(row) {
if (!row || row.psvId == null) return
const list = await props.loadVersions()
versions.value = list || []
const found = versions.value.find(v => String(v.psvId) === String(row.psvId)) || row
form.psvId = found.psvId
form.versionNo = found.versionNo || ''
form.versionSeq = found.versionSeq
prevEff.value = findPrev(found)
form.effectiveTime = pickDefault(prevEff.value)
visible.value = true
}
function onClosed() {
form.psvId = undefined
form.versionNo = ''
form.effectiveTime = undefined
prevEff.value = undefined
formRef.value && formRef.value.clearValidate()
}
function submit() {
formRef.value.validate(valid => {
if (!valid) return
form.effectiveTime = normalizeToHour(form.effectiveTime)
submitting.value = true
props.activateFn(form.psvId, { effectiveTime: form.effectiveTime }).then(() => {
const pending = form.effectiveTime && new Date(String(form.effectiveTime).replace(/-/g, '/')).getTime() > Date.now()
proxy.$modal.msgSuccess(pending ? '已设为待生效' : '版本已启动')
visible.value = false
emit('success')
}).finally(() => { submitting.value = false })
})
}
defineExpose({ open })
</script>
<style scoped>
.activate-hint { color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; }
</style>
@@ -0,0 +1,689 @@
<template>
<div class="app-container" v-loading="pageLoading">
<el-card shadow="never" class="step-card">
<template #header>
<div class="card-head">
<span>
{{ headerTitle }}
<span v-if="versionNo" class="sub">· {{ labVer }} {{ versionNo }}</span>
</span>
<div class="card-actions">
<el-dropdown trigger="click" @command="handleImportCommand">
<el-button type="primary" size="small" :disabled="!buildMode || importing || finishing">
{{ labImport }}
<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="all">{{ labImportAll }}</el-dropdown-item>
<el-dropdown-item command="country">{{ labImportCtry }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<div class="build-choice">
<p class="build-choice__tip">{{ labTip }}</p>
<div class="build-choice__cards">
<button
type="button"
class="build-card build-card--pull"
:class="{ selected: buildMode === 'COPY' }"
:disabled="!!psvId || choosing || finishing"
@click="chooseMode(true)"
>
<div class="build-card__title">{{ labModeCopy }}</div>
<div class="build-card__desc">{{ labModeCopyDesc }}</div>
</button>
<button
type="button"
class="build-card build-card--import"
:class="{ selected: buildMode === 'IMPORT' }"
:disabled="!!psvId || choosing || finishing"
@click="chooseMode(false)"
>
<div class="build-card__title">{{ labModeFresh }}</div>
<div class="build-card__desc">{{ labModeFreshDesc }}</div>
</button>
</div>
</div>
<el-alert
v-if="pendingImports.length"
type="warning"
:closable="false"
show-icon
class="mb12"
:title="pendingImportTip"
/>
<div class="filters">
<div class="filters__left">
<el-input v-model="keyword" clearable :placeholder="labKw" style="width: 220px" />
<el-button size="small" @click="noop">{{ labQuery }}</el-button>
</div>
<div class="filters__count">{{ productCountText }}</div>
</div>
<el-empty v-if="!filteredRows.length" :description="labEmpty" :image-size="80" />
<el-table v-else :data="filteredRows" border>
<el-table-column :label="labCode" min-width="100">
<template #default="{ row }"><b>{{ row.productCode }}</b></template>
</el-table-column>
<el-table-column :label="labName" prop="productName" min-width="140" show-overflow-tooltip />
<el-table-column :label="labLevel" prop="serviceLevel" width="90" align="center" />
<el-table-column :label="labCargo" min-width="140" 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="labRegion" prop="destRegion" width="90" align="center" />
<el-table-column :label="labAction" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button v-if="row.productId || row.productCode" link type="primary" @click="openCountry(row)">{{ labDetail }}</el-button>
<el-button v-if="row.productId || row.productCode" link type="primary" @click="openSurcharge(row)">{{ labSurcharge }}</el-button>
<el-button link type="danger" @click="removeRow(row)">{{ labRemove }}</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<div class="wizard-actions">
<el-button @click="goBack">{{ labCancel }}</el-button>
<el-button type="primary" :loading="finishing" @click="finishSave">{{ labFinish }}</el-button>
</div>
<component :is="countryComp" ref="countryRef" />
<component :is="surchargeComp" ref="surchargeRef" @success="loadProducts" />
<excel-import-dialog
ref="importRef"
:title="titleAll"
width="560px"
:show-update-support="false"
template-url="/templates/product/product-import-all-template.xlsx"
:template-file-name="tplAll"
:import-desc="allImportDesc"
:file-limit="5"
:submit-handler="runImportAll"
@success="onImportOk"
/>
<excel-import-dialog
ref="countryImportRef"
:title="titleCtry"
width="560px"
:show-update-support="false"
:before-submit="validateCountryImport"
:import-desc="countryImportDesc"
template-url="/templates/product/product-import-country-template.xlsx"
:template-file-name="tplCtry"
:file-limit="5"
:submit-handler="runImportCountry"
@success="onImportOk"
>
<template #extra>
<el-form label-width="90px">
<el-form-item :label="labCountry" required>
<el-select v-model="importCountryCode" filterable clearable :placeholder="labPickCountry" 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="progressTitle"
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>
</div>
</template>
<script setup>
import ExcelImportDialog from '@/components/ExcelImportDialog'
import { listRegionOptions } from '@/api/data/region'
const props = defineProps({
kind: { type: String, required: true },
listPath: { type: String, required: true },
listFn: { type: Function, required: true },
delFn: { type: Function, required: true },
prepareFn: { type: Function, required: true },
applyPreviewFn: { type: Function, required: true },
attachExcelFn: { type: Function, required: true },
importAllFn: { type: Function, required: true },
importCountryFn: { type: Function, required: true },
previewAllFn: { type: Function, required: true },
previewCountryFn: { type: Function, required: true },
countryComp: { type: [Object, String], required: true },
surchargeComp: { type: [Object, String], required: true }
})
const { proxy } = getCurrentInstance()
const router = useRouter()
const route = useRoute()
const labTip = '创建价格方式'
const labModeCopy = '按现有生效版本建价'
const labModeCopyDesc = '先本地预览已生效版本产品,点击「完成」时再复制到待生效草稿'
const labModeFresh = '重新建产品价'
const labModeFreshDesc = "不沿用已生效版本;导入解析后先展示,点「完成」时再写入草稿"
const labImport = '导入 Excel 建价'
const labImportAll = '全部导入(多国家价表)'
const labImportCtry = '按国家导入(单国专线成本价)'
const labKw = '产品代码/名称'
const labQuery = '查询'
const labEmpty = '请先选择建价方式,或导入 Excel'
const labCode = '产品代码'
const labName = '产品名称'
const labLevel = '时效等级'
const labCargo = '走货属性'
const labRegion = '目的区域'
const labAction = '操作'
const labDetail = "明细"
const labSurcharge = "附加费"
const labRemove = '移除'
const labCancel = '取消'
const labFinish = '完成'
const labVer = '版本'
const labCountry = '目的国家'
const labPickCountry = '请选择国家'
const labImporting = '正在导入'
const labSaving = '正在保存'
const tplAll = '产品多国家价表导入模板'
const tplCtry = '产品按国家导入模板'
const titleAll = props.kind === 'product' ? '产品 Excel 导入' : '红线价 Excel 导入'
const titleCtry = '按国家导入'
const headerTitle = props.kind === 'product' ? '编辑产品价格' : '编辑红线价'
const allImportDesc = ['适用多国家价表,按目录 sheet 匹配国家导入。', '导入不选生效时间,数据落到待生效草稿版本。', '请在弹窗底部下载模板。']
const countryImportDesc = ['选择目的国家后上传专线成本价表。', '导入不选生效时间,数据落到待生效草稿版本。', '邮编、赔偿标准等 sheet 不导入。']
const pageLoading = ref(false)
const progressTitle = ref(labImporting)
const choosing = ref(false)
const importing = ref(false)
const psvId = ref()
const versionNo = ref('')
const buildMode = ref('')
const rows = ref([])
const keyword = ref('')
const countryRef = ref()
const surchargeRef = ref()
const importRef = ref()
const countryImportRef = ref()
const importCountryCode = ref('')
const regionOptions = ref([])
const importProgressVisible = ref(false)
const importProgressPercent = ref(0)
const importProgressMessage = ref('')
const importProgressStatus = ref(undefined)
const finishing = ref(false)
const localRemoves = ref([])
const pendingImports = ref([])
const previewDetailsByCode = ref({})
const pendingImportTip = computed(() => {
const n = pendingImports.value.length
if (!n) return ''
return "已解析展示,暂存 " + n + " 个导入文件;点「完成」后一并写入草稿"
})
const filteredRows = computed(() => {
const kw = (keyword.value || '').trim().toLowerCase()
if (!kw) return rows.value || []
return (rows.value || []).filter(r => {
const blob = [r.productCode, r.productName].filter(Boolean).join(' ').toLowerCase()
return blob.includes(kw)
})
})
const productCountText = computed(() => {
const n = filteredRows.value.length
const total = (rows.value || []).length
if (n === total) {
return "共 " + n + " 个产品"
}
return "共 " + n + " 个产品(全部 " + total + " 个)"
})
function noop() {}
function isRoutable(path) {
if (!path) return false
const resolved = router.resolve(path)
const matched = resolved.matched || []
if (!matched.length) return false
return !matched.some(m => String(m.path || '').includes('pathMatch'))
}
function goBack() {
const path = props.listPath || (router.currentRoute.value.meta && router.currentRoute.value.meta.activeMenu)
const obj = isRoutable(path) ? { path } : undefined
if (proxy.$tab) {
if (obj) proxy.$tab.closeOpenPage(obj)
else proxy.$tab.closePage()
return
}
if (obj) router.push(obj)
else router.back()
}
function applyVersion(ver) {
if (!ver) {
psvId.value = undefined
versionNo.value = ''
return
}
psvId.value = ver.psvId
versionNo.value = ver.versionNo || ''
const mode = String(ver.priceBuildMode || '').toUpperCase()
if (mode === 'COPY') buildMode.value = 'COPY'
else if (mode === 'IMPORT' || mode === 'PULL') buildMode.value = 'IMPORT'
}
async function loadProducts() {
if (!psvId.value) {
rows.value = []
return
}
const res = await props.listFn({ psvId: psvId.value, pageNum: 1, pageSize: 500 })
rows.value = res.rows || res.data || []
}
async function loadEffectPreview() {
const res = await props.listFn({ statusGroup: 'effect', pageNum: 1, pageSize: 500 })
rows.value = (res.rows || res.data || []).map(r => ({ ...r, _preview: true }))
}
async function bootstrap() {
pageLoading.value = true
try {
// Only resume a draft when opened from pending list "Edit" (?psvId=).
// Plain "New" must stay empty so build-mode cards stay selectable.
const qPsv = route.query && route.query.psvId
if (qPsv != null && qPsv !== '') {
psvId.value = Number(qPsv)
versionNo.value = (route.query && route.query.versionNo) ? String(route.query.versionNo) : ''
if (!buildMode.value) buildMode.value = 'IMPORT'
await loadProducts()
}
} finally {
pageLoading.value = false
}
}
/** Mode select: local only, no DB write. */
async function chooseMode(copyFromLatest) {
if (psvId.value || choosing.value || finishing.value) return
const next = copyFromLatest ? 'COPY' : 'IMPORT'
if (buildMode.value === next && (copyFromLatest ? rows.value.length : true)) {
if (copyFromLatest && rows.value.length) return
if (!copyFromLatest && !rows.value.length && !pendingImports.value.length) return
}
choosing.value = true
pageLoading.value = true
try {
buildMode.value = next
localRemoves.value = []
pendingImports.value = []
previewDetailsByCode.value = {}
if (copyFromLatest) {
await loadEffectPreview()
} else {
rows.value = []
}
} finally {
choosing.value = false
pageLoading.value = false
}
}
function openCountry(row) {
if (!row || !(row.productId || row.productCode)) return
const code = String(row.productCode || '').toUpperCase()
const preview = previewDetailsByCode.value && previewDetailsByCode.value[code]
if ((!row.productId || row._preview) && Array.isArray(preview)) {
countryRef.value && countryRef.value.open(row, preview)
return
}
countryRef.value && countryRef.value.open(row)
}
function openSurcharge(row) {
if (!row || !(row.productId || row.productCode)) return
surchargeRef.value && surchargeRef.value.open(row)
}
function removeRow(row) {
if (!row) return
const code = row.productCode
if (psvId.value && row.productId != null) {
proxy.$modal.confirm('确认从草稿中移除该产品?').then(() => props.delFn(row.productId)).then(() => {
proxy.$modal.msgSuccess('移除成功')
loadProducts()
}).catch(() => {})
return
}
rows.value = (rows.value || []).filter(r => r !== row && r.productCode !== code)
if (code) {
const up = String(code).toUpperCase()
if (previewDetailsByCode.value && previewDetailsByCode.value[up]) {
const next = { ...previewDetailsByCode.value }
delete next[up]
previewDetailsByCode.value = next
}
if (!localRemoves.value.includes(code)) {
localRemoves.value = localRemoves.value.concat([code])
}
}
}
function regionLabel(item) {
const name = item.countryNameCn || item.countryNameEn || ''
const code = item.countryCode2 || ''
return name && code ? name + ' (' + code + ')' : (name || code)
}
function resolveImportCountryName(code) {
const hit = (regionOptions.value || []).find(r => String(r.countryCode2 || '').toUpperCase() === String(code || '').toUpperCase())
if (!hit) return code || ''
return hit.countryNameCn || hit.countryNameEn || hit.countryCode2 || code || ''
}
function loadRegionOptions() {
if (regionOptions.value.length) return
listRegionOptions().then(res => { regionOptions.value = res.data || [] })
}
async function persistDraft() {
if (psvId.value) return true
if (!buildMode.value) {
proxy.$modal.msgWarning('请先选择建价方式')
return false
}
const res = await props.prepareFn(buildMode.value === 'COPY')
applyVersion(res.data)
return !!psvId.value
}
async function applyLocalRemoves() {
const codes = localRemoves.value || []
if (!codes.length || !psvId.value) return
const res = await props.listFn({ psvId: psvId.value, pageNum: 1, pageSize: 500 })
const list = res.rows || res.data || []
for (const code of codes) {
const hit = list.find(r => r && String(r.productCode) === String(code) && r.productId != null)
if (hit) {
await props.delFn(hit.productId).catch(() => {})
}
}
localRemoves.value = []
}
/** Write in-memory preview details into draft (avoids re-import requiring effectiveDate). */
async function applyPendingPreview(onProgress) {
const src = previewDetailsByCode.value || {}
const detailsByProductCode = {}
for (const [k, v] of Object.entries(src)) {
const code = String(k || '').toUpperCase()
if (!code || !Array.isArray(v) || !v.length) continue
detailsByProductCode[code] = v
}
const codes = Object.keys(detailsByProductCode)
if (!codes.length) {
return
}
if (!psvId.value) {
throw new Error('missing draft psvId')
}
const metaMap = new Map()
for (const r of rows.value || []) {
const code = String((r && r.productCode) || '').toUpperCase()
if (code) metaMap.set(code, r)
}
const products = codes.map(code => {
const r = metaMap.get(code) || {}
return {
productCode: r.productCode || code,
productName: r.productName,
serviceLevel: r.serviceLevel,
cargoAttr: r.cargoAttr,
destRegion: r.destRegion,
taxMode: r.taxMode
}
})
await props.applyPreviewFn({
psvId: psvId.value,
products,
detailsByProductCode
}, onProgress)
}
/** Persist staged Excel files onto draft version (prc_price_excel_file). */
async function savePendingExcelFiles() {
const jobs = pendingImports.value.slice()
pendingImports.value = []
if (!jobs.length || !psvId.value || typeof props.attachExcelFn !== 'function') return
for (const job of jobs) {
if (!job || !job.file) continue
await props.attachExcelFn(psvId.value, job.file)
}
}
async function finishSave() {
if (finishing.value) return
if (!buildMode.value) {
proxy.$modal.msgWarning('请先选择建价方式')
return
}
finishing.value = true
progressTitle.value = labSaving
importProgressVisible.value = true
importProgressPercent.value = 0
importProgressMessage.value = ''
importProgressStatus.value = undefined
try {
const ok = await persistDraft()
if (!ok) return
await applyPendingPreview(applyImportProgress)
await savePendingExcelFiles()
await applyLocalRemoves()
proxy.$modal.msgSuccess('已保存到待生效草稿')
goBack()
} catch (e) {
const msg = (e && e.message) || (e && e.msg) || 'save failed'
if (proxy.$modal && proxy.$modal.msgError) proxy.$modal.msgError(msg)
} finally {
importProgressVisible.value = false
finishing.value = false
pageLoading.value = false
}
}
function handleImportCommand(command) {
if (!buildMode.value) {
proxy.$modal.msgWarning('请先选择建价方式')
return
}
if (command === 'country') {
importCountryCode.value = ''
loadRegionOptions()
countryImportRef.value.open()
return
}
importRef.value.open()
}
function validateCountryImport() {
if (!importCountryCode.value) return '请先选择目的国家'
return ''
}
function applyImportProgress(t) {
importProgressPercent.value = Math.min(100, Number(t.percent) || 0)
importProgressMessage.value = t.message || t.error || ''
if (t.status === 'SUCCESS') importProgressStatus.value = 'success'
else if (t.status === 'FAIL') importProgressStatus.value = 'exception'
}
async function runImportWithProgress(runner) {
importing.value = true
importProgressVisible.value = true
importProgressPercent.value = 0
importProgressMessage.value = ''
importProgressStatus.value = undefined
try {
const { task } = await runner(applyImportProgress)
importProgressPercent.value = 100
importProgressStatus.value = 'success'
return (task && task.message) ? task.message : 'ok'
} catch (e) {
importProgressStatus.value = 'exception'
importProgressMessage.value = (e && e.task && e.task.error) || (e && e.message) || 'fail'
await new Promise(r => setTimeout(r, 600))
throw e
} finally {
importProgressVisible.value = false
importing.value = false
}
}
function mergePreviewDetails(detailsByCode) {
if (!detailsByCode || typeof detailsByCode !== 'object') return
const next = { ...(previewDetailsByCode.value || {}) }
for (const [k, v] of Object.entries(detailsByCode)) {
const code = String(k || '').toUpperCase()
if (!code || !Array.isArray(v)) continue
next[code] = v
}
previewDetailsByCode.value = next
}
function mergePreviewProducts(list, detailsByCode) {
mergePreviewDetails(detailsByCode)
const map = new Map()
for (const r of rows.value || []) {
const code = String((r && r.productCode) || '').toUpperCase()
if (code) map.set(code, r)
}
for (const p of list || []) {
if (!p || !p.productCode) continue
const code = String(p.productCode).toUpperCase()
localRemoves.value = (localRemoves.value || []).filter(c => String(c).toUpperCase() !== code)
map.set(code, {
productCode: p.productCode,
productName: p.productName,
serviceLevel: p.serviceLevel,
cargoAttr: p.cargoAttr,
destRegion: p.destRegion,
_preview: true
})
}
rows.value = Array.from(map.values())
}
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 runImportAll(uploadFile) {
const files = unwrapUploadFiles(uploadFile)
if (!files.length) throw new Error("请先选择文件")
const res = await props.previewAllFn(files, (t) => {
importRef.value && importRef.value.setProgress && importRef.value.setProgress(t && t.percent)
})
const data = (res && res.data) ? res.data : {}
mergePreviewProducts(data.products || [], data.detailsByProductCode)
pendingImports.value = pendingImports.value.concat(files.map(raw => ({ kind: 'all', file: raw })))
const shown = (rows.value || []).length
return "解析成功,共 " + files.length + " 个文件,已展示 " + shown + " 个产品,点「完成」后写入草稿"
}
async function runImportCountry(uploadFile) {
const files = unwrapUploadFiles(uploadFile)
if (!files.length) throw new Error("请先选择文件")
const err = validateCountryImport()
if (err) throw new Error(err)
const code = importCountryCode.value
const countryName = resolveImportCountryName(code)
const res = await props.previewCountryFn(files, code, (t) => {
countryImportRef.value && countryImportRef.value.setProgress && countryImportRef.value.setProgress(t && t.percent)
})
const data = (res && res.data) ? res.data : {}
const products = (data.products || []).map(p => ({
...p,
destRegion: p.destRegion || countryName
}))
mergePreviewProducts(products, data.detailsByProductCode)
pendingImports.value = pendingImports.value.concat(files.map(raw => ({ kind: 'country', file: raw, countryCode: code })))
const shown = (rows.value || []).length
return "解析成功,共 " + files.length + " 个文件,已展示 " + shown + " 个产品,点「完成」后写入草稿"
}
function onImportOk() {
// preview already merged into rows; finish will persist
}
onMounted(() => { bootstrap() })
</script>
<style scoped>
.step-card { margin-bottom: 16px; }
.mb12 { margin-bottom: 12px; }
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.sub { margin-left: 8px; color: #909399; font-size: 13px; font-weight: 400; }
.card-actions { display: flex; align-items: center; gap: 8px; }
.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: 280px; 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__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__desc { font-size: 13px; line-height: 1.6; color: #909399; }
.filters { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.filters__left { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.filters__count { color: #909399; font-size: 13px; white-space: nowrap; }
.wizard-actions { display: flex; justify-content: center; gap: 12px; padding: 8px 0 24px; }
.import-progress { padding: 8px 4px 4px; }
.import-progress-msg {
margin-top: 12px; color: var(--el-text-color-secondary); font-size: 13px;
line-height: 1.5; word-break: break-word; max-height: 160px; overflow: auto;
}
</style>
@@ -0,0 +1,82 @@
<template>
<el-drawer v-model="visible" size="860px" :title="title" append-to-body>
<el-table v-loading="loading" :data="list" border max-height="calc(100vh - 160px)" :empty-text="emptyText">
<el-table-column :label="labFile" prop="fileName" min-width="220" show-overflow-tooltip />
<el-table-column :label="labEff" prop="effectiveTime" width="170" align="center" />
<el-table-column :label="labSize" width="100" align="right">
<template #default="{ row }">{{ formatSize(row.fileSize) }}</template>
</el-table-column>
<el-table-column :label="labUser" prop="createBy" width="110" align="center" show-overflow-tooltip />
<el-table-column :label="labTime" prop="createTime" width="170" align="center" />
<el-table-column :label="labAction" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="!row.filePath" @click="handleDownload(row)">{{ labDown }}</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
</template>
<script setup>
const props = defineProps({
listFn: { type: Function, required: true }
})
const { proxy } = getCurrentInstance()
const emptyText = '\u8be5\u7248\u672c\u6682\u65e0\u5bfc\u5165\u9644\u4ef6'
const labFile = '\u6587\u4ef6\u540d\u79f0'
const labEff = '\u751f\u6548\u65f6\u95f4'
const labSize = '\u5927\u5c0f'
const labUser = '\u4e0a\u4f20\u4eba'
const labTime = '\u4e0a\u4f20\u65f6\u95f4'
const labAction = '\u64cd\u4f5c'
const labDown = '\u4e0b\u8f7d'
const labTitle = '\u5bfc\u5165\u9644\u4ef6'
const labNoPath = '\u6587\u4ef6\u8def\u5f84\u4e0d\u5b58\u5728'
const visible = ref(false)
const title = ref('')
const loading = ref(false)
const list = ref([])
const version = ref(null)
function open(row) {
version.value = row
const no = (row && (row.versionNo || row.productCode)) || ''
title.value = no ? (no + ' \u00b7 ' + labTitle) : labTitle
visible.value = true
loadList()
}
function loadList() {
if (!version.value || version.value.psvId == null) {
list.value = []
return
}
loading.value = true
props.listFn(version.value.psvId).then(res => {
list.value = res.data || []
}).finally(() => {
loading.value = false
})
}
function formatSize(bytes) {
const n = Number(bytes)
if (!n || n < 0) return '-'
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
return (n / (1024 * 1024)).toFixed(2) + ' MB'
}
function handleDownload(row) {
if (!row || !row.filePath) {
proxy.$modal.msgWarning(labNoPath)
return
}
proxy.$download.resource(row.filePath)
}
defineExpose({ open })
</script>
@@ -0,0 +1,103 @@
<template>
<el-drawer v-model="visible" :title="title" size="72%" destroy-on-close append-to-body>
<div class="toolbar">
<el-input v-model="keyword" clearable :placeholder="phKw" style="width: 260px" />
<el-button type="primary" @click="noop">{{ labQuery }}</el-button>
<el-button @click="resetKw">{{ labReset }}</el-button>
</div>
<el-alert type="info" :closable="false" show-icon class="mb12" :title="hint" />
<el-table v-loading="loading" :data="filteredRows" border height="calc(100vh - 280px)">
<el-table-column :label="colCode" prop="productCode" min-width="110" />
<el-table-column :label="colName" prop="productName" min-width="140" show-overflow-tooltip />
<el-table-column :label="colLevel" prop="serviceLevel" width="90" align="center" />
<el-table-column :label="colCargo" prop="cargoAttr" min-width="120" show-overflow-tooltip />
<el-table-column :label="colRegion" prop="destRegion" width="90" align="center" />
<el-table-column :label="colAction" width="110" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click.stop="view(row)">{{ viewText }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="product-count">{{ countText }}</div>
</el-drawer>
</template>
<script setup>
const L = {
title: "版本产品",
hint: "点击「查看明细」查看国家/重量段明细",
phKw: "产品代码/名称",
colCode: "产品代码",
colName: "产品名称",
colLevel: "时效等级",
colCargo: "走货属性",
colRegion: "目的区域",
colAction: "操作",
view: "查看明细",
countAll: "共 ",
countUnit: " 个产品",
countFilter: " 个产品(全部 ",
countFilterEnd: " 个)"
}
const props = defineProps({
listFn: { type: Function, required: true }
})
const emit = defineEmits(['view'])
const visible = ref(false)
const loading = ref(false)
const rows = ref([])
const version = ref(null)
const keyword = ref('')
const title = computed(() => {
const no = version.value && version.value.versionNo ? version.value.versionNo : ''
return no ? (L.title + ' ' + no) : L.title
})
const hint = L.hint
const phKw = L.phKw
const colCode = L.colCode
const colName = L.colName
const colLevel = L.colLevel
const colCargo = L.colCargo
const colRegion = L.colRegion
const colAction = L.colAction
const viewText = L.view
const labQuery = "查询"
const labReset = "重置"
const filteredRows = computed(() => {
const kw = (keyword.value || '').trim().toLowerCase()
if (!kw) return rows.value || []
return (rows.value || []).filter(r => {
const blob = [r.productCode, r.productName].filter(Boolean).join(' ').toLowerCase()
return blob.includes(kw)
})
})
const countText = computed(() => {
const n = filteredRows.value.length
const total = (rows.value || []).length
if (n === total) return L.countAll + n + L.countUnit
return L.countAll + n + L.countFilter + total + L.countFilterEnd
})
function noop() {}
function resetKw() {
keyword.value = ''
}
function view(row) {
emit('view', row)
}
function open(ver) {
version.value = ver
visible.value = true
rows.value = []
keyword.value = ''
if (!ver || ver.psvId == null || !props.listFn) return
loading.value = true
props.listFn({ psvId: ver.psvId, pageNum: 1, pageSize: 500 }).then(res => {
rows.value = res.rows || res.data || []
}).finally(() => { loading.value = false })
}
defineExpose({ open })
</script>
<style scoped>
.mb12 { margin-bottom: 12px; }
.toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.product-count { margin-top: 10px; color: #909399; font-size: 13px; text-align: right; }
</style>
@@ -160,13 +160,21 @@ function load() {
})
}
function open(row) {
function open(row, previewList) {
title.value = row.productCode + ' · ' + row.productName + ' · 国家服务明细'
currentProduct.value = row
visible.value = true
list.value = []
keyword.value = ''
status.value = ''
if (Array.isArray(previewList)) {
list.value = previewList.slice().sort((a, b) => {
const c = String(a.countryCode || '').localeCompare(String(b.countryCode || ''))
if (c !== 0) return c
return String(a.zoneCode || '').localeCompare(String(b.zoneCode || ''))
})
return
}
load()
}
@@ -1,65 +0,0 @@
<template>
<el-drawer v-model="visible" size="860px" :title="title" append-to-body>
<el-table v-loading="loading" :data="list" border max-height="calc(100vh - 160px)" empty-text="该产品暂无导入附件">
<el-table-column label="文件名称" prop="fileName" min-width="220" show-overflow-tooltip />
<el-table-column label="生效时间" prop="effectiveTime" width="170" align="center" />
<el-table-column label="大小" width="100" align="right">
<template #default="{ row }">{{ formatSize(row.fileSize) }}</template>
</el-table-column>
<el-table-column label="上传人" prop="createBy" width="110" align="center" show-overflow-tooltip />
<el-table-column label="上传时间" prop="createTime" width="170" align="center" />
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="!row.filePath" @click="handleDownload(row)">下载</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
</template>
<script setup>
import { listProductExcelFiles } from '@/api/price/product'
const { proxy } = getCurrentInstance()
const visible = ref(false)
const title = ref('')
const loading = ref(false)
const list = ref([])
const product = ref(null)
function open(row) {
product.value = row
title.value = (row.productCode || '') + ' · ' + (row.productName || '') + ' · 导入附件'
visible.value = true
loadList()
}
function loadList() {
if (!product.value || !product.value.productCode) return
loading.value = true
listProductExcelFiles(product.value.productCode).then(res => {
list.value = res.data || []
}).finally(() => {
loading.value = false
})
}
function formatSize(bytes) {
const n = Number(bytes)
if (!n || n < 0) return '-'
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
return (n / (1024 * 1024)).toFixed(2) + ' MB'
}
function handleDownload(row) {
if (!row || !row.filePath) {
proxy.$modal.msgWarning('文件路径不存在')
return
}
proxy.$download.resource(row.filePath)
}
defineExpose({ open })
</script>
@@ -1,148 +0,0 @@
<template>
<el-drawer v-model="visible" size="960px" :title="title" append-to-body>
<el-form :inline="true" @submit.prevent>
<el-form-item label="产品代码">
<el-input
v-model="queryParams.productCode"
placeholder="产品代码"
clearable
style="width: 160px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="产品名称">
<el-input
v-model="queryParams.productName"
placeholder="产品名称"
clearable
style="width: 160px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="失效时间段">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="list" border max-height="calc(100vh - 260px)" empty-text="暂无失效产品">
<el-table-column label="产品代码" prop="productCode" min-width="110" show-overflow-tooltip />
<el-table-column label="产品名称" prop="productName" min-width="140" show-overflow-tooltip />
<el-table-column label="时效等级" prop="serviceLevel" width="90" align="center" />
<el-table-column label="走货属性" prop="cargoAttr" min-width="110" show-overflow-tooltip />
<el-table-column label="目的区域" prop="destRegion" width="90" align="center" />
<el-table-column label="生效日期" prop="effectiveDate" width="120" align="center" />
<el-table-column label="失效日期" prop="expireDate" width="120" align="center" />
<el-table-column label="国家数" prop="countryCount" width="80" align="center" />
<el-table-column label="附加费数" prop="surchargeCount" width="90" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<dict-tag :options="bms_product_effect_status" :value="row.status" />
</template>
</el-table-column>
<el-table-column label="更新内容" prop="updateNote" min-width="140" show-overflow-tooltip />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
title="价格"
icon="Document"
@click="excelFileRef.open(row)"
v-hasPermi="['price:product:query']"
></el-button>
<el-button
link
type="primary"
title="产品明细"
icon="View"
@click="emit('view-country', row)"
v-hasPermi="['price:product:query']"
></el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="loadList"
/>
<product-excel-file-drawer ref="excelFileRef" />
</el-drawer>
</template>
<script setup>
import { listProduct } from '@/api/price/product'
import ProductExcelFileDrawer from './ProductExcelFileDrawer.vue'
const emit = defineEmits(['view-country'])
const { proxy } = getCurrentInstance()
const { bms_product_effect_status } = useDict('bms_product_effect_status')
const excelFileRef = ref()
const title = '失效价格'
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const dateRange = ref([])
const queryParams = ref({
pageNum: 1,
pageSize: 10,
productCode: undefined,
productName: undefined,
statusGroup: 'expired'
})
function open() {
visible.value = true
resetQuery()
}
function loadList() {
loading.value = true
const p = { ...queryParams.value }
if (dateRange.value && dateRange.value.length === 2) {
proxy.addDateRange(p, dateRange.value)
}
listProduct(p).then(res => {
list.value = res.rows || []
total.value = res.total || 0
}).finally(() => {
loading.value = false
})
}
function handleQuery() {
queryParams.value.pageNum = 1
loadList()
}
function resetQuery() {
dateRange.value = []
queryParams.value = {
pageNum: 1,
pageSize: 10,
productCode: undefined,
productName: undefined,
statusGroup: 'expired'
}
loadList()
}
defineExpose({ open })
</script>
@@ -1,167 +0,0 @@
<template>
<el-dialog :title="title" v-model="visible" width="640px" append-to-body>
<basic-form ref="formRef" v-model="form" :schemas="schemas" label-width="100px" />
<template #footer>
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="visible = false">取 消</el-button>
</template>
</el-dialog>
</template>
<script setup>
import BasicForm from '@/components/BasicForm'
import { getProduct, addProduct, updateProduct } from '@/api/price/product'
const emit = defineEmits(['success'])
const { proxy } = getCurrentInstance()
const formRef = ref()
const visible = ref(false)
const title = ref('')
const form = ref(emptyForm())
const schemas = computed(() => [
{
label: '产品代码',
prop: 'productCode',
component: 'input',
placeholder: '如 WSP037',
span: 12,
width: '100%',
disabled: !!form.value.productId,
props: { maxlength: 20 },
rules: [{ required: true, message: '产品代码不能为空', trigger: 'blur' }]
},
{
label: '产品名称',
prop: 'productName',
component: 'input',
placeholder: '产品名称',
span: 12,
width: '100%',
props: { maxlength: 100 },
rules: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }]
},
{
label: '时效等级',
prop: 'serviceLevel',
component: 'input',
placeholder: '如 标准',
span: 12,
width: '100%',
props: { maxlength: 20 }
},
{
label: '走货属性',
prop: 'cargoAttr',
component: 'input',
placeholder: '普货/带电等',
span: 12,
width: '100%',
props: { maxlength: 100 }
},
{
label: '目的区域',
prop: 'destRegion',
component: 'input',
placeholder: '如 欧盟',
span: 12,
width: '100%',
props: { maxlength: 50 }
},
// {
// label: '税模式',
// prop: 'taxMode',
// component: 'select',
// placeholder: '请选择',
// dict: 'bms_tax_mode',
// span: 12,
// width: '100%',
// rules: [{ required: true, message: '请选择税模式', trigger: 'change' }]
// },
{
label: '生效时间',
prop: 'effectiveDate',
component: 'datetime',
placeholder: '选择时间',
span: 12,
width: '100%',
rules: [{ required: true, message: '生效时间不能为空', trigger: 'change' }]
},
{
label: '失效时间',
prop: 'expireDate',
component: 'datetime',
placeholder: '选择时间',
span: 12,
width: '100%'
},
{
label: '更新内容',
prop: 'updateNote',
component: 'textarea',
span: 24,
width: '100%',
props: { rows: 2, maxlength: 500 }
},
{
label: '备注',
prop: 'remark',
component: 'textarea',
span: 24,
width: '100%',
props: { rows: 2, maxlength: 500 }
}
])
function emptyForm() {
return {
productId: undefined,
productCode: undefined,
productName: undefined,
serviceLevel: undefined,
cargoAttr: undefined,
destRegion: undefined,
updateNote: undefined,
taxMode: 'TAX_INCLUDED',
effectiveDate: undefined,
expireDate: undefined,
status: '0',
remark: undefined
}
}
function reset() {
form.value = emptyForm()
nextTick(() => formRef.value?.clearValidate())
}
function open(row) {
reset()
if (!row) {
title.value = '新建产品'
visible.value = true
return
}
getProduct(row.productId).then(res => {
form.value = { ...emptyForm(), ...(res.data || {}) }
title.value = '修改产品'
visible.value = true
nextTick(() => formRef.value?.clearValidate())
})
}
function submitForm() {
formRef.value.validate(valid => {
if (!valid) return
const req = form.value.productId ? updateProduct(form.value) : addProduct(form.value)
req.then(() => {
proxy.$modal.msgSuccess('操作成功')
visible.value = false
emit('success')
})
})
}
defineExpose({ open })
</script>
@@ -0,0 +1,27 @@
<template>
<catalog-price-create
kind="product"
list-path="/price/product"
:list-fn="listProduct"
:del-fn="delProduct"
:prepare-fn="prepareProductVersion"
:apply-preview-fn="applyProductCatalogPreview"
:attach-excel-fn="attachProductVersionExcel"
:import-all-fn="importProductExcelAsync"
:import-country-fn="importProductCountryExcelAsync"
:preview-all-fn="previewProductImport"
:preview-country-fn="previewProductCountryImport"
:country-comp="ProductCountryDrawer"
:surcharge-comp="ProductSurchargeDrawer"
/>
</template>
<script setup name="ProductPriceCreate">
import CatalogPriceCreate from '../components/CatalogPriceCreate.vue'
import ProductCountryDrawer from './components/ProductCountryDrawer.vue'
import ProductSurchargeDrawer from './components/ProductSurchargeDrawer.vue'
import {
listProduct, delProduct, prepareProductVersion, applyProductCatalogPreview, attachProductVersionExcel,
importProductExcelAsync, importProductCountryExcelAsync,
previewProductImport, previewProductCountryImport
} from '@/api/price/product'
</script>
+169 -334
View File
@@ -5,9 +5,16 @@
:closable="false"
show-icon
class="mb16"
title="导入前须先选择生效时间;产品与国家明细均按此时间落库,上一版产品/明细失效时间设为该时间。多国家价表用「全部导入」,单国专线成本价用「按国家导入」。相同产品代码+生效日期不覆盖主数据。"
title="已生效展示当前生效版本产品。待生效、已失效按版本展示;待生效可启动或删除整版。建价请点「新建产品价格」。"
/>
<el-tabs v-model="statusTab" class="catalog-tabs" @tab-change="onTabChange">
<el-tab-pane label="已生效" name="effect" />
<el-tab-pane label="待生效" name="pending" />
<el-tab-pane label="已失效" name="expired" />
</el-tabs>
<div v-show="statusTab === 'effect'">
<basic-table
ref="tableRef"
:extra-params="extraParams"
@@ -15,37 +22,11 @@
:search-columns="searchColumns"
:query="queryList"
row-key="productId"
:selection="true"
:selection="false"
>
<template #toolbar="{ selection }">
<template #toolbar>
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['price:product:add']">新建产品</el-button>
</el-col>
<el-col :span="1.5">
<el-dropdown @command="handleImportCommand">
<el-button type="info" plain icon="Upload" v-hasPermi="['price:product:import']">
导入 Excel<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-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="Delete"
:disabled="!selection.length"
@click="handleBatchDelete(selection)"
v-hasPermi="['price:product:remove']"
>批量删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button plain icon="Clock" @click="expiredRef.open()" v-hasPermi="['price:product:list']">失效价格</el-button>
<el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['price:product:add']">新建产品价格</el-button>
</el-col>
</template>
@@ -58,167 +39,104 @@
</template>
<template #col-action="{ row }">
<el-button link type="primary" title="价格" icon="Document" @click="excelFileRef.open(row)" v-hasPermi="['price:product:query']"></el-button>
<el-button link type="primary" title="产品明细" icon="View" @click="countryRef.open(row)" v-hasPermi="['price:product:query']"></el-button>
<el-button link type="primary" title="附加费" icon="Money" @click="surchargeRef.open(row)" v-hasPermi="['price:surcharge:query']"></el-button>
<el-button title="编辑" link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['price:product:edit']"></el-button>
</template>
</basic-table>
</div>
<product-form-dialog ref="formRef" @success="() => tableRef.refresh()" />
<product-excel-file-drawer ref="excelFileRef" />
<div v-show="statusTab === 'pending'">
<el-row class="mb12">
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['price:product:add']">新建产品价格</el-button>
</el-col>
</el-row>
<el-table v-loading="versionLoading" :data="versionRows" border empty-text="暂无待生效版本" @row-click="openVersionProducts">
<el-table-column label="版本号" prop="versionNo" width="120" />
<el-table-column label="生效时间" prop="effectiveTime" min-width="170" />
<el-table-column label="失效时间" prop="expireTime" min-width="170" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">{{ versionStatusText(row.status) }}</template>
</el-table-column>
<el-table-column label="产品数" prop="productCount" width="90" align="center" />
<el-table-column label="明细行数" prop="detailCount" width="100" align="center" />
<el-table-column label="操作" width="360" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click.stop="onEditVersion(row)" v-hasPermi="['price:product:edit']">编辑</el-button>
<el-button link type="primary" @click.stop="openVersionProducts(row)">查看产品</el-button>
<el-button link type="primary" @click.stop="openExcel(row)" v-hasPermi="['price:product:query']">查看附件</el-button>
<el-button link type="success" @click.stop="onStartVersion(row)" v-hasPermi="['price:product:edit']">启动</el-button>
<el-button link type="danger" @click.stop="onDeleteVersion(row)" v-hasPermi="['price:product:remove']">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="versionTotal > 0"
:total="versionTotal"
v-model:page="versionQuery.pageNum"
v-model:limit="versionQuery.pageSize"
@pagination="loadVersions"
/>
</div>
<div v-show="statusTab === 'expired'">
<el-table v-loading="versionLoading" :data="versionRows" border empty-text="暂无失效版本" @row-click="openVersionProducts">
<el-table-column label="版本号" prop="versionNo" width="120" />
<el-table-column label="生效时间" prop="effectiveTime" min-width="170" />
<el-table-column label="失效时间" prop="expireTime" min-width="170" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">{{ versionStatusText(row.status) }}</template>
</el-table-column>
<el-table-column label="产品数" prop="productCount" width="90" align="center" />
<el-table-column label="明细行数" prop="detailCount" width="100" align="center" />
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click.stop="openVersionProducts(row)">查看产品</el-button>
<el-button link type="primary" @click.stop="openExcel(row)" v-hasPermi="['price:product:query']">查看附件</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="versionTotal > 0"
:total="versionTotal"
v-model:page="versionQuery.pageNum"
v-model:limit="versionQuery.pageSize"
@pagination="loadVersions"
/>
</div>
<catalog-version-excel-drawer ref="excelRef" :list-fn="listProductVersionExcelFiles" />
<product-country-drawer ref="countryRef" />
<product-surcharge-drawer ref="surchargeRef" @success="() => tableRef.refresh()" />
<product-expired-drawer ref="expiredRef" @view-country="(row) => countryRef.open(row)" />
<excel-import-dialog
ref="importRef"
title="产品 Excel 导入"
width="560px"
:show-update-support="false"
template-url="/templates/product/product-import-all-template.xlsx"
template-file-name="产品多国家价表导入模板"
:import-desc="allImportDesc"
:before-submit="validateImportEffectiveDate"
:submit-handler="runImportAllAsync"
@success="() => tableRef.refresh()"
>
<template #extra>
<el-form label-width="90px">
<el-form-item label="生效时间" required>
<el-date-picker
v-model="importEffectiveDate"
type="datetime"
format="YYYY-MM-DD HH:00"
value-format="YYYY-MM-DD HH:00:00"
popper-class="product-import-hour-picker"
placeholder="请选择生效时间"
style="width: 100%"
@change="onImportEffectiveTimeChange"
/>
</el-form-item>
</el-form>
</template>
</excel-import-dialog>
<excel-import-dialog
ref="countryImportRef"
title="按国家导入"
width="560px"
:show-update-support="false"
:before-submit="validateCountryImport"
:import-desc="countryImportDesc"
template-url="/templates/product/product-import-country-template.xlsx"
template-file-name="产品按国家导入模板"
:submit-handler="runImportCountryAsync"
@success="() => tableRef.refresh()"
>
<template #extra>
<el-form label-width="90px">
<el-form-item label="生效时间" required>
<el-date-picker
v-model="importEffectiveDate"
type="datetime"
format="YYYY-MM-DD HH:00"
value-format="YYYY-MM-DD HH:00:00"
popper-class="product-import-hour-picker"
placeholder="请选择生效时间"
style="width: 100%"
@change="onImportEffectiveTimeChange"
/>
</el-form-item>
<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="正在导入产品"
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>
<catalog-version-products-drawer ref="versionProductsRef" :list-fn="listProduct" @view="(row) => countryRef.open(row)" />
<catalog-activate-dialog ref="activateRef" :load-versions="loadAllVersions" :activate-fn="activateProductVersion" @success="onActivated" />
</div>
</template>
<script setup name="PriceProduct">
import ExcelImportDialog from '@/components/ExcelImportDialog'
import ProductFormDialog from './components/ProductFormDialog.vue'
import ProductExcelFileDrawer from './components/ProductExcelFileDrawer.vue'
import ProductCountryDrawer from './components/ProductCountryDrawer.vue'
import ProductSurchargeDrawer from './components/ProductSurchargeDrawer.vue'
import ProductExpiredDrawer from './components/ProductExpiredDrawer.vue'
import { listProduct, delProduct, importProductExcelAsync, importProductCountryExcelAsync } from '@/api/price/product'
import { listRegionOptions } from '@/api/data/region'
import CatalogVersionProductsDrawer from '../components/CatalogVersionProductsDrawer.vue'
import CatalogVersionExcelDrawer from '../components/CatalogVersionExcelDrawer.vue'
import CatalogActivateDialog from '../components/CatalogActivateDialog.vue'
import { listProduct, listProductVersions, activateProductVersion, deleteProductVersion, listProductVersionExcelFiles } from '@/api/price/product'
const { proxy } = getCurrentInstance()
const { bms_product_effect_status } = useDict('bms_product_effect_status')
const extraParams = { statusGroup: 'active' }
const statusTab = ref('effect')
const extraParams = reactive({ statusGroup: 'effect' })
const versionProductsRef = ref()
const activateRef = ref()
const versionLoading = ref(false)
const versionRows = ref([])
const versionTotal = ref(0)
const versionQuery = reactive({ pageNum: 1, pageSize: 10 })
const tableRef = ref()
const formRef = ref()
const excelFileRef = ref()
const countryRef = ref()
const surchargeRef = ref()
const expiredRef = ref()
const importRef = ref()
const countryImportRef = ref()
const importCountryCode = ref('')
const importEffectiveDate = ref('')
function pad2(n) {
return String(n).padStart(2, '0')
}
function formatImportHour(d) {
if (!d || Number.isNaN(d.getTime())) return undefined
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
+ ' ' + pad2(d.getHours()) + ':00:00'
}
function normalizeImportToHour(val) {
if (val == null || val === '') return val
if (val instanceof Date) return formatImportHour(val)
const s = String(val).trim().replace('T', ' ')
const m = s.match(/^(\d{4}-\d{2}-\d{2})[ ](\d{2})/)
if (m) return m[1] + ' ' + m[2] + ':00:00'
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s + ' 00:00:00'
return formatImportHour(new Date(s.replace(/-/g, '/'))) || val
}
function onImportEffectiveTimeChange(val) {
importEffectiveDate.value = normalizeImportToHour(val) || ''
}
const regionOptions = ref([])
const importProgressVisible = ref(false)
const importProgressPercent = ref(0)
const importProgressMessage = ref('')
const importProgressStatus = ref(undefined)
const allImportDesc = [
'适用多国家价表(B2C 小包价表等),按目录 sheet 匹配国家导入。',
'必选生效时间:产品与 product_country 明细均按此时间计费;上一版产品/明细失效时间设为该时间。',
'请在弹窗底部下载模板,按模板格式整理后上传。相同产品代码+生效日期不覆盖主数据。'
]
const countryImportDesc = [
'选择目的国家后上传专线成本价表,目录中的产品都会记到该国家。',
'必选生效时间:产品与 product_country 明细均按此时间计费;上一版产品/明细失效时间设为该时间。',
'邮编、赔偿标准等 sheet 不导入。可在弹窗底部下载模板。'
]
const excelRef = ref()
const columns = [
{ prop: 'productCode', label: '产品代码', minWidth: 110 },
@@ -232,20 +150,12 @@ const columns = [
{ prop: 'surchargeCount', label: '附加费数', width: 90, align: 'center' },
{ prop: 'status', label: '状态', width: 90, align: 'center', slot: 'status' },
{ prop: 'updateNote', label: '更新内容', minWidth: 160, showOverflowTooltip: true },
{ prop: 'action', label: '操作', width: 240, fixed: 'right', slot: 'action' }
{ prop: 'action', label: '操作', width: 120, fixed: 'right', slot: 'action' }
]
const searchColumns = [
{ prop: 'productCode', label: '产品代码', component: 'input', placeholder: '产品代码' },
{ prop: 'productName', label: '产品名称', component: 'input', placeholder: '产品名称' },
{
prop: 'status',
label: '状态',
component: 'select',
dict: 'bms_product_effect_status',
placeholder: '请选择状态',
width: '160px'
}
{ prop: 'productName', label: '产品名称', component: 'input', placeholder: '产品名称' }
]
function queryList(params) {
@@ -258,177 +168,102 @@ function queryList(params) {
}
function handleAdd() {
formRef.value.open()
proxy.$router.push({ path: '/price/product-create/index' })
}
function handleUpdate(row) {
formRef.value.open(row)
}
function isPendingProduct(row) {
if (!row) return false
if (String(row.status) === '0') return true
// fallback: effective in future and not expired
if (!row.effectiveDate) return false
const eff = new Date(String(row.effectiveDate).replace(/-/g, '/')).getTime()
if (Number.isNaN(eff) || eff <= Date.now()) return false
if (row.expireDate) {
const exp = new Date(String(row.expireDate).replace(/-/g, '/')).getTime()
if (!Number.isNaN(exp) && exp <= Date.now()) return false
}
return true
}
function handleBatchDelete(selection) {
const rows = (selection || []).filter(r => r && r.productId != null)
if (!rows.length) {
proxy.$modal.msgWarning('请先勾选产品')
function openExcel(row) {
if (!row || row.psvId == null) {
proxy.$modal.msgWarning('该行没有版本,无法查看附件')
return
}
const pending = rows.filter(isPendingProduct)
const blocked = rows.filter(r => !isPendingProduct(r))
if (!pending.length) {
proxy.$modal.msgWarning('仅待生效产品可删除,当前勾选无待生效记录')
excelRef.value && excelRef.value.open(row)
}
function onTabChange(name) {
if (name === 'pending' || name === 'expired') {
versionQuery.pageNum = 1
loadVersions()
return
}
if (blocked.length) {
const tip = blocked.map(r => r.productCode || r.productId).join('、')
proxy.$modal.msgWarning('已跳过非待生效产品:' + tip)
extraParams.statusGroup = 'effect'
nextTick(() => tableRef.value && tableRef.value.refresh())
}
function versionStatusText(status) {
const map = { '0': '草稿', '1': '已生效', '2': '已失效', '3': '待生效' }
return map[String(status)] || status || '-'
}
function loadVersions() {
versionLoading.value = true
const params = {
pageNum: versionQuery.pageNum,
pageSize: versionQuery.pageSize
}
const codes = pending.map(r => r.productCode || r.productId).join('、')
const ids = pending.map(r => r.productId)
proxy.$modal.confirm(
'确认删除已选待生效产品(' + pending.length + '个):'
+ codes
+ '?删除后产品及国家/重量段明细将一并清除,且不可恢复。'
).then(() => delProduct(ids)).then(() => {
tableRef.value.refresh()
if (statusTab.value === 'pending') params.statusGroup = 'pending'
else params.status = '2'
listProductVersions(params).then(res => {
versionRows.value = res.rows || []
versionTotal.value = res.total || 0
}).finally(() => { versionLoading.value = false })
}
function loadAllVersions() {
return listProductVersions({ pageNum: 1, pageSize: 500 }).then(res => res.rows || [])
}
function openVersionProducts(row) {
versionProductsRef.value && versionProductsRef.value.open(row)
}
function onEditVersion(row) {
if (!row || row.psvId == null) return
proxy.$router.push({
path: '/price/product-create/index',
query: {
psvId: row.psvId,
versionNo: row.versionNo || undefined
}
})
}
function onStartVersion(row) {
if (row && row.psvId != null) {
activateRef.value && activateRef.value.open(row)
return
}
loadAllVersions().then(list => {
const pending = (list || []).filter(v => v && (String(v.status) === '0' || String(v.status) === '3'))
if (!pending.length) {
proxy.$modal.msgWarning('当前没有可启动的待生效版本')
return
}
pending.sort((a, b) => (Number(b.versionSeq) || 0) - (Number(a.versionSeq) || 0))
const draft = pending.find(v => String(v.status) === '0')
activateRef.value && activateRef.value.open(draft || pending[0])
})
}
function onDeleteVersion(row) {
if (!row || row.psvId == null) return
proxy.$modal.confirm('确认硬删除该待生效版本?版本、产品、明细将从数据库真删除,且不可恢复。').then(() => deleteProductVersion(row.psvId)).then(() => {
loadVersions()
proxy.$modal.msgSuccess('删除成功')
}).catch(() => {})
}
function handleImportCommand(command) {
if (command === 'country') {
handleCountryImport()
return
}
handleImport()
function onActivated() {
if (statusTab.value === 'pending' || statusTab.value === 'expired') loadVersions()
else if (tableRef.value) tableRef.value.refresh()
}
function handleImport() {
importEffectiveDate.value = ''
importRef.value.open()
}
function regionLabel(item) {
const name = item.countryNameCn || item.countryNameEn || ''
const code = item.countryCode2 || ''
return name && code ? name + ' (' + code + ')' : (name || code)
}
function loadRegionOptions() {
if (regionOptions.value.length) return
listRegionOptions().then(res => {
regionOptions.value = res.data || []
})
}
function handleCountryImport() {
importCountryCode.value = ''
importEffectiveDate.value = ''
loadRegionOptions()
countryImportRef.value.open()
}
function validateImportEffectiveDate() {
if (!importEffectiveDate.value) {
return '请先选择生效时间'
}
return ''
}
function validateCountryImport() {
const effErr = validateImportEffectiveDate()
if (effErr) return effErr
if (!importCountryCode.value) {
return '请先选择目的国家'
}
return ''
}
function applyImportProgress(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 === 'finishing') importProgressMessage.value = '收尾中…'
else if (m === 'queued') importProgressMessage.value = '排队中…'
else if (m.indexOf('importing ') === 0) {
const rest = m.slice('importing '.length)
importProgressMessage.value = '正在导入产品 ' + rest
} 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 runImportWithProgress(runner) {
importProgressVisible.value = true
importProgressPercent.value = 0
importProgressMessage.value = '上传并解析中…'
importProgressStatus.value = undefined
try {
const { task } = await runner(applyImportProgress)
importProgressPercent.value = 100
importProgressStatus.value = 'success'
return (task && task.message) ? task.message : '导入完成'
} catch (e) {
importProgressStatus.value = 'exception'
const tip = (e && e.task && e.task.error) || (e && e.message) || '导入失败'
importProgressMessage.value = tip
await new Promise(resolve => setTimeout(resolve, 600))
throw e
} finally {
importProgressVisible.value = false
}
}
async function runImportAllAsync(uploadFile) {
const raw = uploadFile && (uploadFile.raw || uploadFile)
if (!raw) {
throw new Error('请先选择文件')
}
if (!importEffectiveDate.value) {
throw new Error('请先选择生效时间')
}
const eff = importEffectiveDate.value
return runImportWithProgress((onProgress) => importProductExcelAsync(raw, eff, onProgress))
}
async function runImportCountryAsync(uploadFile) {
const raw = uploadFile && (uploadFile.raw || uploadFile)
if (!raw) {
throw new Error('请先选择文件')
}
if (!importEffectiveDate.value) {
throw new Error('请先选择生效时间')
}
if (!importCountryCode.value) {
throw new Error('请先选择目的国家')
}
const code = importCountryCode.value
const eff = importEffectiveDate.value
return runImportWithProgress((onProgress) => importProductCountryExcelAsync(raw, code, eff, onProgress))
}
</script>
<style scoped>
.mb16 { margin-bottom: 16px; }
.mb12 { margin-bottom: 12px; }
.catalog-tabs { margin-bottom: 8px; }
.import-progress { padding: 8px 4px 4px; }
.import-progress-msg {
margin-top: 12px;
@@ -160,13 +160,21 @@ function load() {
})
}
function open(row) {
function open(row, previewList) {
title.value = row.productCode + ' · ' + row.productName + ' · 国家服务明细'
currentProduct.value = row
visible.value = true
list.value = []
keyword.value = ''
status.value = ''
if (Array.isArray(previewList)) {
list.value = previewList.slice().sort((a, b) => {
const c = String(a.countryCode || '').localeCompare(String(b.countryCode || ''))
if (c !== 0) return c
return String(a.zoneCode || '').localeCompare(String(b.zoneCode || ''))
})
return
}
load()
}
@@ -1,65 +0,0 @@
<template>
<el-drawer v-model="visible" size="860px" :title="title" append-to-body>
<el-table v-loading="loading" :data="list" border max-height="calc(100vh - 160px)" empty-text="该产品暂无导入附件">
<el-table-column label="文件名称" prop="fileName" min-width="220" show-overflow-tooltip />
<el-table-column label="生效时间" prop="effectiveTime" width="170" align="center" />
<el-table-column label="大小" width="100" align="right">
<template #default="{ row }">{{ formatSize(row.fileSize) }}</template>
</el-table-column>
<el-table-column label="上传人" prop="createBy" width="110" align="center" show-overflow-tooltip />
<el-table-column label="上传时间" prop="createTime" width="170" align="center" />
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="!row.filePath" @click="handleDownload(row)">下载</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
</template>
<script setup>
import { listProductExcelFiles } from '@/api/price/redline'
const { proxy } = getCurrentInstance()
const visible = ref(false)
const title = ref('')
const loading = ref(false)
const list = ref([])
const product = ref(null)
function open(row) {
product.value = row
title.value = (row.productCode || '') + ' · ' + (row.productName || '') + ' · 导入附件'
visible.value = true
loadList()
}
function loadList() {
if (!product.value || !product.value.productCode) return
loading.value = true
listProductExcelFiles(product.value.productCode).then(res => {
list.value = res.data || []
}).finally(() => {
loading.value = false
})
}
function formatSize(bytes) {
const n = Number(bytes)
if (!n || n < 0) return '-'
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
return (n / (1024 * 1024)).toFixed(2) + ' MB'
}
function handleDownload(row) {
if (!row || !row.filePath) {
proxy.$modal.msgWarning('文件路径不存在')
return
}
proxy.$download.resource(row.filePath)
}
defineExpose({ open })
</script>
@@ -1,148 +0,0 @@
<template>
<el-drawer v-model="visible" size="960px" :title="title" append-to-body>
<el-form :inline="true" @submit.prevent>
<el-form-item label="产品代码">
<el-input
v-model="queryParams.productCode"
placeholder="产品代码"
clearable
style="width: 160px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="产品名称">
<el-input
v-model="queryParams.productName"
placeholder="产品名称"
clearable
style="width: 160px"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="失效时间段">
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="list" border max-height="calc(100vh - 260px)" empty-text="暂无失效产品">
<el-table-column label="产品代码" prop="productCode" min-width="110" show-overflow-tooltip />
<el-table-column label="产品名称" prop="productName" min-width="140" show-overflow-tooltip />
<el-table-column label="时效等级" prop="serviceLevel" width="90" align="center" />
<el-table-column label="走货属性" prop="cargoAttr" min-width="110" show-overflow-tooltip />
<el-table-column label="目的区域" prop="destRegion" width="90" align="center" />
<el-table-column label="生效日期" prop="effectiveDate" width="120" align="center" />
<el-table-column label="失效日期" prop="expireDate" width="120" align="center" />
<el-table-column label="国家数" prop="countryCount" width="80" align="center" />
<el-table-column label="附加费数" prop="surchargeCount" width="90" align="center" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<dict-tag :options="bms_product_effect_status" :value="row.status" />
</template>
</el-table-column>
<el-table-column label="更新内容" prop="updateNote" min-width="140" show-overflow-tooltip />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-button
link
type="primary"
title="价格"
icon="Document"
@click="excelFileRef.open(row)"
v-hasPermi="['price:redline:query']"
></el-button>
<el-button
link
type="primary"
title="红线价明细"
icon="View"
@click="emit('view-country', row)"
v-hasPermi="['price:redline:query']"
></el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="loadList"
/>
<redline-excel-file-drawer ref="excelFileRef" />
</el-drawer>
</template>
<script setup>
import { listRedline } from '@/api/price/redline'
import RedlineExcelFileDrawer from './RedlineExcelFileDrawer.vue'
const emit = defineEmits(['view-country'])
const { proxy } = getCurrentInstance()
const { bms_product_effect_status } = useDict('bms_product_effect_status')
const excelFileRef = ref()
const title = '失效价格'
const visible = ref(false)
const loading = ref(false)
const list = ref([])
const total = ref(0)
const dateRange = ref([])
const queryParams = ref({
pageNum: 1,
pageSize: 10,
productCode: undefined,
productName: undefined,
statusGroup: 'expired'
})
function open() {
visible.value = true
resetQuery()
}
function loadList() {
loading.value = true
const p = { ...queryParams.value }
if (dateRange.value && dateRange.value.length === 2) {
proxy.addDateRange(p, dateRange.value)
}
listRedline(p).then(res => {
list.value = res.rows || []
total.value = res.total || 0
}).finally(() => {
loading.value = false
})
}
function handleQuery() {
queryParams.value.pageNum = 1
loadList()
}
function resetQuery() {
dateRange.value = []
queryParams.value = {
pageNum: 1,
pageSize: 10,
productCode: undefined,
productName: undefined,
statusGroup: 'expired'
}
loadList()
}
defineExpose({ open })
</script>
@@ -1,167 +0,0 @@
<template>
<el-dialog :title="title" v-model="visible" width="640px" append-to-body>
<basic-form ref="formRef" v-model="form" :schemas="schemas" label-width="100px" />
<template #footer>
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="visible = false">取 消</el-button>
</template>
</el-dialog>
</template>
<script setup>
import BasicForm from '@/components/BasicForm'
import { getRedline, addRedline, updateRedline } from '@/api/price/redline'
const emit = defineEmits(['success'])
const { proxy } = getCurrentInstance()
const formRef = ref()
const visible = ref(false)
const title = ref('')
const form = ref(emptyForm())
const schemas = computed(() => [
{
label: '产品代码',
prop: 'productCode',
component: 'input',
placeholder: '如 WSP037',
span: 12,
width: '100%',
disabled: !!form.value.productId,
props: { maxlength: 20 },
rules: [{ required: true, message: '产品代码不能为空', trigger: 'blur' }]
},
{
label: '产品名称',
prop: 'productName',
component: 'input',
placeholder: '产品名称',
span: 12,
width: '100%',
props: { maxlength: 100 },
rules: [{ required: true, message: '产品名称不能为空', trigger: 'blur' }]
},
{
label: '时效等级',
prop: 'serviceLevel',
component: 'input',
placeholder: '如 标准',
span: 12,
width: '100%',
props: { maxlength: 20 }
},
{
label: '走货属性',
prop: 'cargoAttr',
component: 'input',
placeholder: '普货/带电等',
span: 12,
width: '100%',
props: { maxlength: 100 }
},
{
label: '目的区域',
prop: 'destRegion',
component: 'input',
placeholder: '如 欧盟',
span: 12,
width: '100%',
props: { maxlength: 50 }
},
// {
// label: '税模式',
// prop: 'taxMode',
// component: 'select',
// placeholder: '请选择',
// dict: 'bms_tax_mode',
// span: 12,
// width: '100%',
// rules: [{ required: true, message: '请选择税模式', trigger: 'change' }]
// },
{
label: '生效时间',
prop: 'effectiveDate',
component: 'datetime',
placeholder: '选择时间',
span: 12,
width: '100%',
rules: [{ required: true, message: '生效时间不能为空', trigger: 'change' }]
},
{
label: '失效时间',
prop: 'expireDate',
component: 'datetime',
placeholder: '选择时间',
span: 12,
width: '100%'
},
{
label: '更新内容',
prop: 'updateNote',
component: 'textarea',
span: 24,
width: '100%',
props: { rows: 2, maxlength: 500 }
},
{
label: '备注',
prop: 'remark',
component: 'textarea',
span: 24,
width: '100%',
props: { rows: 2, maxlength: 500 }
}
])
function emptyForm() {
return {
productId: undefined,
productCode: undefined,
productName: undefined,
serviceLevel: undefined,
cargoAttr: undefined,
destRegion: undefined,
updateNote: undefined,
taxMode: 'TAX_INCLUDED',
effectiveDate: undefined,
expireDate: undefined,
status: '0',
remark: undefined
}
}
function reset() {
form.value = emptyForm()
nextTick(() => formRef.value?.clearValidate())
}
function open(row) {
reset()
if (!row) {
title.value = '新建红线价'
visible.value = true
return
}
getRedline(row.productId).then(res => {
form.value = { ...emptyForm(), ...(res.data || {}) }
title.value = '修改产品'
visible.value = true
nextTick(() => formRef.value?.clearValidate())
})
}
function submitForm() {
formRef.value.validate(valid => {
if (!valid) return
const req = form.value.productId ? updateRedline(form.value) : addRedline(form.value)
req.then(() => {
proxy.$modal.msgSuccess('操作成功')
visible.value = false
emit('success')
})
})
}
defineExpose({ open })
</script>
@@ -0,0 +1,27 @@
<template>
<catalog-price-create
kind="redline"
list-path="/price/redline"
:list-fn="listRedline"
:del-fn="delRedline"
:prepare-fn="prepareRedlineVersion"
:apply-preview-fn="applyRedlineCatalogPreview"
:attach-excel-fn="attachRedlineVersionExcel"
:import-all-fn="importRedlineExcelAsync"
:import-country-fn="importRedlineCountryExcelAsync"
:preview-all-fn="previewRedlineImport"
:preview-country-fn="previewRedlineCountryImport"
:country-comp="RedlineCountryDrawer"
:surcharge-comp="RedlineSurchargeDrawer"
/>
</template>
<script setup name="RedlinePriceCreate">
import CatalogPriceCreate from '../components/CatalogPriceCreate.vue'
import RedlineCountryDrawer from './components/RedlineCountryDrawer.vue'
import RedlineSurchargeDrawer from './components/RedlineSurchargeDrawer.vue'
import {
listRedline, delRedline, prepareRedlineVersion, applyRedlineCatalogPreview, attachRedlineVersionExcel,
importRedlineExcelAsync, importRedlineCountryExcelAsync,
previewRedlineImport, previewRedlineCountryImport
} from '@/api/price/redline'
</script>
+167 -323
View File
@@ -5,9 +5,16 @@
:closable="false"
show-icon
class="mb16"
title="导入前须先选择生效时间;红线价与国家明细均按此时间落库,上一版产品/明细失效时间设为该时间。多国家价表用「全部导入」,单国专线成本价用「按国家导入」。相同产品代码+生效日期不覆盖主数据。"
title="红线价已生效展示当前生效版本产品。待生效、已失效按版本展示;待生效可启动或删除整版。"
/>
<el-tabs v-model="statusTab" class="catalog-tabs" @tab-change="onTabChange">
<el-tab-pane label="已生效" name="effect" />
<el-tab-pane label="待生效" name="pending" />
<el-tab-pane label="已失效" name="expired" />
</el-tabs>
<div v-show="statusTab === 'effect'">
<basic-table
ref="tableRef"
:extra-params="extraParams"
@@ -15,38 +22,12 @@
:search-columns="searchColumns"
:query="queryList"
row-key="productId"
:selection="true"
:selection="false"
>
<template #toolbar="{ selection }">
<template #toolbar>
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['price:redline:add']">新建红线价</el-button>
</el-col>
<el-col :span="1.5">
<el-dropdown @command="handleImportCommand">
<el-button type="info" plain icon="Upload" v-hasPermi="['price:redline:import']">
导入 Excel<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-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="Delete"
:disabled="!selection.length"
@click="handleBatchDelete(selection)"
v-hasPermi="['price:redline:remove']"
>批量删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button plain icon="Clock" @click="expiredRef.open()" v-hasPermi="['price:redline:list']">失效价格</el-button>
</el-col>
</template>
<template #col-cargoAttr="{ row }">
@@ -58,167 +39,104 @@
</template>
<template #col-action="{ row }">
<el-button link type="primary" title="价格" icon="Document" @click="excelFileRef.open(row)" v-hasPermi="['price:redline:query']"></el-button>
<el-button link type="primary" title="红线价明细" icon="View" @click="countryRef.open(row)" v-hasPermi="['price:redline:query']"></el-button>
<el-button link type="primary" title="附加费" icon="Money" @click="surchargeRef.open(row)" v-hasPermi="['price:surcharge:query']"></el-button>
<el-button title="编辑" link type="primary" icon="Edit" @click="handleUpdate(row)" v-hasPermi="['price:redline:edit']"></el-button>
</template>
</basic-table>
</div>
<redline-form-dialog ref="formRef" @success="() => tableRef.refresh()" />
<redline-excel-file-drawer ref="excelFileRef" />
<div v-show="statusTab === 'pending'">
<el-row class="mb12">
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['price:redline:add']">新建红线价</el-button>
</el-col>
</el-row>
<el-table v-loading="versionLoading" :data="versionRows" border empty-text="暂无待生效版本" @row-click="openVersionProducts">
<el-table-column label="版本号" prop="versionNo" width="120" />
<el-table-column label="生效时间" prop="effectiveTime" min-width="170" />
<el-table-column label="失效时间" prop="expireTime" min-width="170" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">{{ versionStatusText(row.status) }}</template>
</el-table-column>
<el-table-column label="产品数" prop="productCount" width="90" align="center" />
<el-table-column label="明细行数" prop="detailCount" width="100" align="center" />
<el-table-column label="操作" width="360" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click.stop="onEditVersion(row)" v-hasPermi="['price:redline:edit']">编辑</el-button>
<el-button link type="primary" @click.stop="openVersionProducts(row)">查看产品</el-button>
<el-button link type="primary" @click.stop="openExcel(row)" v-hasPermi="['price:redline:query']">查看附件</el-button>
<el-button link type="success" @click.stop="onStartVersion(row)" v-hasPermi="['price:redline:edit']">启动</el-button>
<el-button link type="danger" @click.stop="onDeleteVersion(row)" v-hasPermi="['price:redline:remove']">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="versionTotal > 0"
:total="versionTotal"
v-model:page="versionQuery.pageNum"
v-model:limit="versionQuery.pageSize"
@pagination="loadVersions"
/>
</div>
<div v-show="statusTab === 'expired'">
<el-table v-loading="versionLoading" :data="versionRows" border empty-text="暂无失效版本" @row-click="openVersionProducts">
<el-table-column label="版本号" prop="versionNo" width="120" />
<el-table-column label="生效时间" prop="effectiveTime" min-width="170" />
<el-table-column label="失效时间" prop="expireTime" min-width="170" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">{{ versionStatusText(row.status) }}</template>
</el-table-column>
<el-table-column label="产品数" prop="productCount" width="90" align="center" />
<el-table-column label="明细行数" prop="detailCount" width="100" align="center" />
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click.stop="openVersionProducts(row)">查看产品</el-button>
<el-button link type="primary" @click.stop="openExcel(row)" v-hasPermi="['price:redline:query']">查看附件</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="versionTotal > 0"
:total="versionTotal"
v-model:page="versionQuery.pageNum"
v-model:limit="versionQuery.pageSize"
@pagination="loadVersions"
/>
</div>
<catalog-version-excel-drawer ref="excelRef" :list-fn="listRedlineVersionExcelFiles" />
<redline-country-drawer ref="countryRef" />
<redline-surcharge-drawer ref="surchargeRef" @success="() => tableRef.refresh()" />
<redline-expired-drawer ref="expiredRef" @view-country="(row) => countryRef.open(row)" />
<excel-import-dialog
ref="importRef"
title="红线价 Excel 导入"
width="560px"
:show-update-support="false"
template-url="/templates/product/product-import-all-template.xlsx"
template-file-name="产品多国家价表导入模板"
:import-desc="allImportDesc"
:before-submit="validateImportEffectiveDate"
:submit-handler="runImportAllAsync"
@success="() => tableRef.refresh()"
>
<template #extra>
<el-form label-width="90px">
<el-form-item label="生效时间" required>
<el-date-picker
v-model="importEffectiveDate"
type="datetime"
format="YYYY-MM-DD HH:00"
value-format="YYYY-MM-DD HH:00:00"
popper-class="redline-import-hour-picker"
placeholder="请选择生效时间"
style="width: 100%"
@change="onImportEffectiveTimeChange"
/>
</el-form-item>
</el-form>
</template>
</excel-import-dialog>
<excel-import-dialog
ref="countryImportRef"
title="按国家导入"
width="560px"
:show-update-support="false"
:before-submit="validateCountryImport"
:import-desc="countryImportDesc"
template-url="/templates/product/product-import-country-template.xlsx"
template-file-name="产品按国家导入模板"
:submit-handler="runImportCountryAsync"
@success="() => tableRef.refresh()"
>
<template #extra>
<el-form label-width="90px">
<el-form-item label="生效时间" required>
<el-date-picker
v-model="importEffectiveDate"
type="datetime"
format="YYYY-MM-DD HH:00"
value-format="YYYY-MM-DD HH:00:00"
popper-class="redline-import-hour-picker"
placeholder="请选择生效时间"
style="width: 100%"
@change="onImportEffectiveTimeChange"
/>
</el-form-item>
<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="正在导入产品"
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>
<catalog-version-products-drawer ref="versionProductsRef" :list-fn="listRedline" @view="(row) => countryRef.open(row)" />
<catalog-activate-dialog ref="activateRef" :load-versions="loadAllVersions" :activate-fn="activateRedlineVersion" @success="onActivated" />
</div>
</template>
<script setup name="PriceProduct">
import ExcelImportDialog from '@/components/ExcelImportDialog'
import RedlineFormDialog from './components/RedlineFormDialog.vue'
import RedlineExcelFileDrawer from './components/RedlineExcelFileDrawer.vue'
import RedlineCountryDrawer from './components/RedlineCountryDrawer.vue'
import RedlineSurchargeDrawer from './components/RedlineSurchargeDrawer.vue'
import RedlineExpiredDrawer from './components/RedlineExpiredDrawer.vue'
import { listRedline, delRedline, importRedlineExcelAsync, importRedlineCountryExcelAsync } from '@/api/price/redline'
import { listRegionOptions } from '@/api/data/region'
import CatalogVersionProductsDrawer from '../components/CatalogVersionProductsDrawer.vue'
import CatalogVersionExcelDrawer from '../components/CatalogVersionExcelDrawer.vue'
import CatalogActivateDialog from '../components/CatalogActivateDialog.vue'
import { listRedline, listRedlineVersions, activateRedlineVersion, deleteRedlineVersion, listRedlineVersionExcelFiles } from '@/api/price/redline'
const { proxy } = getCurrentInstance()
const { bms_product_effect_status } = useDict('bms_product_effect_status')
const extraParams = { statusGroup: 'active' }
const statusTab = ref('effect')
const extraParams = reactive({ statusGroup: 'effect' })
const versionProductsRef = ref()
const activateRef = ref()
const versionLoading = ref(false)
const versionRows = ref([])
const versionTotal = ref(0)
const versionQuery = reactive({ pageNum: 1, pageSize: 10 })
const tableRef = ref()
const formRef = ref()
const excelFileRef = ref()
const countryRef = ref()
const surchargeRef = ref()
const expiredRef = ref()
const importRef = ref()
const countryImportRef = ref()
const importCountryCode = ref('')
const importEffectiveDate = ref('')
function pad2(n) {
return String(n).padStart(2, '0')
}
function formatImportHour(d) {
if (!d || Number.isNaN(d.getTime())) return undefined
return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate())
+ ' ' + pad2(d.getHours()) + ':00:00'
}
function normalizeImportToHour(val) {
if (val == null || val === '') return val
if (val instanceof Date) return formatImportHour(val)
const s = String(val).trim().replace('T', ' ')
const m = s.match(/^(\d{4}-\d{2}-\d{2})[ ](\d{2})/)
if (m) return m[1] + ' ' + m[2] + ':00:00'
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s + ' 00:00:00'
return formatImportHour(new Date(s.replace(/-/g, '/'))) || val
}
function onImportEffectiveTimeChange(val) {
importEffectiveDate.value = normalizeImportToHour(val) || ''
}
const regionOptions = ref([])
const importProgressVisible = ref(false)
const importProgressPercent = ref(0)
const importProgressMessage = ref('')
const importProgressStatus = ref(undefined)
const allImportDesc = [
'适用多国家价表(B2C 小包价表等),按目录 sheet 匹配国家导入。',
'必选生效时间:产品与 product_country 明细均按此时间计费;上一版产品/明细失效时间设为该时间。',
'请在弹窗底部下载模板,按模板格式整理后上传。相同产品代码+生效日期不覆盖主数据。'
]
const countryImportDesc = [
'选择目的国家后上传专线成本价表,目录中的产品都会记到该国家。',
'必选生效时间:产品与 product_country 明细均按此时间计费;上一版产品/明细失效时间设为该时间。',
'邮编、赔偿标准等 sheet 不导入。可在弹窗底部下载模板。'
]
const excelRef = ref()
const columns = [
{ prop: 'productCode', label: '产品代码', minWidth: 110 },
@@ -232,7 +150,7 @@ const columns = [
{ prop: 'surchargeCount', label: '附加费数', width: 90, align: 'center' },
{ prop: 'status', label: '状态', width: 90, align: 'center', slot: 'status' },
{ prop: 'updateNote', label: '更新内容', minWidth: 160, showOverflowTooltip: true },
{ prop: 'action', label: '操作', width: 240, fixed: 'right', slot: 'action' }
{ prop: 'action', label: '操作', width: 120, fixed: 'right', slot: 'action' }
]
const searchColumns = [
@@ -245,176 +163,102 @@ function queryList(params) {
}
function handleAdd() {
formRef.value.open()
proxy.$router.push({ path: '/price/redline-create/index' })
}
function handleUpdate(row) {
formRef.value.open(row)
}
function isPendingProduct(row) {
if (!row) return false
if (String(row.status) === '0') return true
if (!row.effectiveDate) return false
const eff = new Date(String(row.effectiveDate).replace(/-/g, '/')).getTime()
if (Number.isNaN(eff) || eff <= Date.now()) return false
if (row.expireDate) {
const exp = new Date(String(row.expireDate).replace(/-/g, '/')).getTime()
if (!Number.isNaN(exp) && exp <= Date.now()) return false
}
return true
}
function handleBatchDelete(selection) {
const rows = (selection || []).filter(r => r && r.productId != null)
if (!rows.length) {
proxy.$modal.msgWarning('请先勾选红线价')
function openExcel(row) {
if (!row || row.psvId == null) {
proxy.$modal.msgWarning('该行没有版本,无法查看附件')
return
}
const pending = rows.filter(isPendingProduct)
const blocked = rows.filter(r => !isPendingProduct(r))
if (!pending.length) {
proxy.$modal.msgWarning('仅待生效红线价可删除,当前勾选无待生效记录')
excelRef.value && excelRef.value.open(row)
}
function onTabChange(name) {
if (name === 'pending' || name === 'expired') {
versionQuery.pageNum = 1
loadVersions()
return
}
if (blocked.length) {
const tip = blocked.map(r => r.productCode || r.productId).join('、')
proxy.$modal.msgWarning('已跳过非待生效红线价:' + tip)
extraParams.statusGroup = 'effect'
nextTick(() => tableRef.value && tableRef.value.refresh())
}
function versionStatusText(status) {
const map = { '0': '草稿', '1': '已生效', '2': '已失效', '3': '待生效' }
return map[String(status)] || status || '-'
}
function loadVersions() {
versionLoading.value = true
const params = {
pageNum: versionQuery.pageNum,
pageSize: versionQuery.pageSize
}
const codes = pending.map(r => r.productCode || r.productId).join('、')
const ids = pending.map(r => r.productId)
proxy.$modal.confirm(
'确认删除已选待生效红线价(' + pending.length + '个):'
+ codes
+ '?删除后产品及国家/重量段明细将一并清除,且不可恢复。'
).then(() => delRedline(ids)).then(() => {
tableRef.value.refresh()
if (statusTab.value === 'pending') params.statusGroup = 'pending'
else params.status = '2'
listRedlineVersions(params).then(res => {
versionRows.value = res.rows || []
versionTotal.value = res.total || 0
}).finally(() => { versionLoading.value = false })
}
function loadAllVersions() {
return listRedlineVersions({ pageNum: 1, pageSize: 500 }).then(res => res.rows || [])
}
function openVersionProducts(row) {
versionProductsRef.value && versionProductsRef.value.open(row)
}
function onEditVersion(row) {
if (!row || row.psvId == null) return
proxy.$router.push({
path: '/price/redline-create/index',
query: {
psvId: row.psvId,
versionNo: row.versionNo || undefined
}
})
}
function onStartVersion(row) {
if (row && row.psvId != null) {
activateRef.value && activateRef.value.open(row)
return
}
loadAllVersions().then(list => {
const pending = (list || []).filter(v => v && (String(v.status) === '0' || String(v.status) === '3'))
if (!pending.length) {
proxy.$modal.msgWarning('当前没有可启动的待生效版本')
return
}
pending.sort((a, b) => (Number(b.versionSeq) || 0) - (Number(a.versionSeq) || 0))
const draft = pending.find(v => String(v.status) === '0')
activateRef.value && activateRef.value.open(draft || pending[0])
})
}
function onDeleteVersion(row) {
if (!row || row.psvId == null) return
proxy.$modal.confirm('确认硬删除该待生效版本?版本、产品、明细将从数据库真删除,且不可恢复。').then(() => deleteRedlineVersion(row.psvId)).then(() => {
loadVersions()
proxy.$modal.msgSuccess('删除成功')
}).catch(() => {})
}
function handleImportCommand(command) {
if (command === 'country') {
handleCountryImport()
return
}
handleImport()
function onActivated() {
if (statusTab.value === 'pending' || statusTab.value === 'expired') loadVersions()
else if (tableRef.value) tableRef.value.refresh()
}
function handleImport() {
importEffectiveDate.value = ''
importRef.value.open()
}
function regionLabel(item) {
const name = item.countryNameCn || item.countryNameEn || ''
const code = item.countryCode2 || ''
return name && code ? name + ' (' + code + ')' : (name || code)
}
function loadRegionOptions() {
if (regionOptions.value.length) return
listRegionOptions().then(res => {
regionOptions.value = res.data || []
})
}
function handleCountryImport() {
importCountryCode.value = ''
importEffectiveDate.value = ''
loadRegionOptions()
countryImportRef.value.open()
}
function validateImportEffectiveDate() {
if (!importEffectiveDate.value) {
return '请先选择生效时间'
}
return ''
}
function validateCountryImport() {
const effErr = validateImportEffectiveDate()
if (effErr) return effErr
if (!importCountryCode.value) {
return '请先选择目的国家'
}
return ''
}
function applyImportProgress(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 === 'finishing') importProgressMessage.value = '收尾中…'
else if (m === 'queued') importProgressMessage.value = '排队中…'
else if (m.indexOf('importing ') === 0) {
const rest = m.slice('importing '.length)
importProgressMessage.value = '正在导入产品 ' + rest
} 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 runImportWithProgress(runner) {
importProgressVisible.value = true
importProgressPercent.value = 0
importProgressMessage.value = '上传并解析中…'
importProgressStatus.value = undefined
try {
const { task } = await runner(applyImportProgress)
importProgressPercent.value = 100
importProgressStatus.value = 'success'
return (task && task.message) ? task.message : '导入完成'
} catch (e) {
importProgressStatus.value = 'exception'
const tip = (e && e.task && e.task.error) || (e && e.message) || '导入失败'
importProgressMessage.value = tip
await new Promise(resolve => setTimeout(resolve, 600))
throw e
} finally {
importProgressVisible.value = false
}
}
async function runImportAllAsync(uploadFile) {
const raw = uploadFile && (uploadFile.raw || uploadFile)
if (!raw) {
throw new Error('请先选择文件')
}
if (!importEffectiveDate.value) {
throw new Error('请先选择生效时间')
}
const eff = importEffectiveDate.value
return runImportWithProgress((onProgress) => importRedlineExcelAsync(raw, eff, onProgress))
}
async function runImportCountryAsync(uploadFile) {
const raw = uploadFile && (uploadFile.raw || uploadFile)
if (!raw) {
throw new Error('请先选择文件')
}
if (!importEffectiveDate.value) {
throw new Error('请先选择生效时间')
}
if (!importCountryCode.value) {
throw new Error('请先选择目的国家')
}
const code = importCountryCode.value
const eff = importEffectiveDate.value
return runImportWithProgress((onProgress) => importRedlineCountryExcelAsync(raw, code, eff, onProgress))
}
</script>
<style scoped>
.mb16 { margin-bottom: 16px; }
.mb12 { margin-bottom: 12px; }
.catalog-tabs { margin-bottom: 8px; }
.import-progress { padding: 8px 4px 4px; }
.import-progress-msg {
margin-top: 12px;
@@ -143,6 +143,7 @@
width="560px"
:show-update-support="false"
:import-desc="importDesc"
:file-limit="5"
:submit-handler="runImportAsync"
@success="onImportSuccess"
/>
@@ -153,6 +154,7 @@
width="560px"
:show-update-support="false"
:import-desc="countryImportDesc"
:file-limit="5"
:before-submit="validateCountryImport"
:submit-handler="runCountryImportAsync"
@success="onImportSuccess"
@@ -482,7 +484,6 @@ function mergeLocalProducts(existing, incoming) {
const key = String(p.productCode).toUpperCase()
const prev = map.get(key) || {}
const next = { ...prev, ...p }
if (p.pefId == null && prev.pefId != null) next.pefId = prev.pefId
map.set(key, next)
}
return Array.from(map.values())
@@ -573,26 +574,32 @@ function validateCountryImport() {
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 raw = uploadFile && (uploadFile.raw || uploadFile)
if (!raw) {
const files = unwrapUploadFiles(uploadFile)
if (!files.length) {
throw new Error('请先选择文件')
}
// Do not open a second el-dialog over ExcelImportDialog: closing the overlay
// click-throughs into el-upload and EP crashes (inputRef.value.value = '').
// Excel build-price (IMPORT mode): merge by product+country. Modify-price / other modes unchanged.
const replaceByProductCountry = !isModifyImport.value || !!countryCode
if (!props.livePersist) {
const res = await previewImportDetailPrices(raw, {
const res = await previewImportDetailPrices(files, {
updateOnly: isModifyImport.value && !countryCode,
replaceByProductCountry,
countryCode: countryCode || undefined,
details: props.details || []
})
const data = res.data || {}
if (data.pefId != null) {
emit('update:pefId', data.pefId)
}
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 || []))
@@ -600,7 +607,7 @@ async function runImportAsync(uploadFile, countryCode) {
return data.message || '导入完成'
}
const { task } = await importSetDetailPricesAsync(
raw,
files,
{
priceSetId: props.priceSetId,
psvId: await resolvePsvId(false),
+13 -1
View File
@@ -113,6 +113,7 @@ const form = reactive({
})
/** Draft IMPORT Excel attachment id; stamped onto prc_price_set_product on finish */
const draftPefId = ref(undefined)
const draftPefIds = ref([])
const savedPriceBuildMode = ref(undefined)
const savedProductsSnapshot = ref([])
const buildModeDirty = computed(() =>
@@ -314,7 +315,9 @@ function buildPayload(opts = {}) {
priceBuildMode: version.priceBuildMode || undefined
}
}
if (draftPefId.value != null) {
if (draftPefIds.value.length) {
payload.pefIds = draftPefIds.value.slice()
} else if (draftPefId.value != null) {
payload.pefId = draftPefId.value
}
if (opts.forceDetails || hasUnsavedDetail) {
@@ -376,6 +379,11 @@ function onPsvIdUpdate(psvId) {
function onPefIdUpdate(pefId) {
draftPefId.value = pefId
if (pefId == null || pefId === '') return
const id = String(pefId)
if (!draftPefIds.value.some(x => String(x) === id)) {
draftPefIds.value.push(pefId)
}
}
/** Step2 chose PULL / IMPORT: edit live-saves mode; create only local + optional preview pull. */
@@ -391,6 +399,7 @@ function onPriceBuildModeUpdate(mode) {
form.version.priceBuildMode = mode
if (mode !== 'IMPORT') {
draftPefId.value = undefined
draftPefIds.value = []
}
form.details.splice(0, form.details.length)
if (detailsRef.value && detailsRef.value.clearLocalDraft) {
@@ -485,6 +494,7 @@ function applySetData(data, asCopy, asNewVersion) {
form.version.status = undefined
form.version.priceBuildMode = undefined
draftPefId.value = undefined
draftPefIds.value = []
savedPriceBuildMode.value = undefined
form.details = []
form.products = []
@@ -498,6 +508,7 @@ function applySetData(data, asCopy, asNewVersion) {
form.version.status = (resume && resume.status) || '0'
form.version.priceBuildMode = undefined
draftPefId.value = undefined
draftPefIds.value = []
savedPriceBuildMode.value = undefined
form.details = []
form.products = []
@@ -509,6 +520,7 @@ function applySetData(data, asCopy, asNewVersion) {
form.version.status = current.status
form.version.priceBuildMode = current.priceBuildMode || undefined
draftPefId.value = undefined
draftPefIds.value = []
savedPriceBuildMode.value = current.priceBuildMode || undefined
form.details = []
form.products = (current.products || []).map(p => ({ ...p }))
@@ -421,7 +421,6 @@ export function toDetailPayload(d) {
return {
psdId: d.psdId || undefined,
psvId: d.psvId || undefined,
priceSetId: d.priceSetId || undefined,
productCode: d.productCode,
productName: d.productName,
countryCode: d.countryCode || undefined,