feat: 支持客户专属头程报价

This commit is contained in:
2026-09-08 17:43:04 +08:00
parent dd9b8f7a63
commit 44420919f6
4 changed files with 88 additions and 21 deletions
@@ -20,6 +20,7 @@ import {
const version = {
id: '9007199254740995',
customerId: '2096953508805279745',
versionNo: '2',
originalFileName: '2026-08-price-book.xlsx',
sourceFileId: '9007199254740996',
@@ -88,16 +89,17 @@ const detail = {
describe('platform price-book API', () => {
beforeEach(() => vi.clearAllMocks())
it('uses the platform price-book list endpoint without customer scope and preserves long ids', async () => {
it('uses the selected customer price-book scope and preserves long ids', async () => {
http.get.mockResolvedValue({ data: [version] })
const result = await listPlatformPriceBooks({ status: ' PENDING_CONFIRMATION ' })
const result = await listPlatformPriceBooks({ customerId: '2096953508805279745', status: ' PENDING_CONFIRMATION ' })
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books', {
params: { status: 'PENDING_CONFIRMATION' },
params: { customerId: '2096953508805279745', status: 'PENDING_CONFIRMATION' },
})
expect(result[0]).toMatchObject({
id: '9007199254740995',
customerId: '2096953508805279745',
sourceFileId: '9007199254740996',
createdBy: '10001',
versionNo: 2,
@@ -105,12 +107,12 @@ describe('platform price-book API', () => {
})
})
it('omits an empty status filter for the complete version history', async () => {
it('defaults an empty scope to the public price-book history', async () => {
http.get.mockResolvedValue({ data: [] })
await listPlatformPriceBooks({ status: ' ' })
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books', { params: {} })
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books', { params: { customerId: '0' } })
})
it('uploads one workbook, loads its parsed detail, and normalizes parsed rows', async () => {
@@ -118,10 +120,12 @@ describe('platform price-book API', () => {
http.get.mockResolvedValue({ data: detail })
const file = new File(['book'], 'price-book.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const uploadResult = await uploadPlatformPriceBook(file)
const uploadResult = await uploadPlatformPriceBook(file, '2096953508805279745')
const detailResult = await getPlatformPriceBook('9007199254740995')
expect(http.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books', expect.any(FormData))
expect(http.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books', expect.any(FormData), {
params: { customerId: '2096953508805279745' },
})
const form = http.post.mock.calls[0][1] as FormData
expect(form.get('file')).toBe(file)
expect(http.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/price-books/9007199254740995')
@@ -11,6 +11,7 @@ type ApiNumber = string | number
export interface PlatformPriceBookVersion {
id: string
customerId: string
versionNo: number
originalFileName: string
sourceFileId: string | null
@@ -96,12 +97,14 @@ export interface PriceBookConfirmationRequest {
}
export interface PlatformPriceBookQuery {
customerId?: string
status?: PriceBookStatus | string
}
interface PriceBookVersionPayload extends Omit<PlatformPriceBookVersion,
'id' | 'versionNo' | 'sourceFileId' | 'channelCount' | 'rateCount' | 'blockingErrorCount' | 'warningCount' | 'createdBy' | 'publishedBy'> {
'id' | 'customerId' | 'versionNo' | 'sourceFileId' | 'channelCount' | 'rateCount' | 'blockingErrorCount' | 'warningCount' | 'createdBy' | 'publishedBy'> {
id: ApiId
customerId: ApiId
versionNo: ApiNumber
sourceFileId?: ApiId | null
channelCount?: ApiNumber | null
@@ -165,6 +168,7 @@ function normalizeVersion(value: PriceBookVersionPayload): PlatformPriceBookVers
return {
...value,
id: String(value.id),
customerId: String(value.customerId),
versionNo: numberValue(value.versionNo),
sourceFileId: nullableId(value.sourceFileId),
channelCount: numberValue(value.channelCount),
@@ -233,8 +237,9 @@ function normalizeDetail(value: PriceBookDetailPayload): PlatformPriceBookDetail
export async function listPlatformPriceBooks(query: PlatformPriceBookQuery = {}): Promise<PlatformPriceBookVersion[]> {
const status = query.status?.trim()
const customerId = query.customerId?.trim() || '0'
const response = await http.get<ApiEnvelope<PriceBookVersionPayload[]>, ApiEnvelope<PriceBookVersionPayload[]>>(basePath, {
params: status ? { status } : {},
params: status ? { customerId, status } : { customerId },
})
return unwrapApiResponse(response).map(normalizeVersion)
}
@@ -246,10 +251,12 @@ export async function getPlatformPriceBook(versionId: string): Promise<PlatformP
return normalizeDetail(unwrapApiResponse(response))
}
export async function uploadPlatformPriceBook(file: File): Promise<PlatformPriceBookDetail> {
export async function uploadPlatformPriceBook(file: File, customerId = '0'): Promise<PlatformPriceBookDetail> {
const form = new FormData()
form.append('file', file)
const response = await http.post<ApiEnvelope<PriceBookDetailPayload>, ApiEnvelope<PriceBookDetailPayload>>(basePath, form)
const response = await http.post<ApiEnvelope<PriceBookDetailPayload>, ApiEnvelope<PriceBookDetailPayload>>(basePath, form, {
params: { customerId: customerId.trim() || '0' },
})
return normalizeDetail(unwrapApiResponse(response))
}
@@ -72,7 +72,16 @@ describe('CarrierRatesView price-book source contract', () => {
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(source).not.toContain('客户选择')
})
it('supports public and customer-specific price-book scopes without exposing technical ids', () => {
expect(source).toContain('listPlatformTenants')
expect(source).toContain("customerId: '0'")
expect(source).toContain('平台公共报价')
expect(source).toContain('客户专属')
expect(source).toContain('uploadPlatformPriceBook(file, uploadCustomerId.value)')
expect(source).toContain('只替换同一报价范围')
expect(source).not.toContain('{{ detail.version.customerId }}')
})
it('shows parsing blockers and a channel-tabbed, read-only preview', () => {
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
import { AlertTriangle, CircleCheck, Download, Eye, FileSpreadsheet, FileUp, RefreshCw, Ship, Warehouse } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
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'
@@ -29,6 +30,7 @@ import {
} from '../api/platformPriceBooks'
type PriceBookFilters = QueryModel & {
customerId: string
status: PriceBookStatus | ''
}
@@ -55,6 +57,7 @@ const loadError = ref<ApiErrorDetails | null>(null)
const uploadOpen = ref(false)
const uploadFile = ref<File | null>(null)
const uploadFileError = ref('')
const uploadCustomerId = ref('0')
const uploadInput = ref<HTMLInputElement>()
const uploading = ref(false)
const detailOpen = ref(false)
@@ -69,15 +72,29 @@ const mappingWarehouses = ref<PlatformWarehouseResource[]>([])
const mappingWarehousesLoaded = ref(false)
const mappingWarehousesLoading = ref(false)
const mappingWarehousesError = ref<ApiErrorDetails | null>(null)
const customerOptions = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const originSelections = ref<Record<string, string>>({})
const overseasSelections = ref<Record<string, string>>({})
const filters = ref<PriceBookFilters>({ status: '' })
const filters = ref<PriceBookFilters>({ customerId: '0', status: '' })
const appliedFilters = ref<PriceBookFilters>({ ...filters.value })
let loadSequence = 0
let detailSequence = 0
let warehouseSequence = 0
const queryFields: QueryField[] = [
const queryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
type: 'select',
width: 260,
label: '报价范围',
placeholder: '请选择报价范围',
props: { 'aria-label': '报价范围', filterable: true, loading: customersLoading.value },
options: [
{ label: '平台公共报价', value: '0' },
...customerOptions.value.map((customer) => ({ label: `客户专属 · ${customer.tenantName || '未命名客户'}`, value: String(customer.id) })),
],
},
{
key: 'status',
type: 'select',
@@ -92,10 +109,11 @@ const queryFields: QueryField[] = [
{ label: '解析失败', value: 'FAILED' },
],
},
]
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 210, 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 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
@@ -109,6 +127,7 @@ 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 || '')
const isPendingDetail = computed(() => detail.value?.version.status === 'PENDING_CONFIRMATION')
const domesticWarehouses = computed(() => mappingWarehouses.value.filter((warehouse) => (
@@ -222,6 +241,25 @@ function formatEffectivePeriod(version: PlatformPriceBookVersion) {
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 || '未命名客户'}` : '客户专属报价'
}
async function loadCustomerOptions() {
customersLoading.value = true
try {
const result = await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })
customerOptions.value = result.records.filter((customer) => customer.status === 'ACTIVE')
} catch {
customerOptions.value = []
ElMessage.warning('客户列表加载失败,仍可维护平台公共报价')
} finally {
customersLoading.value = false
}
}
function formatMoney(value: number, currency: string) {
return `${currency} ${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`
}
@@ -291,7 +329,7 @@ async function loadPriceBooks() {
loading.value = true
loadError.value = null
try {
const result = await listPlatformPriceBooks({ status: appliedFilters.value.status })
const result = await listPlatformPriceBooks({ customerId: appliedFilters.value.customerId, status: appliedFilters.value.status })
if (sequence !== loadSequence) return
rows.value = result
} catch (error) {
@@ -304,7 +342,10 @@ async function loadPriceBooks() {
}
async function applyQuery(model: QueryModel) {
appliedFilters.value = { status: (model.status || '') as PriceBookStatus | '' }
appliedFilters.value = {
customerId: String(model.customerId || '0'),
status: (model.status || '') as PriceBookStatus | '',
}
page.value = 1
await loadPriceBooks()
}
@@ -317,6 +358,7 @@ function resetUpload() {
function openUpload() {
resetUpload()
uploadCustomerId.value = appliedFilters.value.customerId || '0'
uploadOpen.value = true
}
@@ -477,7 +519,7 @@ async function submitUpload() {
}
uploading.value = true
try {
const result = await uploadPlatformPriceBook(file)
const result = await uploadPlatformPriceBook(file, uploadCustomerId.value)
uploadOpen.value = false
resetUpload()
await showUploadedDetail(result)
@@ -530,7 +572,7 @@ async function confirmPriceBook() {
if (!detail.value || !canConfirmDetail.value) return
try {
await ElMessageBox.confirm(
'确认后,该价格表会立即成为唯一生效版本,并替换当前生效版本。历史版本和原始文件将继续保留。',
`确认后,该价格表会立即成为“${priceBookScopeLabel(detail.value.version.customerId)}”的唯一生效版本,并仅替换同一报价范围的当前生效版本。历史版本和原始文件将继续保留。`,
'确认并生效船期价格表',
{ type: 'warning', confirmButtonText: '确认并立即生效', cancelButtonText: '取消' },
)
@@ -592,6 +634,7 @@ watch(detailOpen, (open) => {
})
onMounted(() => {
void loadCustomerOptions()
void loadPriceBooks()
})
</script>
@@ -650,6 +693,7 @@ onMounted(() => {
</div>
</template>
<template #version="{ row }"><div class="version-cell"><strong>V{{ (row as PlatformPriceBookVersion).versionNo }}</strong></div></template>
<template #scope="{ row }"><el-tag :type="(row as PlatformPriceBookVersion).customerId === '0' ? 'info' : 'warning'" effect="light">{{ priceBookScopeLabel((row as PlatformPriceBookVersion).customerId) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="priceBookStatusMeta((row as PlatformPriceBookVersion).status).type" effect="light">{{ priceBookStatusMeta((row as PlatformPriceBookVersion).status).label }}</el-tag></template>
<template #contents="{ row }"><div class="metric-cell"><strong>{{ (row as PlatformPriceBookVersion).channelCount }}</strong><span>渠道</span><strong>{{ (row as PlatformPriceBookVersion).rateCount }}</strong><span>价格行</span></div></template>
<template #issues="{ row }"><div class="issue-counts"><span :class="{ 'is-blocking': (row as PlatformPriceBookVersion).blockingErrorCount }">阻断 {{ (row as PlatformPriceBookVersion).blockingErrorCount }}</span><span>警告 {{ (row as PlatformPriceBookVersion).warningCount }}</span></div></template>
@@ -669,7 +713,8 @@ onMounted(() => {
@confirm="submitUpload"
>
<div class="upload-content">
<el-alert title="上传后先展示解析结果和仓库映射;确认后才会立即替换当前生效版本。" type="info" :closable="false" show-icon />
<el-alert title="上传后先展示解析结果和仓库映射;确认后只替换同一报价范围的当前生效版本。客户专属报价生效后,计价时优先于平台公共报价。" type="info" :closable="false" show-icon />
<label class="upload-scope-field"><span>报价范围</span><el-select v-model="uploadCustomerId" filterable :disabled="uploading" aria-label="上传报价范围"><el-option label="平台公共报价" value="0" /><el-option v-for="customer in customerOptions" :key="String(customer.id)" :label="`客户专属 · ${customer.tenantName || '未命名客户'}`" :value="String(customer.id)" /></el-select></label>
<input ref="uploadInput" class="visually-hidden-input" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" aria-label="选择 XLSX 船期价格表" :disabled="uploading" @change="selectUploadFile" />
<div class="upload-file-control">
<el-button :disabled="uploading" @click="uploadInput?.click()"><FileSpreadsheet :size="16" />选择 XLSX 文件</el-button>
@@ -710,7 +755,7 @@ onMounted(() => {
<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>版本 V{{ detail.version.versionNo }} · 上传于 {{ formatDateTime(detail.version.createdAt) }}</p></div></div>
<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>
@@ -830,6 +875,8 @@ onMounted(() => {
.issue-counts { color: var(--cc-muted); font-size: 12px; }
.issue-counts .is-blocking, .is-danger { color: var(--el-color-danger); font-weight: 700; }
.upload-content { display: grid; gap: 18px; }
.upload-scope-field { display: grid; gap: 6px; color: var(--cc-muted); font-size: 12px; }
.upload-scope-field :deep(.el-select) { width: 100%; }
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; clip-path: inset(50%); }
.upload-file-control { display: flex; align-items: center; gap: 14px; }
.upload-file-name { display: grid; min-width: 0; gap: 3px; color: var(--cc-text); font-size: 13px; }