应付账单初始化

This commit is contained in:
caojia
2026-11-04 16:04:38 +08:00
committed by liuminghui
parent 04be15deab
commit 51a89b18e1
90 changed files with 15661 additions and 6 deletions
@@ -0,0 +1,341 @@
<template>
<div v-if="panes.length" class="pay-detail">
<section v-for="pane in panes" :key="pane.key" class="pay-product">
<div class="pay-product-title">{{ pane.item.productName || '未命名产品' }}</div>
<el-descriptions :column="3" border size="small" class="pay-summary">
<el-descriptions-item label="价卡归属">{{ pane.item.supplierName || '-' }}</el-descriptions-item>
<el-descriptions-item label="生效日期">{{ pane.item.effectiveDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="国家/口岸">{{ pane.item.countryScope || '-' }}</el-descriptions-item>
<el-descriptions-item label="费用类型">{{ pane.item.chargeType || '-' }}</el-descriptions-item>
<el-descriptions-item label="每公斤加价">{{ pane.item.extraPerKg || '-' }}</el-descriptions-item>
<el-descriptions-item label="服务类型">{{ pane.item.serviceType || '-' }}</el-descriptions-item>
<el-descriptions-item label="分类">{{ pane.item.classification || '-' }}</el-descriptions-item>
<el-descriptions-item label="币种">{{ pane.item.currency || '-' }}</el-descriptions-item>
<el-descriptions-item label="报价工作表">{{ pane.item.sheetName || '-' }}</el-descriptions-item>
<el-descriptions-item label="关联分区表">{{ pane.item.relatedSheet || '-' }}</el-descriptions-item>
<el-descriptions-item label="识别结果">{{ statusText(pane.item.parseStatus) }}</el-descriptions-item>
<el-descriptions-item label="重量限制">{{ pane.item.weightLimit || '-' }}</el-descriptions-item>
<el-descriptions-item label="更新内容" :span="2">{{ pane.item.updateContent || '-' }}</el-descriptions-item>
<el-descriptions-item label="目录备注" :span="3">{{ pane.item.catalogRemark || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="pane.item.remark" label="备注" :span="3">{{ pane.item.remark }}</el-descriptions-item>
</el-descriptions>
<el-tabs v-model="pane.tab">
<el-tab-pane :label="`重量段(${(pane.item.weightBands || []).length})`" name="band">
<el-input v-model="pane.bandKeyword" clearable placeholder="筛选国家、分区、重量段、费用项" class="pay-filter" @input="pane.bandPageNo = 1" />
<div v-if="editable" class="pay-edit-bar">
<el-button size="small" type="primary" plain @click="addBand(pane)">添加重量段</el-button>
</div>
<el-table :data="pageBands(pane)" border size="small" max-height="360" empty-text="没有重量段或起重配置">
<el-table-column prop="sectionName" label="区块" min-width="110" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.sectionName" :edit="editable" /></template>
</el-table-column>
<el-table-column prop="countryName" label="国家" min-width="120" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.countryName" :edit="editable" /></template>
</el-table-column>
<el-table-column prop="countryCode" label="代码" width="80" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.countryCode" :edit="editable" /></template>
</el-table-column>
<el-table-column prop="zoneName" label="分区" min-width="140" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.zoneName" :edit="editable" /></template>
</el-table-column>
<el-table-column prop="weightLabel" label="重量段" min-width="120" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.weightLabel" :edit="editable" /></template>
</el-table-column>
<el-table-column label="下限KG" width="90" align="right">
<template #default="{ row }">{{ money(row.weightMin) }}</template>
</el-table-column>
<el-table-column label="上限KG" width="90" align="right">
<template #default="{ row }">{{ money(row.weightMax) }}</template>
</el-table-column>
<el-table-column prop="chargeName" label="费用项" min-width="120" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.chargeName" :edit="editable" /></template>
</el-table-column>
<el-table-column label="单价" width="110" align="right">
<template #default="{ row }">
<el-input v-if="editable" v-model="row.price" size="small" />
<span v-else>{{ money(row.price) }}</span>
</template>
</el-table-column>
<el-table-column prop="priceUnit" label="单位" width="110" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.priceUnit" :edit="editable" /></template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="70" align="center">
<template #default="{ row }">
<el-button link type="danger" @click="removeRow(pane.item.weightBands, row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="filteredBands(pane).length > pageSize"
class="pay-page"
layout="total, prev, pager, next"
:total="filteredBands(pane).length"
:page-size="pageSize"
v-model:current-page="pane.bandPageNo"
/>
</el-tab-pane>
<el-tab-pane :label="`附加费(${(pane.item.surcharges || []).length})`" name="fee">
<div class="pay-edit-bar">
<el-input v-model="pane.feeKeyword" clearable placeholder="筛选附加费用、地区、收费标准或费用解释" class="pay-filter" @input="pane.feePageNo = 1" />
<el-button v-if="editable" size="small" type="primary" plain @click="addFee(pane)">添加附加费</el-button>
</div>
<el-table :data="pageFees(pane)" border size="small" max-height="360" empty-text="没有附加费">
<el-table-column prop="feeName" label="附加费用" min-width="160" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.feeName" :edit="editable" /></template>
</el-table-column>
<el-table-column prop="zoneName" label="地区" min-width="160" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.zoneName" :edit="editable" /></template>
</el-table-column>
<el-table-column prop="chargeText" label="收费标准" min-width="180" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.chargeText" :edit="editable" /></template>
</el-table-column>
<el-table-column prop="feeDesc" label="费用解释" min-width="240" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.feeDesc" :edit="editable" /></template>
</el-table-column>
<el-table-column prop="calcRule" label="计费条件" width="140">
<template #default="{ row }">
<el-select v-if="editable" v-model="row.calcRule" size="small" placeholder="人工确认">
<el-option v-for="item in calcRules" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<span v-else>{{ calcRuleLabel(row.calcRule) }}</span>
</template>
</el-table-column>
<el-table-column prop="calcParam" label="条件参数" min-width="180" show-overflow-tooltip>
<template #default="{ row }"><cell-text v-model="row.calcParam" :edit="editable" /></template>
</el-table-column>
<el-table-column v-if="editable" label="操作" width="70" align="center">
<template #default="{ row }">
<el-button link type="danger" @click="removeRow(pane.item.surcharges, row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-if="filteredFees(pane).length > pageSize"
class="pay-page"
layout="total, prev, pager, next"
:total="filteredFees(pane).length"
:page-size="pageSize"
v-model:current-page="pane.feePageNo"
/>
</el-tab-pane>
<el-tab-pane v-if="postcodeLines(pane).length" :label="`邮编分区(${postcodeLines(pane).length})`" name="postcode">
<p class="pay-hint">{{ postcodeHint(pane) }}</p>
<el-input v-model="pane.postKeyword" clearable placeholder="筛选邮编、州、城市或分区" class="pay-filter" @input="pane.postPageNo = 1" />
<el-table :data="pagePostcodes(pane)" border size="small" max-height="360" empty-text="没有邮编">
<el-table-column v-for="(title, index) in postcodeTitles(pane)" :key="title" :label="title" min-width="110" show-overflow-tooltip>
<template #default="{ row }">{{ row.parts[index] || '-' }}</template>
</el-table-column>
</el-table>
<el-pagination
v-if="filteredPostcodes(pane).length > pageSize"
class="pay-page"
layout="total, prev, pager, next"
:total="filteredPostcodes(pane).length"
:page-size="pageSize"
v-model:current-page="pane.postPageNo"
/>
</el-tab-pane>
</el-tabs>
</section>
</div>
</template>
<script setup>
// 重量段和附加费。传入 products 时一次展示全部产品。
import { h } from 'vue'
import { ElInput } from 'element-plus'
const props = defineProps({
product: {
type: Object,
default: null
},
products: {
type: Array,
default: () => []
},
editable: {
type: Boolean,
default: false
}
})
const CellText = {
props: { modelValue: { default: '' }, edit: Boolean },
emits: ['update:modelValue'],
setup(props, { emit }) {
return () => props.edit
? h(ElInput, {
modelValue: props.modelValue ?? '',
size: 'small',
'onUpdate:modelValue': value => emit('update:modelValue', value)
})
: h('span', props.modelValue || '-')
}
}
const calcRules = [
{ value: 'ALWAYS', label: '每票都收' },
{ value: 'POSTCODE', label: '命中邮编' },
{ value: 'WEIGHT', label: '超重' },
{ value: 'SIZE', label: '超尺寸' },
{ value: 'RETURN', label: '退件' },
{ value: 'MANUAL', label: '人工确认' }
]
function calcRuleLabel(value) {
return calcRules.find(item => item.value === value)?.label || '人工确认'
}
const pageSize = 20
const panes = ref([])
const source = computed(() => (props.products?.length ? props.products : (props.product ? [props.product] : [])))
watch(source, (list) => {
panes.value = list.map((item, index) => ({
key: item.productId || item.rowKey || `${item.sheetName || ''}-${item.productName || ''}-${index}`,
item,
tab: 'band',
bandKeyword: '',
feeKeyword: '',
postKeyword: '',
bandPageNo: 1,
feePageNo: 1,
postPageNo: 1
}))
}, { immediate: true })
function filteredBands(pane) {
return filterRows(pane.item.weightBands || [], pane.bandKeyword, ['sectionName', 'countryName', 'countryCode', 'zoneName', 'weightLabel', 'chargeName', 'priceUnit'])
}
function filteredFees(pane) {
return filterRows(pane.item.surcharges || [], pane.feeKeyword, ['feeName', 'zoneName', 'chargeText', 'feeDesc', 'calcRule', 'calcParam'])
}
function addBand(pane) {
if (!pane.item.weightBands) pane.item.weightBands = []
pane.item.weightBands.push({ sectionName: '', countryName: '', countryCode: '', zoneName: '', weightLabel: '', chargeName: '', price: '', priceUnit: '' })
pane.bandPageNo = Math.ceil(pane.item.weightBands.length / pageSize) || 1
}
function addFee(pane) {
if (!pane.item.surcharges) pane.item.surcharges = []
pane.item.surcharges.push({ feeName: '', zoneName: '', chargeText: '', feeDesc: '', calcRule: 'MANUAL', calcParam: '' })
pane.feePageNo = Math.ceil(pane.item.surcharges.length / pageSize) || 1
}
function removeRow(list, row) {
const index = list.indexOf(row)
if (index >= 0) list.splice(index, 1)
}
function pageBands(pane) {
return pageOf(filteredBands(pane), pane.bandPageNo)
}
function pageFees(pane) {
return pageOf(filteredFees(pane), pane.feePageNo)
}
function postcodeLines(pane) {
const related = pane.item.relatedSheet || ''
if (!related.includes('邮编')) return []
return (pane.item.extraLines || []).filter(line => line.lineType === '邮编')
}
function postcodeTitles(pane) {
const sheet = `${pane.item.sheetName || ''}${pane.item.productName || ''}`
if (sheet.includes('DE-iMile')) return ['国家', '产品', '邮编', '状态']
return ['州/地区', '城市', '地区代码', '邮编', '分区']
}
function postcodeHint(pane) {
const sheet = `${pane.item.sheetName || ''}${pane.item.productName || ''}`
if (sheet.includes('DE-iMile')) {
return '德国这块没有州和城市。状态为 Active 的邮编属于价卡里的德国邮编清单,走同一套重量价格。'
}
return '用邮编对应州和城市。分区已换成价卡上的列:city(dense) 为 City,province(no dense) 为 Province,rural(remote) 为 Rural。'
}
function filteredPostcodes(pane) {
const text = (pane.postKeyword || '').trim().toLowerCase()
const rows = postcodeLines(pane).map(line => ({ parts: String(line.lineText || '').split(' | '), raw: line.lineText || '' }))
if (!text) return rows
return rows.filter(row => row.raw.toLowerCase().includes(text))
}
function pagePostcodes(pane) {
return pageOf(filteredPostcodes(pane), pane.postPageNo)
}
function filterRows(rows, keyword, fields) {
const text = (keyword || '').trim().toLowerCase()
if (!text) return rows
return rows.filter(row => fields.some(field => String(row?.[field] ?? '').toLowerCase().includes(text)))
}
function pageOf(rows, pageNo) {
const start = (pageNo - 1) * pageSize
return rows.slice(start, start + pageSize)
}
function money(value) {
if (value === null || value === undefined || value === '') return '-'
const num = Number(value)
return Number.isNaN(num) ? value : String(num)
}
function periodLabel(row) {
if (row.effectiveStart || row.effectiveEnd) {
return `${row.effectiveStart || '…'} 至 ${row.effectiveEnd || '…'}`
}
return row.periodText || row.chargeTiming || '-'
}
function statusText(status) {
return { OK: '已识别', EMPTY: '未识别到配置', MISSING: '缺少工作表' }[status] || status || '-'
}
</script>
<style scoped>
.pay-product + .pay-product {
margin-top: 18px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-lighter);
}
.pay-product-title {
margin-bottom: 8px;
font-size: 15px;
font-weight: 600;
}
.pay-summary {
margin-bottom: 12px;
}
.pay-filter {
width: 320px;
}
.pay-edit-bar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.pay-hint {
margin: 0 0 8px;
color: var(--el-text-color-secondary);
font-size: 13px;
line-height: 1.5;
}
.pay-page {
margin-top: 8px;
justify-content: flex-end;
}
.limit-tag {
margin-right: 6px;
}
</style>
@@ -0,0 +1,286 @@
<template>
<el-dialog v-model="visible" title="导入应付价卡" width="1080px" append-to-body destroy-on-close @closed="reset">
<el-form label-width="88px" class="import-form">
<el-form-item label="支付类型" required>
<el-select v-model="productType" filterable placeholder="请选择支付类型" style="width: 420px">
<el-option v-for="item in bms_pay_type" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="产品">
<el-select v-model="dictValue" clearable filterable placeholder="不选则按工作表名称匹配全部字典产品" style="width: 420px">
<el-option v-for="item in payment_product_type" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</el-form>
<el-upload
:key="uploadKey"
drag
accept=".xlsx"
:auto-upload="false"
:show-file-list="false"
:disabled="recognizing"
@change="onFile"
>
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div class="el-upload__text">将 xlsx 价卡拖到此处,或<em>点击选择</em></div>
<template #tip>
<div class="upload-tip">选一个产品就只读对应工作表。不选时,一个文件里有多张表,只读取名称能对上字典的工作表。</div>
</template>
</el-upload>
<div v-if="preview" v-loading="recognizing" class="preview-box">
<el-alert
:title="`${preview.sourceFile || ''} 归属 ${preview.supplierName || '-'} 生效 ${preview.effectiveDate || '-'} 共 ${products.length} 个产品`"
type="info"
:closable="false"
show-icon
/>
<div class="preview-bar">
<el-switch v-model="overwrite" active-text="已存在时另存为新版本" />
<span class="preview-hint">未开启时,已存在的产品不能勾选,保存时会跳过。开启后产品编号不变,旧价格留在历史里,新价格从今天起生效。</span>
</div>
<el-table
ref="tableRef"
:data="products"
border
size="small"
max-height="280"
row-key="rowKey"
@selection-change="onSelect"
>
<el-table-column type="selection" width="48" :selectable="canSelect" />
<el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip />
<el-table-column prop="sheetName" label="报价工作表" min-width="140" show-overflow-tooltip />
<el-table-column prop="countryScope" label="国家/口岸" min-width="110" show-overflow-tooltip />
<el-table-column prop="relatedSheet" label="关联分区表" min-width="120" show-overflow-tooltip />
<el-table-column label="生效日期" width="110">
<template #default="{ row }">{{ row.effectiveDate || preview.effectiveDate || '-' }}</template>
</el-table-column>
<el-table-column prop="weightCount" label="重量段" width="80" align="right" />
<el-table-column prop="surchargeCount" label="附加费" width="80" align="right" />
<el-table-column label="识别" width="120">
<template #default="{ row }">
<el-tag :type="statusType(row.parseStatus)" size="small">{{ statusText(row.parseStatus) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="库中" width="90">
<template #default="{ row }">
<el-tag v-if="row.duplicated" type="warning" size="small">已存在</el-tag>
<span v-else>新记录</span>
</template>
</el-table-column>
</el-table>
<div v-if="skippedText" class="upload-tip">未匹配字典、已跳过:{{ skippedText }}</div>
<div class="sheet-scroll">
<button
v-for="item in products"
:key="item.rowKey"
type="button"
class="sheet-tab"
:class="{ active: activeKey === item.rowKey }"
@click="activeKey = item.rowKey"
>{{ item.sheetName || item.productName }}</button>
</div>
<div class="preview-detail">
<pay-config-detail v-if="activeProduct" :product="activeProduct" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selected.length" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import { previewPay, savePay } from '@/api/price/pay'
import { useDict } from '@/utils/dict'
import PayConfigDetail from './PayConfigDetail.vue'
const emit = defineEmits(['saved'])
const { proxy } = getCurrentInstance()
const { payment_product_type, bms_pay_type } = useDict('payment_product_type', 'bms_pay_type')
const visible = ref(false)
const recognizing = ref(false)
const saving = ref(false)
const overwrite = ref(false)
const dictValue = ref('')
const productType = ref('AP')
const preview = ref(null)
const selected = ref([])
const tableRef = ref()
const activeKey = ref('')
const uploadKey = ref(0)
const products = computed(() => preview.value?.products || [])
const activeProduct = computed(() => products.value.find(item => item.rowKey === activeKey.value) || products.value[0])
const skippedText = computed(() => (preview.value?.skippedSheets || []).filter(Boolean).join('、'))
function open() {
reset()
visible.value = true
}
function reset() {
preview.value = null
selected.value = []
overwrite.value = false
dictValue.value = ''
productType.value = 'AP'
activeKey.value = ''
recognizing.value = false
saving.value = false
uploadKey.value += 1
}
async function onFile(uploadFile) {
const raw = uploadFile?.raw
if (!raw) return
if (!raw.name.toLowerCase().endsWith('.xlsx')) {
proxy.$modal.msgError('请上传xlsx价卡')
return
}
recognizing.value = true
try {
const res = await previewPay(raw, dictValue.value)
const data = res.data || {}
data.products = (data.products || []).map((item, index) => ({
...item,
rowKey: `${item.sheetName || ''}-${item.productName || ''}-${index}`
}))
preview.value = data
activeKey.value = data.products[0]?.rowKey || ''
const picked = data.products.filter(row => canSelect(row))
selected.value = picked
await nextTick()
tableRef.value?.clearSelection()
picked.forEach(row => tableRef.value?.toggleRowSelection(row, true))
} finally {
recognizing.value = false
}
}
function canSelect(row) {
if (row.parseStatus === 'MISSING') return false
return overwrite.value || !row.duplicated
}
watch(overwrite, async () => {
if (!visible.value || !preview.value) return
const picked = products.value.filter(row => canSelect(row))
selected.value = picked
await nextTick()
tableRef.value?.clearSelection()
picked.forEach(row => tableRef.value?.toggleRowSelection(row, true))
})
function onSelect(rows) {
if (!visible.value) return
selected.value = rows || []
}
async function save() {
if (!productType.value) {
proxy.$modal.msgError('请选择支付类型')
return
}
if (!selected.value.length) {
proxy.$modal.msgError('请勾选要保存的产品')
return
}
const duplicated = selected.value.filter(row => row.duplicated).length
const tip = overwrite.value && duplicated
? `将保存 ${selected.value.length} 个产品,其中 ${duplicated} 个会保留旧版本并生成新版本。`
: `将保存 ${selected.value.length} 个产品。`
try {
await proxy.$modal.confirm(tip)
} catch {
return
}
saving.value = true
try {
const products = selected.value.map(row => ({ ...row, productType: productType.value }))
const res = await savePay({ overwrite: overwrite.value, products })
const message = res.msg || '保存成功'
reset()
visible.value = false
ElMessageBox.close()
proxy.$modal.msgSuccess(message)
emit('saved')
} finally {
saving.value = false
}
}
function statusText(status) {
return { OK: '已识别', EMPTY: '未识别到配置', MISSING: '缺少工作表' }[status] || status || '-'
}
function statusType(status) {
if (status === 'OK') return 'success'
if (status === 'EMPTY') return 'warning'
if (status === 'MISSING') return 'danger'
return 'info'
}
defineExpose({ open })
</script>
<style scoped>
.upload-icon {
font-size: 42px;
color: var(--el-text-color-secondary);
}
.upload-tip {
margin-top: 6px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.preview-box {
margin-top: 14px;
}
.preview-bar {
display: flex;
align-items: center;
gap: 12px;
margin: 10px 0;
}
.preview-hint {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.preview-detail {
max-height: 460px;
margin-top: 14px;
overflow: auto;
}
.import-form {
margin-bottom: 8px;
}
.sheet-scroll {
display: flex;
gap: 8px;
margin-top: 12px;
overflow-x: auto;
padding-bottom: 6px;
}
.sheet-tab {
flex: 0 0 auto;
border: 1px solid var(--el-border-color);
background: #fff;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
white-space: nowrap;
}
.sheet-tab.active {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
</style>
@@ -0,0 +1,396 @@
<template>
<div class="app-container">
<basic-table
ref="tableRef"
:columns="columns"
:search-columns="searchColumns"
:query="queryList"
:search-per-row="4"
search-action-align="right"
row-key="productId"
>
<template #toolbar="{ selection, ids }">
<el-col :span="1.5">
<el-button type="primary" plain icon="Upload" @click="importRef.open()" v-hasPermi="['price:pay:import']">导入价卡</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" plain icon="Delete" :disabled="!selection.length" @click="handleDelete(ids)" v-hasPermi="['price:pay:remove']">删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['price:pay:export']">导出列表</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="Download" @click="handleExportDetail" v-hasPermi="['price:pay:export']">导出明细</el-button>
</el-col>
</template>
<template #col-parseStatus="{ row }">
<el-tag :type="statusType(row.parseStatus)" size="small">{{ statusText(row.parseStatus) }}</el-tag>
</template>
<template #col-termRange="{ row }">
<span>{{ termText(row) }}</span>
</template>
<template #col-termStatus="{ row }">
<el-switch
v-if="row.termId"
:model-value="row.termStatus === '1' ? '1' : '0'"
active-value="0"
inactive-value="1"
inline-prompt
active-text="生效"
inactive-text="失效"
@change="(val) => changeStatus(row, 'termStatus', val)"
v-hasPermi="['price:pay:edit']"
/>
</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="openDetail(row)" v-hasPermi="['price:pay:query']">查看</el-button>
<el-button link type="primary" icon="Calendar" @click="openTermDialog(row)" v-hasPermi="['price:pay:edit']">有效期</el-button>
<el-button link type="primary" icon="Clock" @click="openHistory(row)" v-hasPermi="['price:pay:query']">历史</el-button>
<el-button link type="primary" icon="Delete" @click="handleDelete(row.productId)" v-hasPermi="['price:pay:remove']">删除</el-button>
</template>
</basic-table>
<pay-import-dialog ref="importRef" @saved="tableRef.refresh()" />
<el-drawer v-model="historyVisible" :title="historyTitle" size="860px" destroy-on-close>
<div class="history-bar">
<el-button type="primary" plain :disabled="historyPicked.length !== 2" @click="openCompare">比对所选版本</el-button>
<span>勾选两个版本后比对。历史订单按生效时间取价,不看当前开关。</span>
</div>
<el-table v-loading="historyLoading" :data="historyRows" border size="small" empty-text="还没有价格版本" @selection-change="onHistoryPick">
<el-table-column type="selection" width="42" align="center" />
<el-table-column prop="versionNo" label="版本" width="70" align="center" />
<el-table-column prop="sourceFile" label="来源文件" min-width="160" show-overflow-tooltip />
<el-table-column prop="weightCount" label="重量段" width="80" align="center" />
<el-table-column prop="surchargeCount" label="附加费" width="80" align="center" />
<el-table-column label="生效开始" width="110" align="center">
<template #default="{ row }">{{ dayText(row.effectiveStart) }}</template>
</el-table-column>
<el-table-column label="生效结束" width="110" align="center">
<template #default="{ row }">{{ dayText(row.effectiveEnd) || '不限' }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch
v-if="row.termId"
:model-value="row.status === '1' ? '1' : '0'"
active-value="0"
inactive-value="1"
inline-prompt
active-text="生效"
inactive-text="失效"
@change="(val) => changeStatus(row, 'status', val)"
v-hasPermi="['price:pay:edit']"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="150" align="center">
<template #default="{ row }">
<el-button link type="primary" @click="openTermDialog(row)" v-hasPermi="['price:pay:edit']">修改有效期</el-button>
<el-button link type="primary" @click="openDetail(row)" v-hasPermi="['price:pay:query']">查看</el-button>
</template>
</el-table-column>
</el-table>
</el-drawer>
<el-dialog v-model="termVisible" title="修改有效期" width="460px" append-to-body>
<el-form label-width="90px">
<el-form-item label="生效开始" required>
<el-date-picker v-model="termForm.effectiveStart" type="date" value-format="YYYY-MM-DD" placeholder="生效开始" style="width: 100%" />
</el-form-item>
<el-form-item label="生效结束">
<el-date-picker v-model="termForm.effectiveEnd" type="date" value-format="YYYY-MM-DD" placeholder="不填表示不限" style="width: 100%" clearable />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="termVisible = false">取消</el-button>
<el-button type="primary" :loading="termSaving" @click="saveTerm">保存</el-button>
</template>
</el-dialog>
<el-drawer v-model="compareVisible" :title="compareTitle" size="88%" append-to-body destroy-on-close>
<el-table v-loading="compareLoading" :data="compareRows" border size="small" max-height="640" empty-text="没有差异">
<el-table-column prop="kind" label="类型" width="90" align="center" />
<el-table-column prop="change" label="变化" width="80" align="center" />
<el-table-column prop="name" label="项目" min-width="160" show-overflow-tooltip />
<el-table-column prop="zoneName" label="地区" min-width="140" show-overflow-tooltip />
<el-table-column prop="oldValue" label="旧版" min-width="160" show-overflow-tooltip />
<el-table-column prop="newValue" label="新版" min-width="160" show-overflow-tooltip />
</el-table>
</el-drawer>
<el-drawer v-model="detailVisible" :title="detailTitle" size="78%" destroy-on-close>
<pay-config-detail v-loading="detailLoading" :product="detail" :editable="editing" />
<template #footer>
<el-button v-if="!editing" type="primary" @click="editing = true" v-hasPermi="['price:pay:import']">编辑</el-button>
<el-button v-else type="primary" :loading="saving" @click="saveDetail">保存</el-button>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-drawer>
</div>
</template>
<script setup name="PricePay">
import { listPay, getPay, delPay, updatePay, listPayTerm, updatePayTerm, changePayTermStatus, comparePay } from '@/api/price/pay'
import PayImportDialog from './components/PayImportDialog.vue'
import PayConfigDetail from './components/PayConfigDetail.vue'
const { proxy } = getCurrentInstance()
const tableRef = ref()
const importRef = ref()
const detailVisible = ref(false)
const detailLoading = ref(false)
const editing = ref(false)
const saving = ref(false)
const detail = ref(null)
const historyVisible = ref(false)
const historyLoading = ref(false)
const historyRows = ref([])
const historyTitle = ref('价格版本')
const historyProductId = ref(null)
const historyPicked = ref([])
const compareVisible = ref(false)
const compareLoading = ref(false)
const compareTitle = ref('版本比对')
const compareRows = ref([])
const detailTitle = computed(() => {
if (!detail.value) return '应付配置'
const no = detail.value.versionNo ? `第 ${detail.value.versionNo} 版` : ''
return `${detail.value.productName || '应付配置'} ${no}`.trim()
})
const termVisible = ref(false)
const termSaving = ref(false)
const termForm = reactive({ termId: null, productId: null, effectiveStart: '', effectiveEnd: '' })
const statusOptions = [
{ label: '已识别', value: 'OK' },
{ label: '未识别到配置', value: 'EMPTY' },
{ label: '缺少工作表', value: 'MISSING' }
]
const columns = [
{ label: '产品编号', prop: 'productId', align: 'center', width: 90 },
{ label: '产品名称', prop: 'productName', align: 'center', minWidth: 150, showOverflowTooltip: true },
{ label: '价卡归属', prop: 'supplierName', align: 'center', width: 100 },
{ label: '国家/口岸', prop: 'countryScope', align: 'center', minWidth: 120, showOverflowTooltip: true },
{ label: '费用类型', prop: 'chargeType', align: 'center', width: 110 },
{ label: '每公斤加价', prop: 'extraPerKg', align: 'center', width: 120 },
{ label: '报价工作表', prop: 'sheetName', align: 'center', minWidth: 140, showOverflowTooltip: true },
{ label: '关联分区表', prop: 'relatedSheet', align: 'center', minWidth: 140, showOverflowTooltip: true },
{ label: '版本', prop: 'versionNo', align: 'center', width: 70 },
{ label: '有效期', prop: 'termRange', align: 'center', minWidth: 180, showOverflowTooltip: true },
{ label: '状态', prop: 'termStatus', align: 'center', width: 100 },
{ label: '币种', prop: 'currency', align: 'center', width: 80 },
{ label: '重量段', prop: 'weightCount', align: 'center', width: 80 },
{ label: '附加费', prop: 'surchargeCount', align: 'center', width: 80 },
{ label: '识别结果', prop: 'parseStatus', align: 'center', width: 120 },
{ label: '来源文件', prop: 'sourceFile', align: 'center', minWidth: 180, showOverflowTooltip: true },
{ label: '创建时间', prop: 'createTime', align: 'center', width: 160, parseTime: true },
{ label: '操作', prop: 'action', align: 'center', width: 280, fixed: 'right', showInToolbar: false }
]
const searchColumns = [
{ label: '产品名称', prop: 'productName', component: 'input', placeholder: '产品名称', width: '180px' },
{ label: '价卡归属', prop: 'supplierName', component: 'input', placeholder: '威狮、易德、GWE', width: '160px' },
{ label: '来源文件', prop: 'sourceFile', component: 'input', placeholder: '文件名', width: '180px' },
{ label: '报价工作表', prop: 'sheetName', component: 'input', placeholder: '工作表', width: '160px' },
{ label: '国家/口岸', prop: 'countryScope', component: 'input', placeholder: '国家或口岸', width: '160px' },
{ label: '服务类型', prop: 'serviceType', component: 'input', placeholder: '服务类型', width: '150px' },
{ label: '重量段', prop: 'weightKeyword', component: 'input', placeholder: '重量段、分区或费用项', width: '180px' },
{ label: '附加费', prop: 'surchargeName', component: 'input', placeholder: '名称、说明或时效', width: '180px' },
{ label: '生效日期', prop: 'effectiveRange', component: 'daterange', width: '280px' },
{ label: '识别结果', prop: 'parseStatus', component: 'select', options: statusOptions, placeholder: '全部', width: '150px' }
]
function queryList(params) {
return listPay({ ...toQuery(params), templateOnly: true, currentOnly: true })
}
function toQuery(params) {
const query = { ...params }
if (query.effectiveRange?.length === 2) {
query.effectiveBegin = query.effectiveRange[0]
query.effectiveEnd = query.effectiveRange[1]
}
delete query.effectiveRange
return query
}
function currentQuery() {
const query = toQuery(tableRef.value?.getQueryParams() || {})
delete query.pageNum
delete query.pageSize
query.templateOnly = true
query.currentOnly = true
return query
}
function handleExport() {
proxy.download('/price/pay/export', currentQuery(), `应付配置_${Date.now()}.xlsx`)
}
function handleExportDetail() {
proxy.download('/price/pay/exportDetail', currentQuery(), `应付配置明细_${Date.now()}.xlsx`)
}
function handleDelete(ids) {
const idText = Array.isArray(ids) ? ids.join(',') : ids
if (!idText) return
proxy.$modal.confirm('确认删除所选应付产品?重量段和附加费会一起删除。').then(() => {
return delPay(idText)
}).then(() => {
proxy.$modal.msgSuccess('删除成功')
tableRef.value.refresh()
}).catch(() => {})
}
function openDetail(row) {
detailVisible.value = true
editing.value = false
detailLoading.value = true
detail.value = null
getPay(row.versionProductId || row.productId).then(res => {
detail.value = res.data
}).finally(() => {
detailLoading.value = false
})
}
function saveDetail() {
cleanMoney(detail.value)
saving.value = true
updatePay(detail.value).then(() => {
proxy.$modal.msgSuccess('已保存这一版。产品编号不变,绑定该产品的渠道按有效期取价')
editing.value = false
tableRef.value.refresh()
}).finally(() => {
saving.value = false
})
}
function cleanMoney(product) {
;(product?.weightBands || []).forEach(row => {
if (row.price === '') row.price = null
})
}
function dayText(value) {
if (!value) return ''
return typeof value === 'string' ? value.slice(0, 10) : proxy.parseTime(value, '{y}-{m}-{d}')
}
function termText(row) {
const start = dayText(row.termStart || row.effectiveDate)
const end = dayText(row.termEnd)
if (!start && !end) return '-'
return `${start || '不限'} 至 ${end || '不限'}`
}
function openHistory(row) {
historyProductId.value = row.productId
historyTitle.value = `${row.productName || '产品'}(编号 ${row.productId})的价格版本`
historyPicked.value = []
historyVisible.value = true
loadHistory()
}
function onHistoryPick(rows) {
historyPicked.value = rows || []
}
function openCompare() {
if (historyPicked.value.length !== 2) return
const [first, second] = historyPicked.value
compareVisible.value = true
compareLoading.value = true
compareRows.value = []
comparePay(first.productId, second.productId).then(res => {
const data = res.data || {}
const older = data.older?.versionNo || '-'
const newer = data.newer?.versionNo || '-'
compareTitle.value = `第 ${older} 版 对比 第 ${newer} 版`
const weights = (data.weightDiffs || []).map(row => ({ ...row, kind: '重量段' }))
const fees = (data.surchargeDiffs || []).map(row => ({ ...row, kind: '附加费' }))
compareRows.value = weights.concat(fees).filter(row => row.change !== '相同')
}).finally(() => {
compareLoading.value = false
})
}
function loadHistory() {
if (!historyProductId.value) return
historyLoading.value = true
listPayTerm(historyProductId.value).then(res => {
historyRows.value = res.data || []
}).finally(() => {
historyLoading.value = false
})
}
function openTermDialog(row) {
termForm.termId = row.termId
termForm.productId = row.productId
termForm.effectiveStart = dayText(row.termStart || row.effectiveStart || row.effectiveDate)
termForm.effectiveEnd = dayText(row.termEnd || row.effectiveEnd)
termVisible.value = true
}
function saveTerm() {
if (!termForm.effectiveStart) {
proxy.$modal.msgError('请填写生效开始')
return
}
termSaving.value = true
updatePayTerm({
termId: termForm.termId,
productId: termForm.productId,
effectiveStart: termForm.effectiveStart,
effectiveEnd: termForm.effectiveEnd || null
}).then(() => {
proxy.$modal.msgSuccess('有效期已保存')
termVisible.value = false
tableRef.value.refresh()
if (historyVisible.value) loadHistory()
}).finally(() => {
termSaving.value = false
})
}
function changeStatus(row, field, status) {
if (!row?.termId || (status !== '0' && status !== '1') || row[field] === status) return
const previous = row[field]
row[field] = status
changePayTermStatus({ termId: row.termId, productId: row.productId, status }).then(() => {
proxy.$modal.msgSuccess(status === '0' ? '已设为生效' : '已设为失效')
tableRef.value.refresh()
if (historyVisible.value) loadHistory()
}).catch(() => {
row[field] = previous
})
}
function statusText(status) {
return statusOptions.find(item => item.value === status)?.label || status || '-'
}
function statusType(status) {
if (status === 'OK') return 'success'
if (status === 'EMPTY') return 'warning'
if (status === 'MISSING') return 'danger'
return 'info'
}
</script>
<style scoped>
.history-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
color: var(--el-text-color-secondary);
}
</style>