优化
This commit is contained in:
@@ -0,0 +1,596 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { AlertTriangle, ArrowLeft, CircleCheck, Download, FileSpreadsheet, RefreshCw, Ship, Warehouse } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import PageToolbar from '@/shared/components/PageToolbar.vue'
|
||||
import {
|
||||
downloadPlatformPriceBookSource,
|
||||
getPlatformPriceBook,
|
||||
publishPlatformPriceBook,
|
||||
type PlatformPriceBookDetail,
|
||||
type PlatformPriceBookParseIssue,
|
||||
type PlatformPriceBookParsedRate,
|
||||
type PlatformPriceBookVersion,
|
||||
type PriceBookConfirmationRequest,
|
||||
type PriceBookStatus,
|
||||
} from '../api/platformPriceBooks'
|
||||
|
||||
interface OriginMappingRow {
|
||||
key: string
|
||||
sourceCode: string
|
||||
sourceName: string
|
||||
warehouseId: string | null
|
||||
}
|
||||
|
||||
interface OverseasMappingRow {
|
||||
key: string
|
||||
sourceName: string
|
||||
destinationCode: string | null
|
||||
warehouseId: string | null
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const versionId = computed(() => String(route.params.versionId || ''))
|
||||
const detail = ref<PlatformPriceBookDetail | null>(null)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const selectedChannelCode = ref('')
|
||||
const publishing = ref(false)
|
||||
const downloading = ref(false)
|
||||
const mappingWarehouses = ref<PlatformWarehouseResource[]>([])
|
||||
const mappingWarehousesLoaded = ref(false)
|
||||
const mappingWarehousesLoading = ref(false)
|
||||
const mappingWarehousesError = ref<ApiErrorDetails | null>(null)
|
||||
const customerOptions = ref<PlatformTenantResource[]>([])
|
||||
const originSelections = ref<Record<string, string>>({})
|
||||
const overseasSelections = ref<Record<string, string>>({})
|
||||
let detailSequence = 0
|
||||
let warehouseSequence = 0
|
||||
|
||||
const canEdit = computed(() => access.can('firstleg:fba-pricing:rate:edit'))
|
||||
const isPending = computed(() => detail.value?.version.status === 'PENDING_CONFIRMATION')
|
||||
const domesticWarehouses = computed(() => mappingWarehouses.value.filter((warehouse) => (
|
||||
warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC'
|
||||
)))
|
||||
const overseasWarehouses = computed(() => mappingWarehouses.value.filter((warehouse) => (
|
||||
warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'OVERSEAS'
|
||||
)))
|
||||
const originMappingRows = computed<OriginMappingRow[]>(() => {
|
||||
const unique = new Map<string, OriginMappingRow>()
|
||||
for (const rate of detail.value?.rates || []) {
|
||||
const sourceCode = rate.originWarehouseCode.trim()
|
||||
if (!sourceCode) continue
|
||||
const key = mappingKey(sourceCode)
|
||||
if (!unique.has(key)) unique.set(key, {
|
||||
key,
|
||||
sourceCode,
|
||||
sourceName: rate.originWarehouseName || sourceCode,
|
||||
warehouseId: rate.originWarehouseId,
|
||||
})
|
||||
}
|
||||
return [...unique.values()]
|
||||
})
|
||||
const overseasMappingRows = computed<OverseasMappingRow[]>(() => {
|
||||
const unique = new Map<string, OverseasMappingRow>()
|
||||
for (const rate of detail.value?.rates || []) {
|
||||
if (rate.destinationType !== 'OVERSEAS' || !rate.destinationName?.trim()) continue
|
||||
const sourceName = rate.destinationName.trim()
|
||||
const key = mappingKey(sourceName)
|
||||
if (!unique.has(key)) unique.set(key, {
|
||||
key,
|
||||
sourceName,
|
||||
destinationCode: rate.destinationCode,
|
||||
warehouseId: rate.destinationWarehouseId,
|
||||
})
|
||||
}
|
||||
return [...unique.values()]
|
||||
})
|
||||
const hasMissingOverseasSource = computed(() => (detail.value?.rates || []).some((rate) => (
|
||||
rate.destinationType === 'OVERSEAS' && !rate.destinationName?.trim()
|
||||
)))
|
||||
const selectedChannelRates = computed(() => {
|
||||
const rates = detail.value?.rates || []
|
||||
return selectedChannelCode.value ? rates.filter((rate) => rate.channelCode === selectedChannelCode.value) : rates
|
||||
})
|
||||
const selectedChannel = computed(() => detail.value?.channels.find((channel) => channel.channelCode === selectedChannelCode.value) || null)
|
||||
const mappingBlockingIssueCodes = new Set([
|
||||
'ORIGIN_MAPPING_REQUIRED',
|
||||
'ORIGIN_WAREHOUSE_INVALID',
|
||||
'OVERSEAS_MAPPING_REQUIRED',
|
||||
'OVERSEAS_WAREHOUSE_INVALID',
|
||||
'WAREHOUSE_VALIDATION_FAILED',
|
||||
'DUPLICATE_ORIGIN_MAPPING',
|
||||
'DUPLICATE_OVERSEAS_MAPPING',
|
||||
'UNUSED_ORIGIN_MAPPING',
|
||||
'UNUSED_OVERSEAS_MAPPING',
|
||||
'DUPLICATE_WAREHOUSE_MAPPING',
|
||||
'DUPLICATE_RATE',
|
||||
])
|
||||
const nonMappingBlockingIssues = computed(() => (detail.value?.issues || []).filter((issue) => (
|
||||
issue.severity === 'BLOCKING' && !mappingBlockingIssueCodes.has(issue.issueCode)
|
||||
)))
|
||||
const mappingsComplete = computed(() => (
|
||||
originMappingRows.value.length > 0
|
||||
&& originMappingRows.value.every((row) => Boolean(originSelections.value[row.key]))
|
||||
&& overseasMappingRows.value.every((row) => Boolean(overseasSelections.value[row.key]))
|
||||
))
|
||||
const mappingCandidatesMissing = computed(() => (
|
||||
originMappingRows.value.some((row) => !domesticWarehouses.value.some((warehouse) => originWarehouseMatches(row, warehouse)))
|
||||
|| overseasMappingRows.value.some((row) => !overseasWarehouses.value.some((warehouse) => overseasWarehouseMatches(row, warehouse)))
|
||||
))
|
||||
const canConfirm = computed(() => (
|
||||
isPending.value
|
||||
&& canEdit.value
|
||||
&& !loading.value
|
||||
&& !publishing.value
|
||||
&& !mappingWarehousesLoading.value
|
||||
&& mappingWarehousesLoaded.value
|
||||
&& !mappingWarehousesError.value
|
||||
&& !hasMissingOverseasSource.value
|
||||
&& !mappingCandidatesMissing.value
|
||||
&& !nonMappingBlockingIssues.value.length
|
||||
&& mappingsComplete.value
|
||||
))
|
||||
const confirmationHint = computed(() => {
|
||||
if (!isPending.value) return ''
|
||||
if (nonMappingBlockingIssues.value.length) return '当前文件仍有无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。'
|
||||
if (hasMissingOverseasSource.value) return '存在未识别名称的海外仓价格行,无法完成映射。'
|
||||
if (mappingWarehousesError.value) return '平台仓库候选加载失败,暂不能确认生效。'
|
||||
if (mappingWarehousesLoading.value || !mappingWarehousesLoaded.value) return '正在加载平台仓库候选。'
|
||||
if (mappingCandidatesMissing.value) return '上传表中的仓库编码尚未配置为匹配的启用平台公共仓库。'
|
||||
if (!mappingsComplete.value) return '请完成所有起运仓和海外仓映射。'
|
||||
return '确认后将立即替换当前生效版本,历史版本和原文件仍可查询。'
|
||||
})
|
||||
|
||||
function mappingKey(value: string) {
|
||||
return value.trim().toUpperCase()
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
return value ? value.replace('T', ' ').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
function formatEffectivePeriod(version: PlatformPriceBookVersion) {
|
||||
if (!version.publishedAt) return '尚未生效'
|
||||
const start = formatDateTime(version.publishedAt)
|
||||
return version.supersededAt ? `${start} 至 ${formatDateTime(version.supersededAt)}` : `${start} 起`
|
||||
}
|
||||
|
||||
function priceBookScopeLabel(customerId: string | null | undefined) {
|
||||
if (!customerId || customerId === '0') return '平台公共报价'
|
||||
const customer = customerOptions.value.find((item) => String(item.id) === String(customerId))
|
||||
return customer ? `客户专属 · ${customer.tenantName || '未命名客户'}` : '客户专属报价'
|
||||
}
|
||||
|
||||
function formatMoney(value: number, currency: string) {
|
||||
return `${currency} ${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`
|
||||
}
|
||||
|
||||
function priceBookStatusMeta(status: PriceBookStatus) {
|
||||
return {
|
||||
PENDING_CONFIRMATION: { label: '待确认', type: 'warning' as const },
|
||||
ACTIVE: { label: '生效中', type: 'success' as const },
|
||||
SUPERSEDED: { label: '已替换', type: 'info' as const },
|
||||
VOIDED: { label: '已作废', type: 'info' as const },
|
||||
FAILED: { label: '解析失败', type: 'danger' as const },
|
||||
}[status]
|
||||
}
|
||||
|
||||
function priceModelLabel(value: string) {
|
||||
return value === 'REGION_WEIGHT_TIER' ? '海派区域阶梯价' : '按仓价格'
|
||||
}
|
||||
|
||||
function pricingTaxModeLabel(value: string | null) {
|
||||
if (value === 'TAX_INCLUDED') return '包税'
|
||||
if (value === 'TAX_EXCLUDED') return '不包税'
|
||||
return '—'
|
||||
}
|
||||
|
||||
function rateDestinationLabel(rate: PlatformPriceBookParsedRate) {
|
||||
if (rate.destinationType === 'OVERSEAS') return rate.destinationName || rate.destinationCode || '—'
|
||||
if (rate.destinationType === 'FBA') return rate.destinationCode || rate.destinationName || '—'
|
||||
return rate.regionCode || '—'
|
||||
}
|
||||
|
||||
function issueLocation(issue: PlatformPriceBookParseIssue) {
|
||||
return [issue.sourceSheetName, issue.sourceRowNumber ? `第 ${issue.sourceRowNumber} 行` : null, issue.sourceCell]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || '—'
|
||||
}
|
||||
|
||||
function sourceLocation(rate: PlatformPriceBookParsedRate) {
|
||||
return `${rate.sourceSheetName} · 第 ${rate.sourceRowNumber} 行`
|
||||
}
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string) {
|
||||
const details = getApiErrorDetails(error, fallback)
|
||||
return details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message
|
||||
}
|
||||
|
||||
function safeFileName(value: string) {
|
||||
const safe = value.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim()
|
||||
return safe || '船期价格表.xlsx'
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
try {
|
||||
anchor.href = url
|
||||
anchor.download = safeFileName(fileName)
|
||||
document.body.append(anchor)
|
||||
anchor.click()
|
||||
} finally {
|
||||
anchor.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
}
|
||||
|
||||
function resetMappings(value: PlatformPriceBookDetail) {
|
||||
const origins: Record<string, string> = {}
|
||||
const overseas: Record<string, string> = {}
|
||||
for (const rate of value.rates) {
|
||||
const originCode = rate.originWarehouseCode.trim()
|
||||
if (originCode && rate.originWarehouseId) origins[mappingKey(originCode)] = rate.originWarehouseId
|
||||
if (rate.destinationType === 'OVERSEAS' && rate.destinationName?.trim() && rate.destinationWarehouseId) {
|
||||
overseas[mappingKey(rate.destinationName)] = rate.destinationWarehouseId
|
||||
}
|
||||
}
|
||||
originSelections.value = origins
|
||||
overseasSelections.value = overseas
|
||||
}
|
||||
|
||||
function autoSelectExactWarehouseMappings() {
|
||||
const origins = { ...originSelections.value }
|
||||
const overseas = { ...overseasSelections.value }
|
||||
const usedOrigins = new Set(Object.values(origins))
|
||||
const usedOverseas = new Set(Object.values(overseas))
|
||||
|
||||
for (const row of originMappingRows.value) {
|
||||
if (origins[row.key]) continue
|
||||
const matches = domesticWarehouses.value.filter((warehouse) => originWarehouseMatches(row, warehouse))
|
||||
if (matches.length !== 1) continue
|
||||
const warehouseId = String(matches[0].id)
|
||||
if (!usedOrigins.has(warehouseId)) {
|
||||
origins[row.key] = warehouseId
|
||||
usedOrigins.add(warehouseId)
|
||||
}
|
||||
}
|
||||
for (const row of overseasMappingRows.value) {
|
||||
if (overseas[row.key]) continue
|
||||
const matches = overseasWarehouses.value.filter((warehouse) => overseasWarehouseMatches(row, warehouse))
|
||||
if (matches.length !== 1) continue
|
||||
const warehouseId = String(matches[0].id)
|
||||
if (!usedOverseas.has(warehouseId)) {
|
||||
overseas[row.key] = warehouseId
|
||||
usedOverseas.add(warehouseId)
|
||||
}
|
||||
}
|
||||
originSelections.value = origins
|
||||
overseasSelections.value = overseas
|
||||
}
|
||||
|
||||
function applyDetail(value: PlatformPriceBookDetail) {
|
||||
detail.value = value
|
||||
selectedChannelCode.value = value.channels[0]?.channelCode || ''
|
||||
resetMappings(value)
|
||||
if (mappingWarehousesLoaded.value) autoSelectExactWarehouseMappings()
|
||||
}
|
||||
|
||||
async function loadCustomerOptions() {
|
||||
try {
|
||||
const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })
|
||||
customerOptions.value = result.records.filter((customer) => customer.status === 'ACTIVE')
|
||||
} catch {
|
||||
customerOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMappingWarehouses(force = false) {
|
||||
if (mappingWarehousesLoaded.value && !force) return true
|
||||
const sequence = ++warehouseSequence
|
||||
mappingWarehousesLoading.value = true
|
||||
mappingWarehousesError.value = null
|
||||
try {
|
||||
const result = await listAllPlatformWarehouses()
|
||||
if (sequence !== warehouseSequence) return false
|
||||
mappingWarehouses.value = result
|
||||
mappingWarehousesLoaded.value = true
|
||||
autoSelectExactWarehouseMappings()
|
||||
return true
|
||||
} catch (error) {
|
||||
if (sequence === warehouseSequence) {
|
||||
mappingWarehouses.value = []
|
||||
mappingWarehousesLoaded.value = false
|
||||
mappingWarehousesError.value = getApiErrorDetails(error, '平台公共仓库加载失败,请稍后重试')
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
if (sequence === warehouseSequence) mappingWarehousesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
const id = versionId.value
|
||||
const sequence = ++detailSequence
|
||||
detail.value = null
|
||||
loadError.value = null
|
||||
if (!id) {
|
||||
loadError.value = { message: '缺少价格表版本,无法加载详情' }
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getPlatformPriceBook(id)
|
||||
if (sequence !== detailSequence) return
|
||||
applyDetail(result)
|
||||
if (result.version.status === 'PENDING_CONFIRMATION' && canEdit.value) await loadMappingWarehouses()
|
||||
} catch (error) {
|
||||
if (sequence === detailSequence) loadError.value = getApiErrorDetails(error, '船期价格表详情加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === detailSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function originWarehouseMatches(row: OriginMappingRow, warehouse: PlatformWarehouseResource) {
|
||||
return mappingKey(warehouse.warehouseCode) === mappingKey(row.sourceCode)
|
||||
}
|
||||
|
||||
function overseasWarehouseMatches(row: OverseasMappingRow, warehouse: PlatformWarehouseResource) {
|
||||
return !row.destinationCode || mappingKey(warehouse.warehouseCode) === mappingKey(row.destinationCode)
|
||||
}
|
||||
|
||||
function selectedByOther(selections: Record<string, string>, currentKey: string, warehouseId: string) {
|
||||
return Object.entries(selections).some(([key, selected]) => key !== currentKey && selected === warehouseId)
|
||||
}
|
||||
|
||||
function originWarehouseDisabled(row: OriginMappingRow, warehouse: PlatformWarehouseResource) {
|
||||
return !originWarehouseMatches(row, warehouse)
|
||||
|| selectedByOther(originSelections.value, row.key, String(warehouse.id))
|
||||
}
|
||||
|
||||
function overseasWarehouseDisabled(row: OverseasMappingRow, warehouse: PlatformWarehouseResource) {
|
||||
return !overseasWarehouseMatches(row, warehouse)
|
||||
|| selectedByOther(overseasSelections.value, row.key, String(warehouse.id))
|
||||
}
|
||||
|
||||
function buildConfirmationRequest(): PriceBookConfirmationRequest {
|
||||
return {
|
||||
originMappings: originMappingRows.value.map((row) => ({
|
||||
originWarehouseCode: row.sourceCode,
|
||||
warehouseId: originSelections.value[row.key],
|
||||
})),
|
||||
overseasMappings: overseasMappingRows.value.map((row) => ({
|
||||
sourceDestinationName: row.sourceName,
|
||||
warehouseId: overseasSelections.value[row.key],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPriceBook() {
|
||||
if (!detail.value || !canConfirm.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认后,该价格表会立即成为“${priceBookScopeLabel(detail.value.version.customerId)}”的唯一生效版本,并仅替换同一报价范围的当前生效版本。历史版本和原始文件将继续保留。`,
|
||||
'确认并生效船期价格表',
|
||||
{ type: 'warning', confirmButtonText: '确认并立即生效', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error('确认操作未完成,请重试。')
|
||||
return
|
||||
}
|
||||
|
||||
publishing.value = true
|
||||
try {
|
||||
applyDetail(await publishPlatformPriceBook(detail.value.version.id, buildConfirmationRequest()))
|
||||
ElMessage.success('船期价格表已生效,上一生效版本已保留为历史版本。')
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error, '确认生效失败,请根据校验结果修正后重试'))
|
||||
await loadDetail()
|
||||
} finally {
|
||||
publishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSource() {
|
||||
if (!detail.value) return
|
||||
downloading.value = true
|
||||
try {
|
||||
const blob = await downloadPlatformPriceBookSource(detail.value.version.id)
|
||||
if (!blob.size) throw new Error('原始价格表内容为空')
|
||||
saveBlob(blob, detail.value.version.originalFileName)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error, '原始价格表下载失败,请稍后重试'))
|
||||
} finally {
|
||||
downloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
void router.push({ name: 'fba-carrier-rates' })
|
||||
}
|
||||
|
||||
watch(versionId, () => { void loadDetail() })
|
||||
onMounted(() => {
|
||||
void loadCustomerOptions()
|
||||
void loadDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage as="div" class="price-book-detail" spacing="normal">
|
||||
<DetailPageLayout aria-label="船期价格表版本详情">
|
||||
<template #header>
|
||||
<div class="detail-heading">
|
||||
<el-button @click="backToList"><ArrowLeft :size="16" />返回列表</el-button>
|
||||
<PageToolbar
|
||||
class="detail-toolbar"
|
||||
as="section"
|
||||
:title="detail ? `船期价格表 · V${detail.version.versionNo}` : '船期价格表详情'"
|
||||
:description="detail ? `${priceBookScopeLabel(detail.version.customerId)} · 上传于 ${formatDateTime(detail.version.createdAt)}` : '查看版本、仓库映射和渠道价格'"
|
||||
>
|
||||
<template v-if="detail" #actions>
|
||||
<el-tag :type="priceBookStatusMeta(detail.version.status).type" effect="light">{{ priceBookStatusMeta(detail.version.status).label }}</el-tag>
|
||||
<el-button :loading="downloading" @click="downloadSource"><Download :size="16" />下载原文件</el-button>
|
||||
<el-button v-if="isPending && canEdit" type="primary" :loading="publishing" :disabled="!canConfirm" @click="confirmPriceBook"><CircleCheck :size="16" />确认并立即生效</el-button>
|
||||
</template>
|
||||
</PageToolbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<LoadErrorState v-if="loadError" title="船期价格表详情加载失败" :message="loadError.message" :loading="loading" @retry="loadDetail">
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<section class="surface-panel version-panel">
|
||||
<header class="section-heading">
|
||||
<div><h2><FileSpreadsheet :size="18" />版本信息</h2><p>{{ detail.version.originalFileName }}</p></div>
|
||||
</header>
|
||||
<dl class="detail-grid">
|
||||
<div><dt>报价范围</dt><dd>{{ priceBookScopeLabel(detail.version.customerId) }}</dd></div>
|
||||
<div><dt>解析渠道</dt><dd>{{ detail.version.channelCount }}</dd></div>
|
||||
<div><dt>解析价格行</dt><dd>{{ detail.version.rateCount }}</dd></div>
|
||||
<div><dt>阻断错误</dt><dd :class="{ 'is-danger': detail.version.blockingErrorCount }">{{ detail.version.blockingErrorCount }}</dd></div>
|
||||
<div><dt>警告</dt><dd>{{ detail.version.warningCount }}</dd></div>
|
||||
<div class="detail-grid__wide"><dt>实际生效区间</dt><dd>{{ formatEffectivePeriod(detail.version) }}</dd></div>
|
||||
<div v-if="detail.version.status === 'VOIDED'" class="detail-grid__wide"><dt>作废原因</dt><dd>{{ detail.version.voidReason || '—' }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section v-if="isPending && canEdit" class="surface-panel mapping-section">
|
||||
<header class="section-heading"><div><h2><Warehouse :size="18" />仓库映射</h2><p>来源编码必须匹配启用的平台公共仓库编码。</p></div></header>
|
||||
<el-alert
|
||||
v-if="nonMappingBlockingIssues.length"
|
||||
title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert v-else-if="confirmationHint" :title="confirmationHint" :type="canConfirm ? 'success' : 'warning'" :closable="false" show-icon />
|
||||
|
||||
<div v-if="mappingWarehousesError" class="mapping-load-error" role="alert">
|
||||
<div><strong>平台仓库候选加载失败</strong><p>{{ mappingWarehousesError.message }}</p><ApiErrorMeta :details="mappingWarehousesError" /></div>
|
||||
<el-button :loading="mappingWarehousesLoading" @click="loadMappingWarehouses(true)"><RefreshCw :size="16" />重新加载</el-button>
|
||||
</div>
|
||||
<div v-else-if="mappingWarehousesLoaded" class="mapping-groups">
|
||||
<section class="mapping-group">
|
||||
<h3>起运仓映射</h3>
|
||||
<div v-for="row in originMappingRows" :key="row.key" class="mapping-row">
|
||||
<div><strong>{{ row.sourceName }}</strong><span>来源编码:{{ row.sourceCode }}</span></div>
|
||||
<el-select v-model="originSelections[row.key]" filterable placeholder="请选择匹配的国内仓">
|
||||
<el-option v-for="warehouse in domesticWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" :disabled="originWarehouseDisabled(row, warehouse)" />
|
||||
</el-select>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="overseasMappingRows.length" class="mapping-group">
|
||||
<h3>海外仓映射</h3>
|
||||
<div v-for="row in overseasMappingRows" :key="row.key" class="mapping-row">
|
||||
<div><strong>{{ row.sourceName }}</strong><span>目的仓编码:{{ row.destinationCode || '未提供' }}</span></div>
|
||||
<el-select v-model="overseasSelections[row.key]" filterable placeholder="请选择匹配的海外仓">
|
||||
<el-option v-for="warehouse in overseasWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" :disabled="overseasWarehouseDisabled(row, warehouse)" />
|
||||
</el-select>
|
||||
</div>
|
||||
</section>
|
||||
<el-alert v-if="mappingCandidatesMissing" title="未找到编码匹配的启用平台公共仓库,请先在仓库管理中补齐对应仓库。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="hasMissingOverseasSource" title="有海外仓价格行缺少来源名称,无法确认映射。" type="error" :closable="false" show-icon />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel detail-section">
|
||||
<header class="section-heading"><div><h2><AlertTriangle :size="18" />解析与校验</h2><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></div></header>
|
||||
<el-empty v-if="!detail.issues.length" description="未发现解析问题" :image-size="72" />
|
||||
<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">
|
||||
<el-table-column label="级别" width="90"><template #default="{ row }"><el-tag :type="(row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? 'danger' : 'warning'" size="small">{{ (row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? '阻断' : '警告' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="issueCode" label="编码" min-width="165" show-overflow-tooltip />
|
||||
<el-table-column prop="message" label="说明" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column label="来源" min-width="180"><template #default="{ row }">{{ issueLocation(row as PlatformPriceBookParseIssue) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel detail-section channel-preview">
|
||||
<header class="section-heading"><div><h2><Ship :size="18" />按渠道预览</h2><p>显示上传 Excel 的解析结果,确认页不提供在线改价。</p></div></header>
|
||||
<el-empty v-if="!detail.channels.length" description="未解析出渠道" :image-size="72" />
|
||||
<template v-else>
|
||||
<el-tabs v-model="selectedChannelCode" class="channel-tabs">
|
||||
<el-tab-pane v-for="channel in detail.channels" :key="channel.channelCode" :label="channel.channelName" :name="channel.channelCode" />
|
||||
</el-tabs>
|
||||
<div class="channel-meta"><span>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</span><span>表页:{{ selectedChannel?.sourceSheetName || '—' }}</span></div>
|
||||
<el-table :data="selectedChannelRates" size="small" max-height="440" border stripe aria-label="渠道价格行预览">
|
||||
<el-table-column label="渠道" min-width="140" show-overflow-tooltip><template #default>{{ selectedChannel?.channelName || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="originWarehouseName" label="起运仓" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="目的地" min-width="140"><template #default="{ row }">{{ rateDestinationLabel(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
|
||||
<el-table-column label="包税方式" width="105"><template #default="{ row }">{{ pricingTaxModeLabel((row as PlatformPriceBookParsedRate).pricingTaxMode) }}</template></el-table-column>
|
||||
<el-table-column prop="chargeUnit" label="单位" width="78" />
|
||||
<el-table-column label="单价" min-width="120"><template #default="{ row }">{{ formatMoney((row as PlatformPriceBookParsedRate).unitPrice, (row as PlatformPriceBookParsedRate).currency) }}</template></el-table-column>
|
||||
<el-table-column label="参考时效" min-width="130" show-overflow-tooltip><template #default>{{ selectedChannel?.referenceTransit || '—' }}</template></el-table-column>
|
||||
<el-table-column label="最晚进仓时间" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.latestWarehouseEntry || '—' }}</template></el-table-column>
|
||||
<el-table-column label="船期" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.estimatedDepartureText || selectedChannel?.estimatedDepartureDate || '—' }}</template></el-table-column>
|
||||
<el-table-column label="附加费" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.surchargeDescription || '—' }}</template></el-table-column>
|
||||
<el-table-column label="备注" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.remark || '—' }}</template></el-table-column>
|
||||
<el-table-column label="生效状态" width="105"><template #default>{{ priceBookStatusMeta(detail.version.status).label }}</template></el-table-column>
|
||||
<el-table-column label="来源" min-width="170"><template #default="{ row }">{{ sourceLocation(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.price-book-detail { min-width: 0; max-width: 1880px; }
|
||||
.detail-heading { display: flex; min-width: 0; align-items: flex-start; gap: 16px; }
|
||||
.detail-toolbar { min-width: 0; flex: 1; }
|
||||
.detail-toolbar :deep(.page-toolbar__actions) { align-items: center; }
|
||||
.surface-panel { padding: 16px; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
|
||||
.section-heading h2 { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--cc-text); font-size: 15px; }
|
||||
.section-heading h2 svg { color: var(--cc-primary); }
|
||||
.section-heading p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); border-radius: 8px; overflow: hidden; }
|
||||
.detail-grid > div { min-width: 0; padding: 11px 13px; border-right: 1px solid var(--cc-border); background: var(--cc-surface-soft, #f8fafc); }
|
||||
.detail-grid > div:nth-child(5n) { border-right: 0; }
|
||||
.detail-grid__wide { grid-column: span 3; border-top: 1px solid var(--cc-border); }
|
||||
.detail-grid__wide + .detail-grid__wide { grid-column: span 2; }
|
||||
.detail-grid dt { color: var(--cc-muted); font-size: 11px; }
|
||||
.detail-grid dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 14px; font-weight: 700; }
|
||||
.is-danger { color: var(--el-color-danger) !important; }
|
||||
.mapping-section, .detail-section { display: grid; min-width: 0; gap: 14px; }
|
||||
.mapping-section .section-heading, .detail-section .section-heading { margin-bottom: 0; }
|
||||
.mapping-groups { display: grid; gap: 14px; }
|
||||
.mapping-group { display: grid; gap: 8px; }
|
||||
.mapping-group h3 { margin: 0; color: var(--cc-text); font-size: 13px; }
|
||||
.mapping-row { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(320px, 1fr); align-items: center; gap: 16px; padding: 11px 12px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface-soft, #f8fafc); }
|
||||
.mapping-row > div { display: grid; min-width: 0; gap: 4px; }
|
||||
.mapping-row strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; }
|
||||
.mapping-row span { color: var(--cc-muted); font-size: 12px; }
|
||||
.mapping-row :deep(.el-select) { width: 100%; }
|
||||
.mapping-load-error { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 14px; border: 1px solid color-mix(in srgb, var(--cc-danger) 24%, var(--cc-border)); border-radius: 8px; }
|
||||
.mapping-load-error strong { color: var(--cc-danger); font-size: 13px; }
|
||||
.mapping-load-error p { margin: 4px 0; color: var(--cc-text); font-size: 12px; }
|
||||
.channel-tabs { width: 100%; min-width: 0; margin-bottom: 0; overflow: hidden; }
|
||||
.channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; }
|
||||
.channel-meta { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: -4px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
@media (max-width: 720px) {
|
||||
.detail-heading, .mapping-load-error { align-items: stretch; flex-direction: column; }
|
||||
.detail-toolbar { width: 100%; }
|
||||
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.detail-grid > div, .detail-grid > div:nth-child(5n) { border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-grid > div:nth-child(2n) { border-right: 0; }
|
||||
.detail-grid__wide, .detail-grid__wide + .detail-grid__wide { grid-column: span 2; }
|
||||
.mapping-row { grid-template-columns: 1fr; gap: 10px; }
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,8 @@ import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(join(process.cwd(), 'src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue'), 'utf8')
|
||||
const detailSource = readFileSync(join(process.cwd(), 'src/modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue'), 'utf8')
|
||||
const routeSource = readFileSync(join(process.cwd(), 'src/modules/fba-pricing/routes.ts'), 'utf8')
|
||||
const queryToolbarSource = readFileSync(join(process.cwd(), 'src/shared/components/QueryToolbar.vue'), 'utf8')
|
||||
|
||||
describe('CarrierRatesView price-book source contract', () => {
|
||||
@@ -19,13 +21,14 @@ describe('CarrierRatesView price-book source contract', () => {
|
||||
})
|
||||
|
||||
it('uses only the parsed price-book workflow instead of legacy carrier-rate editing', () => {
|
||||
const workflowSource = `${source}\n${detailSource}`
|
||||
for (const capability of [
|
||||
'listPlatformPriceBooks',
|
||||
'getPlatformPriceBook',
|
||||
'uploadPlatformPriceBook',
|
||||
'publishPlatformPriceBook',
|
||||
'downloadPlatformPriceBookSource',
|
||||
]) expect(source).toContain(capability)
|
||||
]) expect(workflowSource).toContain(capability)
|
||||
|
||||
for (const legacyCapability of [
|
||||
'createPlatformCarrierRateVersion',
|
||||
@@ -35,43 +38,47 @@ describe('CarrierRatesView price-book source contract', () => {
|
||||
'编辑价格',
|
||||
'有效期',
|
||||
'生效日期',
|
||||
]) expect(source).not.toContain(legacyCapability)
|
||||
]) expect(workflowSource).not.toContain(legacyCapability)
|
||||
})
|
||||
|
||||
it('keeps uploaded prices immutable and makes confirmation the only edit action', () => {
|
||||
it('opens a formal detail route and keeps uploaded prices immutable', () => {
|
||||
expect(source).toContain('上传并解析')
|
||||
expect(source).toContain('查看并确认')
|
||||
expect(source).toContain('确认并立即生效')
|
||||
expect(source).toContain('价格行不可在页面内修改')
|
||||
expect(source).toContain('confirmPriceBook')
|
||||
expect(source).toContain('publishPlatformPriceBook')
|
||||
expect(source).not.toContain('草稿')
|
||||
expect(source).toContain("name: 'fba-carrier-rate-detail'")
|
||||
expect(routeSource).toContain("path: 'first-leg-pricing/carrier-rates/:versionId'")
|
||||
expect(routeSource).toContain("import('./carrier-rates/pages/CarrierRateDetailView.vue')")
|
||||
expect(detailSource).toContain('返回列表')
|
||||
expect(detailSource).toContain('确认并立即生效')
|
||||
expect(detailSource).toContain('价格行不可在页面内修改')
|
||||
expect(detailSource).toContain('confirmPriceBook')
|
||||
expect(detailSource).toContain('publishPlatformPriceBook')
|
||||
expect(`${source}\n${detailSource}`).not.toContain('草稿')
|
||||
})
|
||||
|
||||
it('shows all source-backed prototype price metadata without inventing cost breakdowns', () => {
|
||||
for (const label of ['渠道', '参考时效', '最晚进仓时间', '船期', '附加费', '备注', '生效状态']) {
|
||||
expect(source).toContain(`label="${label}"`)
|
||||
expect(detailSource).toContain(`label="${label}"`)
|
||||
}
|
||||
for (const field of ['referenceTransit', 'latestWarehouseEntry', 'estimatedDepartureText', 'surchargeDescription', 'remark']) {
|
||||
expect(source).toContain(`selectedChannel?.${field}`)
|
||||
expect(detailSource).toContain(`selectedChannel?.${field}`)
|
||||
}
|
||||
expect(source).not.toContain('AMS+换单费')
|
||||
expect(source).not.toContain('建议报价')
|
||||
expect(detailSource).not.toContain('AMS+换单费')
|
||||
expect(detailSource).not.toContain('建议报价')
|
||||
})
|
||||
|
||||
it('maps only active platform public domestic and overseas warehouses before confirmation', () => {
|
||||
expect(source).toContain('listAllPlatformWarehouses()')
|
||||
expect(source).toContain("warehouse.warehouseType === 'DOMESTIC'")
|
||||
expect(source).toContain("warehouse.warehouseType === 'OVERSEAS'")
|
||||
expect(source).toContain('起运仓映射')
|
||||
expect(source).toContain('海外仓映射')
|
||||
expect(source).toContain('originWarehouseCode')
|
||||
expect(source).toContain('sourceDestinationName')
|
||||
expect(source).toContain('autoSelectExactWarehouseMappings')
|
||||
expect(source).toContain("'DUPLICATE_RATE'")
|
||||
expect(source).toContain('.channel-tabs { width: 100%; min-width: 0;')
|
||||
expect(source).toContain('.channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; }')
|
||||
expect(source).not.toContain('tenantId')
|
||||
expect(detailSource).toContain('listAllPlatformWarehouses()')
|
||||
expect(detailSource).toContain("warehouse.warehouseType === 'DOMESTIC'")
|
||||
expect(detailSource).toContain("warehouse.warehouseType === 'OVERSEAS'")
|
||||
expect(detailSource).toContain('起运仓映射')
|
||||
expect(detailSource).toContain('海外仓映射')
|
||||
expect(detailSource).toContain('originWarehouseCode')
|
||||
expect(detailSource).toContain('sourceDestinationName')
|
||||
expect(detailSource).toContain('autoSelectExactWarehouseMappings')
|
||||
expect(detailSource).toContain("'DUPLICATE_RATE'")
|
||||
expect(detailSource).toContain('.channel-tabs { width: 100%; min-width: 0;')
|
||||
expect(detailSource).toContain('.channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; }')
|
||||
expect(detailSource).not.toContain('tenantId')
|
||||
})
|
||||
|
||||
it('supports public and customer-specific price-book scopes without exposing technical ids', () => {
|
||||
@@ -81,19 +88,19 @@ describe('CarrierRatesView price-book source contract', () => {
|
||||
expect(source).toContain('客户专属')
|
||||
expect(source).toContain('uploadPlatformPriceBook(file, uploadCustomerId.value)')
|
||||
expect(source).toContain('只替换同一报价范围')
|
||||
expect(source).not.toContain('{{ detail.version.customerId }}')
|
||||
expect(detailSource).not.toContain('{{ detail.version.customerId }}')
|
||||
})
|
||||
|
||||
it('shows parsing blockers and a channel-tabbed, read-only preview', () => {
|
||||
expect(source).toContain('nonMappingBlockingIssues')
|
||||
expect(source).toContain('解析与校验')
|
||||
expect(source).toContain('按渠道预览')
|
||||
expect(source).toContain('<el-tabs v-model="selectedChannelCode"')
|
||||
expect(source).toContain('sourceLocation')
|
||||
expect(source).toContain('pricingTaxModeLabel')
|
||||
expect(source).toContain('<el-table :data="selectedChannelRates" size="small" max-height="360" border stripe aria-label="渠道价格行预览">')
|
||||
expect(source).toContain('<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">')
|
||||
expect(source).not.toContain('detail.issues" size="small" max-height="280" border stripe')
|
||||
expect(detailSource).toContain('nonMappingBlockingIssues')
|
||||
expect(detailSource).toContain('解析与校验')
|
||||
expect(detailSource).toContain('按渠道预览')
|
||||
expect(detailSource).toContain('<el-tabs v-model="selectedChannelCode"')
|
||||
expect(detailSource).toContain('sourceLocation')
|
||||
expect(detailSource).toContain('pricingTaxModeLabel')
|
||||
expect(detailSource).toContain('<el-table :data="selectedChannelRates" size="small" max-height="440" border stripe aria-label="渠道价格行预览">')
|
||||
expect(detailSource).toContain('<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">')
|
||||
expect(detailSource).not.toContain('detail.issues" size="small" max-height="280" border stripe')
|
||||
})
|
||||
|
||||
it('accepts only xlsx uploads and retains original-file download', () => {
|
||||
@@ -111,20 +118,20 @@ describe('CarrierRatesView price-book source contract', () => {
|
||||
expect(queryToolbarSource).toContain('flex-wrap: wrap')
|
||||
expect(source).toContain('@media (max-width: 720px)')
|
||||
expect(source).toContain('.table-actions :deep(.el-button) { min-height: 40px; }')
|
||||
expect(source).toContain('@media (max-width: 420px)')
|
||||
expect(source).toContain('.detail-grid { grid-template-columns: 1fr; }')
|
||||
expect(detailSource).toContain('@media (max-width: 720px)')
|
||||
expect(detailSource).toContain('.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }')
|
||||
})
|
||||
|
||||
it('uses bordered striped tables and explicit semantic business buttons', () => {
|
||||
expect(source).toContain('<DataTable')
|
||||
expect(source).toContain(':columns="columns"')
|
||||
expect(source).toContain('border stripe aria-label="渠道价格行预览"')
|
||||
expect(detailSource).toContain('border stripe aria-label="渠道价格行预览"')
|
||||
expect(source).toContain('<el-button size="small" plain type="primary" @click="openDetail')
|
||||
expect(source).toMatch(/size="small"\r?\n\s+plain\r?\n\s+:loading="downloadingIds\.has/)
|
||||
expect(source).toContain('<Eye :size="15" />{{ detailActionLabel')
|
||||
expect(source).toContain('<Download :size="15" />原文件')
|
||||
expect(source).toContain('<CircleCheck :size="16" />确认并立即生效')
|
||||
expect(source).toContain('.price-book-footer :deep(.el-button) { min-height: 44px; }')
|
||||
expect(detailSource).toContain('<CircleCheck :size="16" />确认并立即生效')
|
||||
expect(detailSource).toContain("import PageToolbar from '@/shared/components/PageToolbar.vue'")
|
||||
expect(source).not.toContain('<el-button link type="primary" @click="openDetail')
|
||||
})
|
||||
|
||||
@@ -143,9 +150,10 @@ describe('CarrierRatesView price-book source contract', () => {
|
||||
expect(source).not.toContain('<template #state>')
|
||||
})
|
||||
|
||||
it('keeps both row actions directly visible without a premature more menu', () => {
|
||||
it('keeps all row actions directly visible without a premature more menu', () => {
|
||||
expect(source).toContain('<Eye :size="15" />{{ detailActionLabel')
|
||||
expect(source).toContain('<Download :size="15" />原文件')
|
||||
expect(source).toContain('<CircleX :size="15" />作废')
|
||||
expect(source).not.toContain('MoreHorizontal')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,8 +44,6 @@ const downloadingIds = ref(new Set<string>())
|
||||
const voidingIds = ref(new Set<string>())
|
||||
const customerOptions = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const originSelections = ref<Record<string, string>>({})
|
||||
const overseasSelections = ref<Record<string, string>>({})
|
||||
const filters = ref<PriceBookFilters>({ customerId: '0', status: '' })
|
||||
const appliedFilters = ref<PriceBookFilters>({ ...filters.value })
|
||||
let loadSequence = 0
|
||||
@@ -74,13 +72,14 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
{ label: '待确认', value: 'PENDING_CONFIRMATION' },
|
||||
{ label: '生效中', value: 'ACTIVE' },
|
||||
{ label: '已替换', value: 'SUPERSEDED' },
|
||||
{ label: '已作废', value: 'VOIDED' },
|
||||
{ label: '解析失败', value: 'FAILED' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 300, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'scope', label: '报价范围', minWidth: 190, slotName: 'scope' },
|
||||
{ key: 'version', label: '价格表版本', minWidth: 180, slotName: 'version' },
|
||||
{ key: 'originalFileName', prop: 'originalFileName', label: '原始文件', minWidth: 250, showOverflowTooltip: true, defaultVisible: false },
|
||||
@@ -95,7 +94,6 @@ const canEdit = computed(() => access.can('firstleg:fba-pricing:rate:edit'))
|
||||
const pagedRows = computed(() => rows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
const activeCount = computed(() => rows.value.filter((row) => row.status === 'ACTIVE').length)
|
||||
const pendingCount = computed(() => rows.value.filter((row) => row.status === 'PENDING_CONFIRMATION').length)
|
||||
const selectedScopeLabel = computed(() => priceBookScopeLabel(appliedFilters.value.customerId))
|
||||
const loadErrorMessage = computed(() => loadError.value?.message || '')
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
@@ -127,46 +125,17 @@ async function loadCustomerOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatMoney(value: number, currency: string) {
|
||||
return `${currency} ${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`
|
||||
}
|
||||
|
||||
function priceBookStatusMeta(status: PriceBookStatus) {
|
||||
const values = {
|
||||
PENDING_CONFIRMATION: { label: '待确认', type: 'warning' as const },
|
||||
ACTIVE: { label: '生效中', type: 'success' as const },
|
||||
SUPERSEDED: { label: '已替换', type: 'info' as const },
|
||||
VOIDED: { label: '已作废', type: 'info' as const },
|
||||
FAILED: { label: '解析失败', type: 'danger' as const },
|
||||
}
|
||||
return values[status]
|
||||
}
|
||||
|
||||
function priceModelLabel(value: string) {
|
||||
return value === 'REGION_WEIGHT_TIER' ? '海派区域阶梯价' : '按仓价格'
|
||||
}
|
||||
|
||||
function pricingTaxModeLabel(value: string | null) {
|
||||
if (value === 'TAX_INCLUDED') return '包税'
|
||||
if (value === 'TAX_EXCLUDED') return '不包税'
|
||||
return '-'
|
||||
}
|
||||
|
||||
function rateDestinationLabel(rate: PlatformPriceBookParsedRate) {
|
||||
if (rate.destinationType === 'OVERSEAS') return rate.destinationName || rate.destinationCode || '-'
|
||||
if (rate.destinationType === 'FBA') return rate.destinationCode || rate.destinationName || '-'
|
||||
return rate.regionCode || '-'
|
||||
}
|
||||
|
||||
function issueLocation(issue: PlatformPriceBookParseIssue) {
|
||||
return [issue.sourceSheetName, issue.sourceRowNumber ? `第 ${issue.sourceRowNumber} 行` : null, issue.sourceCell]
|
||||
.filter(Boolean)
|
||||
.join(' · ') || '-'
|
||||
}
|
||||
|
||||
function sourceLocation(rate: PlatformPriceBookParsedRate) {
|
||||
return `${rate.sourceSheetName} · 第 ${rate.sourceRowNumber} 行`
|
||||
}
|
||||
|
||||
function apiErrorMessage(error: unknown, fallback: string) {
|
||||
const details = getApiErrorDetails(error, fallback)
|
||||
return details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message
|
||||
@@ -248,134 +217,8 @@ function selectUploadFile(event: Event) {
|
||||
uploadFile.value = file
|
||||
}
|
||||
|
||||
function resetMappings(value: PlatformPriceBookDetail) {
|
||||
const origins: Record<string, string> = {}
|
||||
const overseas: Record<string, string> = {}
|
||||
const originByKey = new Map<string, OriginMappingRow>()
|
||||
const overseasByKey = new Map<string, OverseasMappingRow>()
|
||||
|
||||
for (const rate of value.rates) {
|
||||
const originCode = rate.originWarehouseCode.trim()
|
||||
if (originCode) {
|
||||
const key = mappingKey(originCode)
|
||||
if (!originByKey.has(key)) originByKey.set(key, {
|
||||
key,
|
||||
sourceCode: originCode,
|
||||
sourceName: rate.originWarehouseName || originCode,
|
||||
warehouseId: rate.originWarehouseId,
|
||||
})
|
||||
}
|
||||
if (rate.destinationType === 'OVERSEAS' && rate.destinationName?.trim()) {
|
||||
const sourceName = rate.destinationName.trim()
|
||||
const key = mappingKey(sourceName)
|
||||
if (!overseasByKey.has(key)) overseasByKey.set(key, {
|
||||
key,
|
||||
sourceName,
|
||||
destinationCode: rate.destinationCode,
|
||||
warehouseId: rate.destinationWarehouseId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of originByKey.values()) {
|
||||
if (row.warehouseId) origins[row.key] = row.warehouseId
|
||||
}
|
||||
for (const row of overseasByKey.values()) {
|
||||
if (row.warehouseId) overseas[row.key] = row.warehouseId
|
||||
}
|
||||
originSelections.value = origins
|
||||
overseasSelections.value = overseas
|
||||
}
|
||||
|
||||
function autoSelectExactWarehouseMappings() {
|
||||
const origins = { ...originSelections.value }
|
||||
const overseas = { ...overseasSelections.value }
|
||||
const usedOrigins = new Set(Object.values(origins))
|
||||
const usedOverseas = new Set(Object.values(overseas))
|
||||
|
||||
for (const row of originMappingRows.value) {
|
||||
if (origins[row.key]) continue
|
||||
const matches = domesticWarehouses.value.filter((warehouse) => originWarehouseMatches(row, warehouse))
|
||||
if (matches.length !== 1) continue
|
||||
const warehouseId = String(matches[0].id)
|
||||
if (!usedOrigins.has(warehouseId)) {
|
||||
origins[row.key] = warehouseId
|
||||
usedOrigins.add(warehouseId)
|
||||
}
|
||||
}
|
||||
for (const row of overseasMappingRows.value) {
|
||||
if (overseas[row.key]) continue
|
||||
const matches = overseasWarehouses.value.filter((warehouse) => overseasWarehouseMatches(row, warehouse))
|
||||
if (matches.length !== 1) continue
|
||||
const warehouseId = String(matches[0].id)
|
||||
if (!usedOverseas.has(warehouseId)) {
|
||||
overseas[row.key] = warehouseId
|
||||
usedOverseas.add(warehouseId)
|
||||
}
|
||||
}
|
||||
originSelections.value = origins
|
||||
overseasSelections.value = overseas
|
||||
}
|
||||
|
||||
function applyDetail(value: PlatformPriceBookDetail) {
|
||||
detail.value = value
|
||||
selectedVersionId.value = value.version.id
|
||||
selectedChannelCode.value = value.channels[0]?.channelCode || ''
|
||||
resetMappings(value)
|
||||
if (mappingWarehousesLoaded.value) autoSelectExactWarehouseMappings()
|
||||
}
|
||||
|
||||
async function loadMappingWarehouses(force = false) {
|
||||
if (mappingWarehousesLoaded.value && !force) return true
|
||||
const sequence = ++warehouseSequence
|
||||
mappingWarehousesLoading.value = true
|
||||
mappingWarehousesError.value = null
|
||||
try {
|
||||
const result = await listAllPlatformWarehouses()
|
||||
if (sequence !== warehouseSequence) return false
|
||||
mappingWarehouses.value = result
|
||||
mappingWarehousesLoaded.value = true
|
||||
autoSelectExactWarehouseMappings()
|
||||
return true
|
||||
} catch (error) {
|
||||
if (sequence === warehouseSequence) {
|
||||
mappingWarehouses.value = []
|
||||
mappingWarehousesLoaded.value = false
|
||||
mappingWarehousesError.value = getApiErrorDetails(error, '平台公共仓库加载失败,请稍后重试')
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
if (sequence === warehouseSequence) mappingWarehousesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row: PlatformPriceBookVersion) {
|
||||
const sequence = ++detailSequence
|
||||
selectedVersionId.value = row.id
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
detailError.value = null
|
||||
detail.value = null
|
||||
try {
|
||||
const result = await getPlatformPriceBook(row.id)
|
||||
if (sequence !== detailSequence) return
|
||||
applyDetail(result)
|
||||
if (result.version.status === 'PENDING_CONFIRMATION' && canEdit.value) await loadMappingWarehouses()
|
||||
} catch (error) {
|
||||
if (sequence !== detailSequence) return
|
||||
detailError.value = getApiErrorDetails(error, '船期价格表详情加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === detailSequence) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function showUploadedDetail(value: PlatformPriceBookDetail) {
|
||||
detailSequence += 1
|
||||
detailOpen.value = true
|
||||
detailLoading.value = false
|
||||
detailError.value = null
|
||||
applyDetail(value)
|
||||
if (value.version.status === 'PENDING_CONFIRMATION' && canEdit.value) await loadMappingWarehouses()
|
||||
function openDetail(row: PlatformPriceBookVersion) {
|
||||
void router.push({ name: 'fba-carrier-rate-detail', params: { versionId: row.id } })
|
||||
}
|
||||
|
||||
async function submitUpload() {
|
||||
@@ -389,9 +232,9 @@ async function submitUpload() {
|
||||
const result = await uploadPlatformPriceBook(file, uploadCustomerId.value)
|
||||
uploadOpen.value = false
|
||||
resetUpload()
|
||||
await showUploadedDetail(result)
|
||||
ElMessage.success('价格表已解析,请核对结果并确认仓库映射。')
|
||||
await loadPriceBooks()
|
||||
ElMessage.success('价格表已解析,请在详情页核对结果并确认仓库映射。')
|
||||
await router.push({ name: 'fba-carrier-rate-detail', params: { versionId: result.version.id } })
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error, '价格表上传或解析失败,请检查文件后重试'))
|
||||
await loadPriceBooks()
|
||||
@@ -400,69 +243,6 @@ async function submitUpload() {
|
||||
}
|
||||
}
|
||||
|
||||
function originWarehouseMatches(row: OriginMappingRow, warehouse: PlatformWarehouseResource) {
|
||||
return mappingKey(warehouse.warehouseCode) === mappingKey(row.sourceCode)
|
||||
}
|
||||
|
||||
function overseasWarehouseMatches(row: OverseasMappingRow, warehouse: PlatformWarehouseResource) {
|
||||
return !row.destinationCode || mappingKey(warehouse.warehouseCode) === mappingKey(row.destinationCode)
|
||||
}
|
||||
|
||||
function selectedByOther(selections: Record<string, string>, currentKey: string, warehouseId: string) {
|
||||
return Object.entries(selections).some(([key, selected]) => key !== currentKey && selected === warehouseId)
|
||||
}
|
||||
|
||||
function originWarehouseDisabled(row: OriginMappingRow, warehouse: PlatformWarehouseResource) {
|
||||
return !originWarehouseMatches(row, warehouse)
|
||||
|| selectedByOther(originSelections.value, row.key, String(warehouse.id))
|
||||
}
|
||||
|
||||
function overseasWarehouseDisabled(row: OverseasMappingRow, warehouse: PlatformWarehouseResource) {
|
||||
return !overseasWarehouseMatches(row, warehouse)
|
||||
|| selectedByOther(overseasSelections.value, row.key, String(warehouse.id))
|
||||
}
|
||||
|
||||
function buildConfirmationRequest(): PriceBookConfirmationRequest {
|
||||
return {
|
||||
originMappings: originMappingRows.value.map((row) => ({
|
||||
originWarehouseCode: row.sourceCode,
|
||||
warehouseId: originSelections.value[row.key],
|
||||
})),
|
||||
overseasMappings: overseasMappingRows.value.map((row) => ({
|
||||
sourceDestinationName: row.sourceName,
|
||||
warehouseId: overseasSelections.value[row.key],
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPriceBook() {
|
||||
if (!detail.value || !canConfirmDetail.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认后,该价格表会立即成为“${priceBookScopeLabel(detail.value.version.customerId)}”的唯一生效版本,并仅替换同一报价范围的当前生效版本。历史版本和原始文件将继续保留。`,
|
||||
'确认并生效船期价格表',
|
||||
{ type: 'warning', confirmButtonText: '确认并立即生效', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error('确认操作未完成,请重试。')
|
||||
return
|
||||
}
|
||||
|
||||
publishing.value = true
|
||||
try {
|
||||
const result = await publishPlatformPriceBook(detail.value.version.id, buildConfirmationRequest())
|
||||
applyDetail(result)
|
||||
ElMessage.success('船期价格表已生效,上一生效版本已保留为历史版本。')
|
||||
await loadPriceBooks()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error, '确认生效失败,请根据校验结果修正后重试'))
|
||||
if (selectedVersionId.value) await openDetail({ id: selectedVersionId.value } as PlatformPriceBookVersion)
|
||||
} finally {
|
||||
publishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSource(row: PlatformPriceBookVersion) {
|
||||
const next = new Set(downloadingIds.value)
|
||||
next.add(row.id)
|
||||
@@ -480,26 +260,54 @@ async function downloadSource(row: PlatformPriceBookVersion) {
|
||||
}
|
||||
}
|
||||
|
||||
async function voidPending(row: PlatformPriceBookVersion) {
|
||||
if (row.status !== 'PENDING_CONFIRMATION' || !canEdit.value) return
|
||||
let reason = ''
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(
|
||||
`作废后,版本 V${row.versionNo} 将不能再确认生效;版本记录和原始文件会继续保留。`,
|
||||
'作废待确认价格表',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认作废',
|
||||
cancelButtonText: '取消',
|
||||
inputPlaceholder: '请输入作废原因',
|
||||
inputValidator: (value) => value.trim() ? true : '请输入作废原因',
|
||||
inputPattern: /^.{1,500}$/s,
|
||||
inputErrorMessage: '作废原因不能超过 500 个字符',
|
||||
},
|
||||
)
|
||||
reason = result.value.trim()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error('作废操作未完成,请重试。')
|
||||
return
|
||||
}
|
||||
|
||||
const next = new Set(voidingIds.value)
|
||||
next.add(row.id)
|
||||
voidingIds.value = next
|
||||
try {
|
||||
await voidPlatformPriceBook(row.id, reason)
|
||||
ElMessage.success(`价格表 V${row.versionNo} 已作废`)
|
||||
await loadPriceBooks()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error, '价格表作废失败,请稍后重试'))
|
||||
} finally {
|
||||
const current = new Set(voidingIds.value)
|
||||
current.delete(row.id)
|
||||
voidingIds.value = current
|
||||
}
|
||||
}
|
||||
|
||||
function detailActionLabel(row: PlatformPriceBookVersion) {
|
||||
return row.status === 'PENDING_CONFIRMATION' && canEdit.value ? '查看并确认' : '查看详情'
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
if (publishing.value) return
|
||||
detailOpen.value = false
|
||||
}
|
||||
|
||||
watch(uploadOpen, (open) => {
|
||||
if (!open && !uploading.value) resetUpload()
|
||||
})
|
||||
|
||||
watch(detailOpen, (open) => {
|
||||
if (open || publishing.value) return
|
||||
detailSequence += 1
|
||||
detailLoading.value = false
|
||||
detailError.value = null
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void loadCustomerOptions()
|
||||
void loadPriceBooks()
|
||||
@@ -557,6 +365,14 @@ onMounted(() => {
|
||||
:loading="downloadingIds.has((row as PlatformPriceBookVersion).id)"
|
||||
@click="downloadSource(row as PlatformPriceBookVersion)"
|
||||
><Download :size="15" />原文件</el-button>
|
||||
<el-button
|
||||
v-if="canEdit && (row as PlatformPriceBookVersion).status === 'PENDING_CONFIRMATION'"
|
||||
size="small"
|
||||
plain
|
||||
type="danger"
|
||||
:loading="voidingIds.has((row as PlatformPriceBookVersion).id)"
|
||||
@click="voidPending(row as PlatformPriceBookVersion)"
|
||||
><CircleX :size="15" />作废</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template>
|
||||
@@ -595,136 +411,6 @@ onMounted(() => {
|
||||
</div>
|
||||
</AppDialog>
|
||||
|
||||
<AppDrawer
|
||||
v-model="detailOpen"
|
||||
:title="detail ? `船期价格表 · V${detail.version.versionNo}` : '船期价格表详情'"
|
||||
size="min(920px, 96vw)"
|
||||
:loading="detailLoading"
|
||||
:show-footer="isPendingDetail && canEdit"
|
||||
:confirm-loading="publishing"
|
||||
:confirm-disabled="!canConfirmDetail"
|
||||
confirm-text="确认并立即生效"
|
||||
@confirm="confirmPriceBook"
|
||||
@cancel="closeDetail"
|
||||
>
|
||||
<template #footer="{ close, submitting }">
|
||||
<div class="price-book-footer">
|
||||
<el-button :disabled="submitting" @click="close">取消</el-button>
|
||||
<el-button type="primary" :loading="publishing" :disabled="detailLoading || !canConfirmDetail" @click="confirmPriceBook"><CircleCheck :size="16" />确认并立即生效</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<section v-if="detailError" class="detail-load-error" role="alert">
|
||||
<strong>价格表详情暂时无法加载</strong>
|
||||
<p>{{ detailError.message }}</p>
|
||||
<ApiErrorMeta :details="detailError" />
|
||||
<el-button type="primary" @click="selectedVersionId && openDetail({ id: selectedVersionId } as PlatformPriceBookVersion)"><RefreshCw :size="16" />重新加载</el-button>
|
||||
</section>
|
||||
|
||||
<DetailPageLayout v-else-if="detail" class="detail-content" aria-label="船期价格表版本详情">
|
||||
<template #header><header class="detail-summary">
|
||||
<div class="detail-summary__title"><FileSpreadsheet :size="22" /><div><h3>{{ detail.version.originalFileName }}</h3><p>{{ priceBookScopeLabel(detail.version.customerId) }} · 版本 V{{ detail.version.versionNo }} · 上传于 {{ formatDateTime(detail.version.createdAt) }}</p></div></div>
|
||||
<div class="detail-summary__actions"><el-tag :type="priceBookStatusMeta(detail.version.status).type" effect="light">{{ priceBookStatusMeta(detail.version.status).label }}</el-tag><el-button :loading="downloadingIds.has(detail.version.id)" @click="downloadSource(detail.version)"><Download :size="16" />下载原文件</el-button></div>
|
||||
</header></template>
|
||||
|
||||
<template #summary><section class="detail-section version-facts">
|
||||
<dl class="detail-grid">
|
||||
<div><dt>解析渠道</dt><dd>{{ detail.version.channelCount }}</dd></div>
|
||||
<div><dt>解析价格行</dt><dd>{{ detail.version.rateCount }}</dd></div>
|
||||
<div><dt>阻断错误</dt><dd :class="{ 'is-danger': detail.version.blockingErrorCount }">{{ detail.version.blockingErrorCount }}</dd></div>
|
||||
<div><dt>警告</dt><dd>{{ detail.version.warningCount }}</dd></div>
|
||||
<div><dt>实际生效区间</dt><dd>{{ formatEffectivePeriod(detail.version) }}</dd></div>
|
||||
</dl>
|
||||
</section></template>
|
||||
|
||||
<section v-if="isPendingDetail && canEdit" class="detail-section mapping-section">
|
||||
<header class="detail-section__heading"><div><h3><Warehouse :size="17" />仓库映射</h3><p>来源编码必须匹配启用的平台公共仓库编码。</p></div></header>
|
||||
<el-alert
|
||||
v-if="nonMappingBlockingIssues.length"
|
||||
title="当前文件存在无法通过仓库映射解决的阻断错误,请修改原 Excel 后重新上传。"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert v-else-if="confirmationHint" :title="confirmationHint" :type="canConfirmDetail ? 'success' : 'warning'" :closable="false" show-icon />
|
||||
|
||||
<div v-if="mappingWarehousesError" class="mapping-load-error" role="alert">
|
||||
<div><strong>平台仓库候选加载失败</strong><p>{{ mappingWarehousesError.message }}</p><ApiErrorMeta :details="mappingWarehousesError" /></div>
|
||||
<el-button :loading="mappingWarehousesLoading" @click="loadMappingWarehouses(true)"><RefreshCw :size="16" />重新加载</el-button>
|
||||
</div>
|
||||
<div v-else-if="mappingWarehousesLoaded" class="mapping-groups">
|
||||
<section class="mapping-group">
|
||||
<h4>起运仓映射</h4>
|
||||
<div v-for="row in originMappingRows" :key="row.key" class="mapping-row">
|
||||
<div><strong>{{ row.sourceName }}</strong><span>来源编码:{{ row.sourceCode }}</span></div>
|
||||
<el-select v-model="originSelections[row.key]" filterable placeholder="请选择匹配的国内仓">
|
||||
<el-option
|
||||
v-for="warehouse in domesticWarehouses"
|
||||
:key="String(warehouse.id)"
|
||||
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
|
||||
:value="String(warehouse.id)"
|
||||
:disabled="originWarehouseDisabled(row, warehouse)"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="overseasMappingRows.length" class="mapping-group">
|
||||
<h4>海外仓映射</h4>
|
||||
<div v-for="row in overseasMappingRows" :key="row.key" class="mapping-row">
|
||||
<div><strong>{{ row.sourceName }}</strong><span>目的仓编码:{{ row.destinationCode || '未提供' }}</span></div>
|
||||
<el-select v-model="overseasSelections[row.key]" filterable placeholder="请选择匹配的海外仓">
|
||||
<el-option
|
||||
v-for="warehouse in overseasWarehouses"
|
||||
:key="String(warehouse.id)"
|
||||
:label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`"
|
||||
:value="String(warehouse.id)"
|
||||
:disabled="overseasWarehouseDisabled(row, warehouse)"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
</section>
|
||||
<el-alert v-if="mappingCandidatesMissing" title="未找到编码匹配的启用平台公共仓库,请先在仓库管理中补齐对应仓库。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-if="hasMissingOverseasSource" title="有海外仓价格行缺少来源名称,无法确认映射。" type="error" :closable="false" show-icon />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section">
|
||||
<header class="detail-section__heading"><div><h3><AlertTriangle :size="17" />解析与校验</h3><p>阻断错误未解决时不能生效;价格行不可在页面内修改。</p></div></header>
|
||||
<el-empty v-if="!detail.issues.length" description="未发现解析问题" :image-size="72" />
|
||||
<el-table v-else :data="detail.issues" size="small" max-height="280" aria-label="价格表解析问题">
|
||||
<el-table-column label="级别" width="90"><template #default="{ row }"><el-tag :type="(row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? 'danger' : 'warning'" size="small">{{ (row as PlatformPriceBookParseIssue).severity === 'BLOCKING' ? '阻断' : '警告' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="issueCode" label="编码" min-width="165" show-overflow-tooltip />
|
||||
<el-table-column prop="message" label="说明" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column label="来源" min-width="180"><template #default="{ row }">{{ issueLocation(row as PlatformPriceBookParseIssue) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="detail-section channel-preview">
|
||||
<header class="detail-section__heading"><div><h3><Ship :size="17" />按渠道预览</h3><p>显示上传 Excel 的解析结果,确认页不提供在线改价。</p></div></header>
|
||||
<el-empty v-if="!detail.channels.length" description="未解析出渠道" :image-size="72" />
|
||||
<template v-else>
|
||||
<el-tabs v-model="selectedChannelCode" class="channel-tabs">
|
||||
<el-tab-pane v-for="channel in detail.channels" :key="channel.channelCode" :label="channel.channelName" :name="channel.channelCode" />
|
||||
</el-tabs>
|
||||
<div class="channel-meta"><span>{{ priceModelLabel(selectedChannel?.priceModel || '') }}</span><span>表页:{{ selectedChannel?.sourceSheetName || '-' }}</span></div>
|
||||
<el-table :data="selectedChannelRates" size="small" max-height="360" border stripe aria-label="渠道价格行预览">
|
||||
<el-table-column label="渠道" min-width="140" show-overflow-tooltip><template #default>{{ selectedChannel?.channelName || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="originWarehouseName" label="起运仓" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="目的地" min-width="140"><template #default="{ row }">{{ rateDestinationLabel(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
|
||||
<el-table-column label="包税方式" width="105"><template #default="{ row }">{{ pricingTaxModeLabel((row as PlatformPriceBookParsedRate).pricingTaxMode) }}</template></el-table-column>
|
||||
<el-table-column prop="chargeUnit" label="单位" width="78" />
|
||||
<el-table-column label="单价" min-width="120"><template #default="{ row }">{{ formatMoney((row as PlatformPriceBookParsedRate).unitPrice, (row as PlatformPriceBookParsedRate).currency) }}</template></el-table-column>
|
||||
<el-table-column label="参考时效" min-width="130" show-overflow-tooltip><template #default>{{ selectedChannel?.referenceTransit || '—' }}</template></el-table-column>
|
||||
<el-table-column label="最晚进仓时间" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.latestWarehouseEntry || '—' }}</template></el-table-column>
|
||||
<el-table-column label="船期" min-width="150" show-overflow-tooltip><template #default>{{ selectedChannel?.estimatedDepartureText || selectedChannel?.estimatedDepartureDate || '—' }}</template></el-table-column>
|
||||
<el-table-column label="附加费" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.surchargeDescription || '—' }}</template></el-table-column>
|
||||
<el-table-column label="备注" min-width="180" show-overflow-tooltip><template #default>{{ selectedChannel?.remark || '—' }}</template></el-table-column>
|
||||
<el-table-column label="生效状态" width="105"><template #default>{{ priceBookStatusMeta(detail.version.status).label }}</template></el-table-column>
|
||||
<el-table-column label="来源" min-width="170"><template #default="{ row }">{{ sourceLocation(row as PlatformPriceBookParsedRate) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</section>
|
||||
</DetailPageLayout>
|
||||
</AppDrawer>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
@@ -732,8 +418,6 @@ onMounted(() => {
|
||||
.price-book-page { min-width: 0; }
|
||||
.table-actions { display: flex; min-height: 32px; align-items: center; gap: 8px; }
|
||||
.table-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.price-book-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.price-book-footer :deep(.el-button) { margin-left: 0; }
|
||||
.version-cell { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.version-cell strong { color: var(--cc-text); }
|
||||
.version-cell span, .metric-cell span { color: var(--cc-muted); font-size: 11px; }
|
||||
@@ -751,59 +435,8 @@ onMounted(() => {
|
||||
.upload-file-name span { color: var(--cc-muted); font-size: 12px; }
|
||||
.upload-file-name.is-empty strong { color: var(--cc-muted); font-weight: 400; }
|
||||
.upload-file-error { margin: -8px 0 0; color: var(--el-color-danger); font-size: 12px; }
|
||||
.detail-load-error { display: flex; min-height: 220px; align-items: flex-start; justify-content: center; flex-direction: column; }
|
||||
.detail-load-error strong { color: var(--cc-danger); font-size: 15px; }
|
||||
.detail-load-error p { margin: 6px 0; color: var(--cc-text); font-size: 13px; }
|
||||
.detail-load-error :deep(.el-button) { margin-top: 16px; }
|
||||
.detail-content { display: grid; min-width: 0; gap: 20px; }
|
||||
.detail-summary { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-summary__title, .detail-summary__actions { display: flex; min-width: 0; align-items: flex-start; gap: 10px; }
|
||||
.detail-summary__title > svg { flex: 0 0 22px; margin-top: 2px; color: var(--cc-primary); }
|
||||
.detail-summary__title h3 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 17px; line-height: 1.5; }
|
||||
.detail-summary__title p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-summary__actions { flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.detail-section { display: grid; gap: 14px; min-width: 0; }
|
||||
.detail-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.detail-section__heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--cc-text); font-size: 14px; }
|
||||
.detail-section__heading h3 svg { color: var(--cc-primary); }
|
||||
.detail-section__heading p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.version-facts { padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; border: 1px solid var(--cc-border); border-radius: 8px; overflow: hidden; }
|
||||
.detail-grid > div { min-width: 0; padding: 12px 14px; border-right: 1px solid var(--cc-border); background: var(--cc-surface-soft, #f8fafc); }
|
||||
.detail-grid > div:nth-child(3n) { border-right: 0; }
|
||||
.detail-grid > div:nth-child(-n+3) { border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-grid dt { color: var(--cc-muted); font-size: 11px; }
|
||||
.detail-grid dd { margin: 4px 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 15px; font-weight: 700; }
|
||||
.mapping-section { padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-surface-soft, #f8fafc); }
|
||||
.mapping-groups { display: grid; gap: 16px; }
|
||||
.mapping-group { display: grid; gap: 8px; }
|
||||
.mapping-group h4 { margin: 0; color: var(--cc-text); font-size: 13px; }
|
||||
.mapping-row { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(280px, 1fr); align-items: center; gap: 16px; padding: 12px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.mapping-row > div { display: grid; min-width: 0; gap: 4px; }
|
||||
.mapping-row strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 13px; }
|
||||
.mapping-row span { overflow-wrap: anywhere; color: var(--cc-muted); font-size: 12px; }
|
||||
.mapping-load-error { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 14px; border: 1px solid color-mix(in srgb, var(--cc-danger) 24%, var(--cc-border)); border-radius: 8px; background: #fff; }
|
||||
.mapping-load-error strong { color: var(--cc-danger); font-size: 13px; }
|
||||
.mapping-load-error p { margin: 4px 0; color: var(--cc-text); font-size: 12px; }
|
||||
.channel-tabs { width: 100%; min-width: 0; margin-bottom: 0; overflow: hidden; }
|
||||
.channel-preview :deep(.el-table) { width: 100%; max-width: 100%; min-width: 0; }
|
||||
.channel-meta { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: -4px 0 8px; color: var(--cc-muted); font-size: 12px; }
|
||||
@media (max-width: 720px) {
|
||||
.table-actions :deep(.el-button) { min-height: 40px; }
|
||||
.price-book-footer :deep(.el-button) { min-height: 44px; }
|
||||
.upload-file-control, .detail-summary, .mapping-load-error { align-items: stretch; flex-direction: column; }
|
||||
.detail-summary__actions { justify-content: flex-start; }
|
||||
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.detail-grid > div:nth-child(3n) { border-right: 1px solid var(--cc-border); }
|
||||
.detail-grid > div:nth-child(2n) { border-right: 0; }
|
||||
.detail-grid > div:nth-child(-n+3) { border-bottom: 0; }
|
||||
.detail-grid > div:nth-child(-n+4) { border-bottom: 1px solid var(--cc-border); }
|
||||
.mapping-row { grid-template-columns: 1fr; gap: 10px; }
|
||||
.mapping-row :deep(.el-select) { width: 100%; }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
.detail-grid > div, .detail-grid > div:nth-child(3n) { border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.detail-grid > div:last-child { border-bottom: 0; }
|
||||
.upload-file-control { align-items: stretch; flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user