feat: 支持客户专属头程报价
This commit is contained in:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user