This commit is contained in:
2026-08-06 18:02:04 +08:00
parent 16ab992c61
commit 08982b87ff
8 changed files with 213 additions and 138 deletions
@@ -20,7 +20,7 @@ import {
const payload = {
id: '9007199254740995',
tenantId: '10001',
tenantId: '0',
rateCode: 'RATE-US-WEST',
rateVersion: 2,
originWarehouseId: '20001',
@@ -48,38 +48,36 @@ const payload = {
describe('platformCarrierRates api', () => {
beforeEach(() => vi.clearAllMocks())
it('queries the real carrier-rate path and keeps every long id as a string', async () => {
it('queries the platform-wide carrier-rate path and keeps every long id as a string', async () => {
mocks.get.mockResolvedValue({ data: [payload] })
const result = await listPlatformCarrierRates({ customerId: ' 10001 ', rateCode: ' RATE-US ' })
const result = await listPlatformCarrierRates({ rateCode: ' RATE-US ' })
expect(mocks.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates', {
params: { tenantId: '10001', rateCode: 'RATE-US' },
params: { rateCode: 'RATE-US' },
})
expect(result[0]).toMatchObject({
id: '9007199254740995',
tenantId: '10001',
tenantId: '0',
originWarehouseId: '20001',
carrierId: '30001',
})
})
it('omits the compatibility tenant parameter for an all-customer list query', async () => {
it('omits the tenant parameter for the platform-wide list query', async () => {
mocks.get.mockResolvedValue({ data: [payload] })
await listPlatformCarrierRates()
await listPlatformCarrierRates({ customerId: '', rateCode: ' ' })
await listPlatformCarrierRates({ rateCode: ' ' })
expect(mocks.get).toHaveBeenNthCalledWith(1, '/platform/v1/fba-pricing/carrier-rates', { params: {} })
expect(mocks.get).toHaveBeenNthCalledWith(2, '/platform/v1/fba-pricing/carrier-rates', { params: {} })
})
it('loads a version detail with tenant scope', async () => {
it('loads a platform-wide version detail without tenant scope', async () => {
mocks.get.mockResolvedValue({ data: payload })
await getPlatformCarrierRate('10001', '9001')
expect(mocks.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/9001', {
params: { tenantId: '10001' },
})
await getPlatformCarrierRate('9001')
expect(mocks.get).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/9001')
})
it('creates immutable versions and retires an active version', async () => {
@@ -103,14 +101,10 @@ describe('platformCarrierRates api', () => {
remark: null,
}
await createPlatformCarrierRateVersion('10001', request)
await retirePlatformCarrierRate('10001', '9001')
await createPlatformCarrierRateVersion(request)
await retirePlatformCarrierRate('9001')
expect(mocks.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/versions', request, {
params: { tenantId: '10001' },
})
expect(mocks.delete).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/9001', {
params: { tenantId: '10001' },
})
expect(mocks.post).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/versions', request)
expect(mocks.delete).toHaveBeenCalledWith('/platform/v1/fba-pricing/carrier-rates/9001')
})
})
@@ -59,7 +59,6 @@ export interface CreatePlatformCarrierRateVersionRequest {
}
export interface PlatformCarrierRateQuery {
customerId?: string
rateCode?: string
}
@@ -87,38 +86,32 @@ function normalizeCarrierRate(value: PlatformCarrierRatePayload): PlatformCarrie
}
export async function listPlatformCarrierRates(query: PlatformCarrierRateQuery = {}) {
const customerId = query.customerId?.trim()
const rateCode = query.rateCode?.trim()
const response = await http.get<ApiEnvelope<PlatformCarrierRatePayload[]>, ApiEnvelope<PlatformCarrierRatePayload[]>>(
basePath,
{ params: { ...(customerId ? { tenantId: customerId } : {}), ...(rateCode ? { rateCode } : {}) } },
{ params: rateCode ? { rateCode } : {} },
)
return unwrapApiResponse(response).map(normalizeCarrierRate)
}
export async function getPlatformCarrierRate(tenantId: string, id: string) {
export async function getPlatformCarrierRate(id: string) {
const response = await http.get<ApiEnvelope<PlatformCarrierRatePayload>, ApiEnvelope<PlatformCarrierRatePayload>>(
`${basePath}/${id}`,
{ params: { tenantId } },
)
return normalizeCarrierRate(unwrapApiResponse(response))
}
export async function createPlatformCarrierRateVersion(
tenantId: string,
payload: CreatePlatformCarrierRateVersionRequest,
) {
const response = await http.post<ApiEnvelope<PlatformCarrierRatePayload>, ApiEnvelope<PlatformCarrierRatePayload>>(
`${basePath}/versions`,
payload,
{ params: { tenantId } },
)
return normalizeCarrierRate(unwrapApiResponse(response))
}
export async function retirePlatformCarrierRate(tenantId: string, id: string) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, {
params: { tenantId },
})
export async function retirePlatformCarrierRate(id: string) {
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`)
return unwrapApiResponse(response)
}
@@ -7,10 +7,10 @@ const queryToolbarSource = readFileSync(join(process.cwd(), 'src/shared/componen
describe('CarrierRatesView source contract', () => {
it('keeps operation column first and covers every required business field', () => {
const customerColumn = "key: 'customer', label: '所属企业'"
const scopeColumn = "key: 'scope', label: '适用范围'"
const destinationColumn = "key: 'destinationCode', prop: 'destinationCode'"
expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf(customerColumn))
expect(source.indexOf(customerColumn)).toBeLessThan(source.indexOf(destinationColumn))
expect(source.indexOf("key: 'actions'")).toBeLessThan(source.indexOf(scopeColumn))
expect(source.indexOf(scopeColumn)).toBeLessThan(source.indexOf(destinationColumn))
expect(source.indexOf(destinationColumn)).toBeLessThan(source.indexOf("key: 'rate'"))
for (const label of ['起运仓', '业务类型', '渠道编码', '船司', 'FBA 目的仓', '计费单位', '单价', '币种', '固定附加费', '有效期', '备注', '生效时间', '结束时间']) {
expect(source).toContain(label)
@@ -27,41 +27,35 @@ describe('CarrierRatesView source contract', () => {
expect(source).not.toContain('编辑价格')
})
it('keeps the customer company scope in the query toolbar without exposing tenant concepts', () => {
expect(source).toContain("key: 'customerId'")
expect(source.indexOf("key: 'customerId'")).toBeLessThan(source.indexOf("key: 'rateCode'"))
expect(source).toContain("'aria-label': '所属企业'")
expect(source).toContain('listPlatformTenants')
expect(source).toContain('masterDataLoaded')
it('treats carrier rates as platform-wide data without a customer selector', () => {
expect(source).toContain("key: 'scope', label: '适用范围'")
expect(source).toContain('全部客户')
expect(source).not.toContain("key: 'customerId'")
expect(source).not.toContain('listPlatformTenants')
expect(source).not.toContain('PlatformTenantSelector')
expect(source).not.toContain('selectedTenantId')
expect(source).not.toContain('租户')
})
it('uses semantic compact filter widths and keeps the customer selection empty by default', () => {
it('uses semantic compact filter widths without a customer field', () => {
for (const field of [
"key: 'customerId',\n type: 'select',\n width: 280",
"key: 'rateCode', type: 'input', width: 220",
"key: 'destinationCode', type: 'input', width: 220",
"key: 'channelCode', type: 'input', width: 220",
"key: 'chargeUnit', type: 'select', width: 180",
"key: 'status', type: 'select', width: 160",
]) expect(source).toContain(field)
expect(source).toContain("customerId: ''")
expect(source).toContain("placeholder: customerLoadError.value ? '企业列表加载失败' : '全部客户'")
expect(source).not.toContain(':search-on-reset="false"')
expect(source).not.toContain('请先选择所属企业')
expect(source).not.toContain('请选择所属企业并查询价格数据')
expect(source).not.toContain('customers.value[0]')
})
it('loads all customers initially and after reset while preserving row scope for record actions', () => {
it('loads the platform-wide list and keeps row actions independent of customer scope', () => {
expect(source).toContain('void loadRateData()')
expect(source).toContain("listPlatformCarrierRates(customerId ? { customerId } : {})")
expect(source).toContain('loadRateData(customerId)')
expect(source).toContain('getPlatformCarrierRate(String(row.tenantId), row.id)')
expect(source).toContain('retirePlatformCarrierRate(String(row.tenantId), row.id)')
expect(source).toContain('customerLabel(detail.tenantId)')
expect(source).toContain('listPlatformCarrierRates({ rateCode: filters.value.rateCode })')
expect(source).toContain('getPlatformCarrierRate(row.id)')
expect(source).toContain('retirePlatformCarrierRate(row.id)')
expect(source).toContain('<dt>适用范围</dt><dd>全部客户</dd>')
})
it('loads shared carrier master data under the platform public scope', () => {
@@ -73,8 +67,8 @@ describe('CarrierRatesView source contract', () => {
expect(source).not.toContain('loadCustomerMasterData')
})
it('keeps customer rate queries independent from edit-only master data', () => {
expect(source).toContain('await loadRateData(customerId)')
it('keeps public rate queries independent from edit-only master data', () => {
expect(source).toContain('await loadRateData()')
expect(source).not.toContain('loadPlatformMasterData(customerId)')
})
@@ -4,7 +4,6 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem
import { CalendarClock, Eye, Plus, Power, Ship, Warehouse } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { listPlatformTenants, type PlatformTenantResource as PlatformCustomerResource } from '@/modules/iam'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -26,7 +25,6 @@ import {
} from '../api/platformCarrierRates'
type RateFilters = QueryModel & {
customerId: string
rateCode: string
destinationCode: string
channelCode: string
@@ -51,9 +49,6 @@ interface RateForm {
}
const access = useAccessPreviewStore()
const customers = ref<PlatformCustomerResource[]>([])
const customersLoading = ref(false)
const customerLoadError = ref<ApiErrorDetails | null>(null)
const masterDataLoaded = ref(false)
const rows = ref<PlatformCarrierRateResource[]>([])
const warehouses = ref<PlatformWarehouseResource[]>([])
@@ -69,7 +64,6 @@ const detailLoading = ref(false)
const saving = ref(false)
const detail = ref<PlatformCarrierRateResource | null>(null)
const retiringIds = ref(new Set<string>())
const editorCustomerId = ref('')
const formRef = ref<FormInstance>()
let loadSequence = 0
let masterDataSequence = 0
@@ -100,18 +94,9 @@ const { captureBaseline } = useUnsavedWorkRegistration({
requiredPermission,
})
const filters = ref<RateFilters>({ customerId: '', rateCode: '', destinationCode: '', channelCode: '', chargeUnit: '', status: '' })
const filters = ref<RateFilters>({ rateCode: '', destinationCode: '', channelCode: '', chargeUnit: '', status: '' })
const appliedFilters = ref<RateFilters>({ ...filters.value })
const customerSwitchDisabled = computed(() => editorOpen.value || detailOpen.value || saving.value || detailLoading.value || retiringIds.value.size > 0)
const queryFields = computed<QueryField[]>(() => [
{
key: 'customerId',
type: 'select',
width: 280,
placeholder: customerLoadError.value ? '企业列表加载失败' : '全部客户',
props: { 'aria-label': '所属企业', filterable: true, disabled: customersLoading.value || Boolean(customerLoadError.value) || customerSwitchDisabled.value },
options: customers.value.map((customer) => ({ value: String(customer.id), label: `${customer.tenantName}(${customer.tenantCode || customer.id})` })),
},
{ key: 'rateCode', type: 'input', width: 220, placeholder: '价格编码', props: { 'aria-label': '价格编码' } },
{ key: 'destinationCode', type: 'input', width: 220, placeholder: 'FBA 目的仓编码', props: { 'aria-label': 'FBA 目的仓编码' } },
{ key: 'channelCode', type: 'input', width: 220, placeholder: '渠道编码', props: { 'aria-label': '渠道编码' } },
@@ -131,7 +116,7 @@ const queryFields = computed<QueryField[]>(() => [
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
{ key: 'customer', label: '所属企业', minWidth: 200, slotName: 'customer' },
{ key: 'scope', label: '适用范围', minWidth: 140, slotName: 'scope' },
{ key: 'destinationCode', prop: 'destinationCode', label: 'FBA 目的仓', minWidth: 135 },
{ key: 'rate', label: '价格版本', minWidth: 180, slotName: 'rate' },
{ key: 'originWarehouseName', prop: 'originWarehouseName', label: '起运仓', minWidth: 170, showOverflowTooltip: true },
@@ -188,10 +173,6 @@ const rules: FormRules<RateForm> = {
effectiveTo: [{ validator: validateEffectiveTo, trigger: 'change' }],
}
watch(() => filters.value.customerId, () => {
page.value = 1
})
watch(
() => access.can('firstleg:fba-pricing:rate:edit'),
(canEdit) => {
@@ -214,43 +195,16 @@ function statusLabel(status: CarrierRateStatus) {
return status === 'ACTIVE' ? '启用' : '停用'
}
function customerLabel(customerId: string) {
const customer = customers.value.find((item) => String(item.id) === String(customerId))
return customer ? `${customer.tenantName}(${customer.tenantCode || '未设置编码'})` : '客户信息待加载'
}
function nullable(value: string) {
return value.trim() || null
}
async function loadCustomers() {
customersLoading.value = true
customerLoadError.value = null
try {
const records: PlatformCustomerResource[] = []
let currentPage = 1
let pages = 1
do {
const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' })
records.push(...result.records)
pages = Math.max(1, result.pages)
currentPage += 1
} while (currentPage <= pages)
customers.value = records
} catch (error) {
customers.value = []
customerLoadError.value = getApiErrorDetails(error, '企业列表加载失败,暂时无法按所属企业筛选或显示企业名称;价格列表仍可查询')
} finally {
customersLoading.value = false
}
}
async function loadRateData(customerId = filters.value.customerId) {
async function loadRateData() {
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const rateResult = await listPlatformCarrierRates(customerId ? { customerId } : {})
const rateResult = await listPlatformCarrierRates({ rateCode: filters.value.rateCode })
if (sequence !== loadSequence) return
rows.value = rateResult
} catch (error) {
@@ -292,9 +246,7 @@ async function loadPlatformMasterData() {
}
async function applyQuery(model: QueryModel) {
const customerId = String(model.customerId || '')
appliedFilters.value = {
customerId,
rateCode: String(model.rateCode || ''),
destinationCode: String(model.destinationCode || ''),
channelCode: String(model.channelCode || ''),
@@ -302,12 +254,11 @@ async function applyQuery(model: QueryModel) {
status: (model.status || '') as CarrierRateStatus | '',
}
page.value = 1
await loadRateData(customerId)
await loadRateData()
}
function resetQuery(model: QueryModel) {
appliedFilters.value = {
customerId: String(model.customerId || ''),
rateCode: String(model.rateCode || ''),
destinationCode: String(model.destinationCode || ''),
channelCode: String(model.channelCode || ''),
@@ -336,16 +287,10 @@ function resetForm(rate?: PlatformCarrierRateResource) {
}
async function openCreate(rate?: PlatformCarrierRateResource) {
const customerId = String(rate?.tenantId || filters.value.customerId || '')
if (!customerId) {
ElMessage.warning('新增价格版本需要先选择所属企业')
return
}
if (!masterDataLoaded.value) {
const loaded = await loadPlatformMasterData()
if (!loaded) return
}
editorCustomerId.value = customerId
resetForm(rate)
captureBaseline()
editorOpen.value = true
@@ -357,7 +302,7 @@ async function openDetail(row: PlatformCarrierRateResource) {
detailLoading.value = true
detail.value = null
try {
detail.value = await getPlatformCarrierRate(String(row.tenantId), row.id)
detail.value = await getPlatformCarrierRate(row.id)
} catch (error) {
const meta = getApiErrorDetails(error, '价格版本详情加载失败')
ElMessage.error(meta.requestId ? `${meta.message}(requestId: ${meta.requestId})` : meta.message)
@@ -399,8 +344,6 @@ function buildPayload(): CreatePlatformCarrierRateVersionRequest | null {
}
async function saveVersion() {
const customerId = editorCustomerId.value
if (!customerId) return
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
const payload = buildPayload()
@@ -410,10 +353,10 @@ async function saveVersion() {
}
saving.value = true
try {
await createPlatformCarrierRateVersion(customerId, payload)
await createPlatformCarrierRateVersion(payload)
editorOpen.value = false
ElMessage.success('船司 FBA 价格新版本已创建')
await loadRateData(filters.value.customerId)
await loadRateData()
} catch (error) {
const meta = getApiErrorDetails(error, '价格版本创建失败,请检查有效期和匹配维度后重试')
ElMessage.error(meta.requestId ? `${meta.message}(requestId: ${meta.requestId})` : meta.message)
@@ -430,9 +373,9 @@ async function retireRate(row: PlatformCarrierRateResource) {
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消' },
)
retiringIds.value = new Set(retiringIds.value).add(row.id)
await retirePlatformCarrierRate(String(row.tenantId), row.id)
await retirePlatformCarrierRate(row.id)
ElMessage.success('价格版本已停用')
await loadRateData(filters.value.customerId)
await loadRateData()
} catch (error) {
if (error === 'cancel' || error === 'close') return
const meta = getApiErrorDetails(error, '价格版本停用失败,请重试')
@@ -450,7 +393,6 @@ function closeEditor() {
}
onMounted(() => {
void loadCustomers()
void loadRateData()
})
</script>
@@ -465,11 +407,6 @@ onMounted(() => {
@reset="resetQuery"
/>
<el-alert v-if="customerLoadError" type="warning" :closable="false" show-icon role="alert">
<template #title><div class="api-error-alert-title"><span>{{ customerLoadError.message }}</span><ApiErrorMeta :details="customerLoadError" /></div></template>
<el-button link type="warning" @click="loadCustomers">重新加载企业列表</el-button>
</el-alert>
<LoadErrorState
v-if="loadError"
title="船司 FBA 价格加载失败"
@@ -489,7 +426,7 @@ onMounted(() => {
<el-button
v-if="access.can('firstleg:fba-pricing:rate:edit')"
type="primary"
:disabled="!filters.customerId || masterDataLoading"
:disabled="masterDataLoading"
@click="openCreate()"
><Plus :size="16" />新增价格版本</el-button>
</div>
@@ -524,7 +461,7 @@ onMounted(() => {
><Power :size="15" />停用</el-button>
</div>
</template>
<template #customer="{ row }"><strong>{{ customerLabel(String((row as PlatformCarrierRateResource).tenantId)) }}</strong></template>
<template #scope><strong>全部客户</strong></template>
<template #rate="{ row }">
<div class="primary-cell"><strong>{{ (row as PlatformCarrierRateResource).rateCode }}</strong><span>版本 V{{ (row as PlatformCarrierRateResource).rateVersion }}</span></div>
</template>
@@ -601,7 +538,7 @@ onMounted(() => {
<div><strong>{{ detail.rateCode }}</strong><span>版本 V{{ detail.rateVersion }}</span></div>
<StatusBadge :status="statusLabel(detail.status)" />
</header>
<section class="detail-section"><h3><Warehouse :size="17" />运输匹配</h3><dl class="detail-grid"><div><dt>所属企业</dt><dd>{{ customerLabel(detail.tenantId) }}</dd></div><div><dt>起运仓</dt><dd>{{ detail.originWarehouseName }}</dd></div><div><dt>FBA 目的仓</dt><dd>{{ detail.destinationCode }}</dd></div><div><dt>业务类型</dt><dd>{{ detail.businessType }}</dd></div><div><dt>渠道编码</dt><dd>{{ detail.channelCode }}</dd></div><div><dt>船司</dt><dd>{{ detail.carrierName }}</dd></div><div><dt>船司 ID</dt><dd>{{ detail.carrierId }}</dd></div></dl></section>
<section class="detail-section"><h3><Warehouse :size="17" />运输匹配</h3><dl class="detail-grid"><div><dt>适用范围</dt><dd>全部客户</dd></div><div><dt>起运仓</dt><dd>{{ detail.originWarehouseName }}</dd></div><div><dt>FBA 目的仓</dt><dd>{{ detail.destinationCode }}</dd></div><div><dt>业务类型</dt><dd>{{ detail.businessType }}</dd></div><div><dt>渠道编码</dt><dd>{{ detail.channelCode }}</dd></div><div><dt>船司</dt><dd>{{ detail.carrierName }}</dd></div><div><dt>船司 ID</dt><dd>{{ detail.carrierId }}</dd></div></dl></section>
<section class="detail-section"><h3><Ship :size="17" />计费口径</h3><dl class="detail-grid"><div><dt>计费单位</dt><dd>{{ detail.chargeUnit }}</dd></div><div><dt>币种</dt><dd>{{ detail.currency }}</dd></div><div><dt>单价</dt><dd>{{ formatMoney(detail.unitPrice, detail.currency) }}</dd></div><div><dt>固定附加费</dt><dd>{{ formatMoney(detail.additionalFee, detail.currency) }}</dd></div></dl></section>
<section class="detail-section"><h3><CalendarClock :size="17" />版本有效期</h3><dl class="detail-grid"><div><dt>生效时间</dt><dd>{{ formatDateTime(detail.effectiveFrom) }}</dd></div><div><dt>结束时间</dt><dd>{{ formatDateTime(detail.effectiveTo) }}</dd></div><div><dt>更新时间</dt><dd>{{ formatDateTime(detail.updatedAt) }}</dd></div><div><dt>记录版本</dt><dd>{{ detail.version ?? '-' }}</dd></div></dl><p v-if="detail.remark" class="detail-remark">{{ detail.remark }}</p></section>
</div>