diff --git a/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.test.ts b/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.test.ts index faf2a07c..43216167 100644 --- a/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.test.ts +++ b/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.test.ts @@ -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') diff --git a/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.ts b/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.ts index 81b64e6c..e1cf9459 100644 --- a/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.ts +++ b/src/modules/fba-pricing/carrier-rates/api/platformPriceBooks.ts @@ -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 { + '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 { const status = query.status?.trim() + const customerId = query.customerId?.trim() || '0' const response = await http.get, ApiEnvelope>(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 { +export async function uploadPlatformPriceBook(file: File, customerId = '0'): Promise { const form = new FormData() form.append('file', file) - const response = await http.post, ApiEnvelope>(basePath, form) + const response = await http.post, ApiEnvelope>(basePath, form, { + params: { customerId: customerId.trim() || '0' }, + }) return normalizeDetail(unwrapApiResponse(response)) } diff --git a/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts b/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts index c41aaf8c..351807d3 100644 --- a/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts +++ b/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts @@ -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', () => { diff --git a/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue b/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue index 60379e53..49817d7c 100644 --- a/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue +++ b/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue @@ -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(null) const uploadOpen = ref(false) const uploadFile = ref(null) const uploadFileError = ref('') +const uploadCustomerId = ref('0') const uploadInput = ref() const uploading = ref(false) const detailOpen = ref(false) @@ -69,15 +72,29 @@ const mappingWarehouses = ref([]) const mappingWarehousesLoaded = ref(false) const mappingWarehousesLoading = ref(false) const mappingWarehousesError = ref(null) +const customerOptions = ref([]) +const customersLoading = ref(false) const originSelections = ref>({}) const overseasSelections = ref>({}) -const filters = ref({ status: '' }) +const filters = ref({ customerId: '0', status: '' }) const appliedFilters = ref({ ...filters.value }) let loadSequence = 0 let detailSequence = 0 let warehouseSequence = 0 -const queryFields: QueryField[] = [ +const queryFields = computed(() => [ + { + 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() }) @@ -650,6 +693,7 @@ onMounted(() => { + @@ -669,7 +713,8 @@ onMounted(() => { @confirm="submitUpload" >
- + +
选择 XLSX 文件 @@ -710,7 +755,7 @@ onMounted(() => { @@ -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; } diff --git a/src/modules/inbounds/fee/InboundFeePanel.test.ts b/src/modules/inbounds/fee/InboundFeePanel.test.ts index b72aa05a..4a0096b0 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.test.ts +++ b/src/modules/inbounds/fee/InboundFeePanel.test.ts @@ -35,9 +35,11 @@ const snapshot: InboundFeeSnapshot = { carrierRateId: '202', rateCode: 'RATE-LAX', carrierRateVersion: 5, customerId: '303', customerName: '测试客户', originWarehouseId: '404', originWarehouseName: '义乌仓', businessType: 'FBA_SEA', channelCode: 'SEA-TAX', carrierId: '505', carrierName: '测试船司', - destinationCode: 'LAX9', taxMode: 'TAX_INCLUDED', chargeMode: 'KG', + destinationCode: 'LAX9', taxMode: 'TAX_REFUND', pricingTaxMode: 'TAX_INCLUDED', chargeMode: 'KG', conversionFactor: 167, roundingScale: 2, roundingMode: 'HALF_UP', - actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6, chargeableQuantity: 133.6, + actualWeightKg: 100, actualVolumeCbm: 0.8, convertedQuantity: 133.6, + baselineChargeableQuantity: 133.6, lowerChargeableQuantity: 120.24, upperChargeableQuantity: 146.96, + tolerancePercent: 10, chargeableQuantity: 133.6, chargeUnit: 'KG', unitPrice: 8.5, subtotalAmount: 1135.6, additionalAmount: 100, totalAmount: 1235.6, currency: 'CNY', rateEffectiveFrom: '2026-08-01T00:00:00', rateEffectiveTo: null, @@ -88,6 +90,8 @@ describe('InboundFeePanel', () => { await flushPromises() expect(getLatestInboundFee).toHaveBeenCalledWith('10001', '88') + expect(document.body.textContent).toContain('订单报关:退税报关') + expect(document.body.textContent).toContain('报价税制:包税') expect(document.body.textContent).not.toContain('租户') expect(document.body.textContent).toContain('头程自动费用 · CNY') expect(document.body.textContent).toContain('¥1,235.60') @@ -237,6 +241,18 @@ describe('InboundFeePanel', () => { expect(wrapper.find('[data-testid="adjust-fee-button"]').exists()).toBe(true) }) + it('allows a published fee adjustment until the customer confirms it', async () => { + const wrapper = mountPanel() + await flushPromises() + expect(wrapper.get('[data-testid="adjust-fee-button"]').text()).toContain('调整已发布费用') + + document.body.innerHTML = '' + vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, customerConfirmationStatus: 'CONFIRMED' }) + const confirmed = mountPanel() + await flushPromises() + expect(confirmed.find('[data-testid="adjust-fee-button"]').exists()).toBe(false) + }) + it('confirms and then sends a calculated fee through the implemented backend transitions', async () => { vi.mocked(getLatestInboundFee).mockResolvedValue({ ...snapshot, feeStatus: 'CALCULATED', confirmedAt: null, sentAt: null }) vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never) @@ -294,7 +310,7 @@ describe('InboundFeePanel', () => { expect(adjustPublishedInboundFee).toHaveBeenCalledWith( '10001', '88', - { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费用' }, + { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费用', toleranceConfirmed: true }, expect.stringMatching(/^fee-adjust-/), ) expect(wrapper.emitted('changed')?.[0]?.[0]).toMatchObject({ feeVersion: 3 }) @@ -307,6 +323,11 @@ describe('InboundFeePanel', () => { expect(source).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))') expect(source).toContain('class="adjustment-unit"') expect(source).toContain('white-space: nowrap') + expect(source).toContain('snapshot.value?.lowerChargeableQuantity') + expect(source).toContain('snapshot.value?.upperChargeableQuantity') + expect(source).toContain('toleranceConfirmed: true') + expect(source).toContain("'再次确认计费量调整'") + expect(source).toContain('nextQuantity < adjustmentMinimum.value || nextQuantity > adjustmentMaximum.value') }) it('refreshes the latest snapshot after a conflict while retaining the dialog', async () => { diff --git a/src/modules/inbounds/fee/InboundFeePanel.vue b/src/modules/inbounds/fee/InboundFeePanel.vue index b51a25ce..28f91214 100644 --- a/src/modules/inbounds/fee/InboundFeePanel.vue +++ b/src/modules/inbounds/fee/InboundFeePanel.vue @@ -86,11 +86,17 @@ const normalizedInboundId = computed(() => { return identifier && identifier !== 'null' && identifier !== 'undefined' ? identifier : '' }) const adjustmentEligible = computed(() => ( - snapshot.value?.feeStatus === 'CALCULATED' + ['CALCULATED', 'CONFIRMED', 'SENT'].includes(snapshot.value?.feeStatus || '') && snapshot.value?.nextProcessStatus === 'WAIT_SPLIT' + && snapshot.value?.customerConfirmationStatus !== 'CONFIRMED' )) const chargeModeLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '按重量计费' : '按体积计费') const chargeUnitLabel = computed(() => snapshot.value?.chargeUnit === 'KG' ? 'KG' : 'CBM') +const convertedQuantityLabel = computed(() => snapshot.value?.chargeMode === 'KG' ? '体积重量' : '重量折算体积') +const adjustmentBaseline = computed(() => Number(snapshot.value?.baselineChargeableQuantity || 0)) +const adjustmentMinimum = computed(() => Number(snapshot.value?.lowerChargeableQuantity || 0)) +const adjustmentMaximum = computed(() => Number(snapshot.value?.upperChargeableQuantity || 0)) +const adjustmentQuantityChanged = computed(() => Math.abs(Number(adjustmentForm.chargeableQuantity) - adjustmentBaseline.value) > 1e-8) const statusInfo = computed(() => feeStatusInfo(snapshot.value?.feeStatus)) const sourceOptions = computed(() => { const value = measurement.value @@ -145,6 +151,12 @@ function taxModeLabel(value: string) { return firstLegTaxModeLabel(value) } +function pricingTaxModeLabel(value: string | null | undefined) { + if (value === 'TAX_INCLUDED') return '包税' + if (value === 'TAX_EXCLUDED') return '不包税' + return '—' +} + function roundingModeLabel(value: string) { const labels: Record = { UP: '远离零方向', DOWN: '向零方向', CEILING: '向上', FLOOR: '向下', @@ -275,6 +287,7 @@ function adjustmentPayload(): AdjustPublishedInboundFeeRequest { unitPrice: Number(adjustmentForm.unitPrice), additionalAmount: Number(adjustmentForm.additionalAmount), reason: adjustmentForm.reason.trim(), + toleranceConfirmed: true, } } @@ -291,6 +304,22 @@ async function submitAdjustment() { if (!snapshot.value) return const valid = await adjustFormRef.value?.validate().catch(() => false) if (!valid) return + const nextQuantity = Number(adjustmentForm.chargeableQuantity) + if (nextQuantity < adjustmentMinimum.value || nextQuantity > adjustmentMaximum.value) { + ElMessage.warning(`调整后计费量须在基准值的 ±10% 范围内(${formatNumber(adjustmentMinimum.value, 4)}–${formatNumber(adjustmentMaximum.value, 4)} ${chargeUnitLabel.value})`) + return + } + if (adjustmentQuantityChanged.value) { + try { + await ElMessageBox.confirm( + `计费量将由 ${formatNumber(adjustmentBaseline.value, 4)} 调整为 ${formatNumber(nextQuantity, 4)} ${chargeUnitLabel.value},并生成新费用版本。是否继续?`, + '再次确认计费量调整', + { type: 'warning', confirmButtonText: '确认调整', cancelButtonText: '返回检查' }, + ) + } catch { + return + } + } const payload = adjustmentPayload() adjustmentError.value = null conflictRefreshed.value = false @@ -364,7 +393,7 @@ defineExpose({ reload: loadFee }) data-testid="adjust-fee-button" type="primary" @click="openAdjustment" - >调整初算费用 + >{{ snapshot?.feeStatus === 'CALCULATED' ? '调整初算费用' : '调整已发布费用' }}

计费依据

实际重量与体积重量取大后计费,金额以服务端快照为准。

- {{ taxModeLabel(snapshot.taxMode) }} +
+ 订单报关:{{ taxModeLabel(snapshot.taxMode) }} + 报价税制:{{ pricingTaxModeLabel(snapshot.pricingTaxMode) }} +
实际重量{{ formatNumber(snapshot.actualWeightKg, 3) }} KG
实际体积{{ formatNumber(snapshot.actualVolumeCbm, 4) }} CBM
-
体积重量{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}系数 {{ formatNumber(snapshot.conversionFactor, 4) }}
+
{{ convertedQuantityLabel }}{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}系数 {{ formatNumber(snapshot.conversionFactor, 4) }}
取大后的计费量{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}
@@ -517,6 +549,10 @@ defineExpose({ reload: loadFee }) @confirm="submitAdjustment" > + + +

系统基准 {{ formatNumber(adjustmentBaseline, 4) }} {{ chargeUnitLabel }},服务端允许范围 {{ formatNumber(adjustmentMinimum, 4) }}–{{ formatNumber(adjustmentMaximum, 4) }} {{ chargeUnitLabel }}(包含边界);超出范围将无法提交。

+

当前输入仍保留,请先核对最新版本后再提交。

@@ -524,7 +560,7 @@ defineExpose({ reload: loadFee })
- + {{ chargeUnitLabel }} @@ -571,6 +607,7 @@ defineExpose({ reload: loadFee }) .version-status { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } .calculation-section, .fee-timeline { padding: 10px 0; border-bottom: 1px solid var(--cc-border); } .section-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.calculation-tags { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; } .section-title h4 { font-size: 14px; } .calculation-flow { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 7px; border-top: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); } .calculation-flow > div { display: flex; min-width: 0; min-height: 46px; flex-direction: row; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 10px; border-right: 1px solid var(--cc-border); } diff --git a/src/modules/inbounds/fee/api.test.ts b/src/modules/inbounds/fee/api.test.ts index 8c485311..95f7aab9 100644 --- a/src/modules/inbounds/fee/api.test.ts +++ b/src/modules/inbounds/fee/api.test.ts @@ -42,7 +42,7 @@ describe('inbound fee API', () => { it('adjusts a published snapshot with the required idempotency key', async () => { vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { ...snapshot, feeVersion: 3 } }) - const payload = { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费' } + const payload = { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '补充已确认的附加费', toleranceConfirmed: true as const } await expect(adjustPublishedInboundFee('10001', '88', payload, 'fee-adjust-1')) .resolves.toMatchObject({ feeVersion: 3 }) @@ -78,7 +78,7 @@ describe('inbound fee API', () => { const error = await adjustPublishedInboundFee( '10001', '88', - { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '价格修正' }, + { expectedFeeVersion: 2, chargeableQuantity: 140, unitPrice: 8.2, additionalAmount: 80, reason: '价格修正', toleranceConfirmed: true }, 'fee-adjust-2', ).catch((reason: unknown) => reason) diff --git a/src/modules/inbounds/fee/types.ts b/src/modules/inbounds/fee/types.ts index 305d247a..1e3b814b 100644 --- a/src/modules/inbounds/fee/types.ts +++ b/src/modules/inbounds/fee/types.ts @@ -26,6 +26,7 @@ export interface InboundFeeSnapshot { carrierName: string destinationCode: string taxMode: string + pricingTaxMode: string | null chargeMode: InboundChargeMode conversionFactor: number roundingScale: number @@ -33,6 +34,10 @@ export interface InboundFeeSnapshot { actualWeightKg: number actualVolumeCbm: number convertedQuantity: number + baselineChargeableQuantity: number + lowerChargeableQuantity: number + upperChargeableQuantity: number + tolerancePercent: number chargeableQuantity: number chargeUnit: string unitPrice: number | null @@ -58,4 +63,5 @@ export interface AdjustPublishedInboundFeeRequest { unitPrice: number additionalAmount: number reason: string + toleranceConfirmed: true } diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts index 7907255c..fced8f6c 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.test.ts @@ -89,6 +89,9 @@ function measurement(overrides: Partial = {}): Inbound items: [{ id: '9101', inboundDetailId: '8101', + inboundCode: 'RK202608040001', + fbaShipmentNo: 'FBA18ABC1234', + scannedShipmentId: 'FBA18SCAN5678', boxCode: 'RK202608040001#10-1', itemNo: '001', boxSpecification: '50×40×30cm', @@ -168,6 +171,8 @@ describe('InboundMeasurementPanel', () => { expect(getInboundMeasurement).toHaveBeenCalledWith('10001', '7001') expect(wrapper.text()).toContain('RK202608040001') + expect(wrapper.find('.measurement-table').text()).toContain('FBA18ABC1234') + expect(wrapper.find('.measurement-table').text()).toContain('FBA18SCAN5678') expect(wrapper.find('.measurement-table').text()).toContain('0.6') expect(wrapper.find('.measurement-table').text()).not.toContain('周长') await wrapper.find('.el-table__expand-icon').trigger('click') diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue index f423f66a..3ebab966 100644 --- a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue +++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue @@ -32,6 +32,9 @@ const emit = defineEmits<{ type EditableMeasurementItem = Omit & { itemId: string clientKey: string + inboundCode: string + fbaShipmentNo: string | null + scannedShipmentId: string | null serverValues: Pick chargeRuleId: string | null coefficientMode: 'ACTUAL' | 'ADD' | 'MULTIPLY' @@ -266,6 +269,9 @@ function editableItem(item: InboundMeasurementItem): EditableMeasurementItem { itemId: String(item.id), inboundDetailId: String(item.inboundDetailId || item.id), clientKey: nextItemKey(), + inboundCode: item.inboundCode || '—', + fbaShipmentNo: item.fbaShipmentNo || null, + scannedShipmentId: item.scannedShipmentId || null, boxCode: item.boxCode, itemNo: item.itemNo, boxSpecification: item.boxSpecification, @@ -701,6 +707,15 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may + + + + + + + + + diff --git a/src/modules/inbounds/measurement/types.ts b/src/modules/inbounds/measurement/types.ts index 52fce114..0f784174 100644 --- a/src/modules/inbounds/measurement/types.ts +++ b/src/modules/inbounds/measurement/types.ts @@ -13,7 +13,10 @@ export type InboundMeasurementProcessStatus = export interface InboundMeasurementItem { id: MeasurementIdentifier + inboundCode?: string | null inboundDetailId?: MeasurementIdentifier | null + fbaShipmentNo?: string | null + scannedShipmentId?: string | null boxCode: string | null itemNo: string boxSpecification: string diff --git a/src/modules/loading-lists/api/platformLoadingLists.test.ts b/src/modules/loading-lists/api/platformLoadingLists.test.ts index 8e73e56f..1b0268ad 100644 --- a/src/modules/loading-lists/api/platformLoadingLists.test.ts +++ b/src/modules/loading-lists/api/platformLoadingLists.test.ts @@ -48,7 +48,7 @@ const detail = { summary: preloadSummary, items: [{ itemId: 11, itemVersion: 4, accountId: 12, orderId: 15, orderNo: 'ORD-001', inboundId: 13, inboundDetailId: 14, containerNo: 'MSCU1234567', productQuantity: 20, - customerName: '测试客户', inboundCode: 'RK001', skuCode: 'SKU001', destinationWarehouse: 'ONT8', chineseName: '产品', englishName: null, + customerName: '测试客户', customsTaxMode: 'TAX_REFUND', taxRefundFlag: true, inboundCode: 'RK001', skuCode: 'SKU001', destinationWarehouse: 'ONT8', chineseName: '产品', englishName: null, goodsImageUrl: null, unitType: 'SEALED_PALLET', palletId: 9007199254740993n, palletNo: 'PLT-001', indivisible: true, packageCount: 2, palletCount: 1, grossWeightKg: 10, netWeightKg: 9, volumeCbm: 1, dimensionalWeightKg: 2, unitsPerPackage: 10, fbaTrackingNo: null, shipmentNo: null, shippingMark: null, internalRemark: null, customerRemark: null, warehouseRemark: null, loadSequence: 1, frozenAt: null, @@ -75,7 +75,7 @@ describe('platform loading lists API', () => { vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: detail }) const result = await getPreloadPlan('100', '300') - expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', unitType: 'SEALED_PALLET', palletId: '9007199254740993', palletNo: 'PLT-001', indivisible: true, containerNo: 'MSCU1234567', productQuantity: '20' }) + expect(result.items[0]).toMatchObject({ itemId: '11', itemVersion: 4, accountId: '12', orderId: '15', orderNo: 'ORD-001', inboundId: '13', inboundDetailId: '14', unitType: 'SEALED_PALLET', palletId: '9007199254740993', palletNo: 'PLT-001', indivisible: true, containerNo: 'MSCU1234567', productQuantity: '20', customsTaxMode: 'TAX_REFUND', taxRefundFlag: true }) expect(result.summary.containerBookingId).toBe('1900000000000000004') expect(result.histories[0]).toMatchObject({ historyId: '21', itemId: '11', operationId: 'transport-21', operatorId: '22' }) }) diff --git a/src/modules/loading-lists/api/platformLoadingLists.ts b/src/modules/loading-lists/api/platformLoadingLists.ts index 0fe802b7..fcf15b13 100644 --- a/src/modules/loading-lists/api/platformLoadingLists.ts +++ b/src/modules/loading-lists/api/platformLoadingLists.ts @@ -80,6 +80,8 @@ export interface LoadingItem { itemVersion: number accountId: string customerName: string | null + customsTaxMode: 'TAX_REFUND' | 'BUY_DECLARATION' | null + taxRefundFlag: boolean | null orderId: string orderNo: string inboundId: string diff --git a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue index 67f83571..e14f564a 100644 --- a/src/modules/loading-lists/components/LoadingListDetailDrawer.vue +++ b/src/modules/loading-lists/components/LoadingListDetailDrawer.vue @@ -35,6 +35,7 @@ const baseItemColumns: DataTableColumn[] = [ { key: 'skuCode', prop: 'skuCode', label: 'SKU', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true }, { key: 'destinationWarehouse', prop: 'destinationWarehouse', label: '目的仓', minWidth: 150, className: 'identifier-column', showOverflowTooltip: true }, { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 150 }, + { key: 'taxRefundFlag', label: '是否退税', width: 96, align: 'center', slotName: 'taxRefundFlag' }, { key: 'chineseName', prop: 'chineseName', label: '中文品名', minWidth: 160 }, { key: 'englishName', prop: 'englishName', label: '英文品名', minWidth: 170 }, { key: 'netWeightKg', label: '净重(kg)', width: 110, align: 'right', slotName: 'netWeight' }, @@ -217,6 +218,7 @@ function formatNumber(value?: number | null, digits = 3) { + diff --git a/src/modules/loading-lists/components/LoadingListsView.test.ts b/src/modules/loading-lists/components/LoadingListsView.test.ts index a2d5c685..3842e1be 100644 --- a/src/modules/loading-lists/components/LoadingListsView.test.ts +++ b/src/modules/loading-lists/components/LoadingListsView.test.ts @@ -152,6 +152,7 @@ describe('LoadingListsView', () => { expect(detailSource.indexOf("key: 'orderNo'")).toBeLessThan(detailSource.indexOf("key: 'containerNo'")) expect(detailSource.indexOf("key: 'containerNo'")).toBeLessThan(detailSource.indexOf("key: 'skuCode'")) const fields = [ + 'taxRefundFlag', 'chineseName', 'englishName', 'netWeightKg', diff --git a/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.test.ts b/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.test.ts index 23c2b7f8..7caa4701 100644 --- a/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.test.ts @@ -17,7 +17,10 @@ describe('DeliveryOrderDetailView', () => { status: 'DELIVERING', overseasWarehouseName: '洛杉矶仓', palletCount: 1, packageCount: 2, createdAt: '2026-09-01T10:00:00', }, - skus: [], fees: [], operations: [], + skus: [], fees: [ + { id: 'fee-private', feeDirection: 'RECEIVABLE', feeType: 'PRIVATE_LAST_MILE_DELIVERY', currency: 'USD', amount: 88 }, + { id: 'fee-delivery', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 66 }, + ], operations: [], }) }) @@ -28,6 +31,9 @@ describe('DeliveryOrderDetailView', () => { expect(wrapper.text()).toContain('DEL/1') expect(wrapper.text()).toContain('派送中') expect(wrapper.text()).toContain('洛杉矶仓') + await wrapper.find('.el-tabs__item:nth-child(3)').trigger('click') + expect(wrapper.text()).toContain('私人仓尾程派送费') + expect(wrapper.text()).toContain('派送费用') wrapper.unmount() }) diff --git a/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.vue b/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.vue index 64152d13..9a3672e7 100644 --- a/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.vue +++ b/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.vue @@ -24,7 +24,7 @@ const statusLabels: Record = { WAIT_DELIVERY: '待派送', DELIV const methodLabels: Record = { FBA_PICKUP: 'FBA自提', CUSTOMER_PICKUP: '客户自提', TRUCK: '卡车派送', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送', SELF_DELIVERY: '自送', THIRD_PARTY: '第三方配送' } const pickupLabels: Record = { ONE_PICKUP_ONE_DROP: '一提一卸', ONE_PICKUP_MULTI_DROP: '一提多卸', MULTI_PICKUP_ONE_DROP: '多提一卸', MULTI_PICKUP_MULTI_DROP: '多提多卸', WAREHOUSE_PICKUP: '仓库自提', DOOR_PICKUP: '上门提货', SELF_PICKUP: '自提' } const loadingLabels: Record = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' } -const feeLabels: Record = { DELIVERY_FEE: '派送费用', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' } +const feeLabels: Record = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' } const operationLabels: Record = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库' } const resultLabels: Record = { CREATED: '已创建' } diff --git a/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts b/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts index 4a52f479..8cfe45f6 100644 --- a/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts +++ b/src/modules/overseas-warehouse/pages/InventoryCustomerName.behavior.test.ts @@ -64,7 +64,29 @@ describe('inventory customer-name snapshots', () => { await flushPromises() expect(wrapper.text()).toContain('华远贸易') expect(wrapper.text()).toContain('洛杉矶仓') + expect(wrapper.find('.overseas-detail-title p').text()).toBe(`入库单号 / 入仓码 / SKU ${balance.inboundOrderNo} / ${balance.inboundCode} / ${balance.skuCode}`) + expect(wrapper.text()).toContain('美本土散板') + expect(wrapper.text()).not.toContain('美本土散版') expect(wrapper.text()).not.toContain('ONT8目的仓') expect(wrapper.text()).not.toContain(customerId) + expect(wrapper.text()).not.toContain(balance.id) + }) + + it('combines the available references and then shows an explicit empty business reference without exposing the inventory id', async () => { + vi.mocked(listInventoryBalances).mockResolvedValueOnce({ records: [{ ...balance, inboundOrderNo: '', skuCode: '' }], page: 1, size: 10, total: 1, pages: 1 }) + routing.route.params = { inventoryId: balance.id } + routing.route.query = { inboundCode: balance.inboundCode } + const inboundCodeWrapper = mount(InventoryDetailView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) + await flushPromises() + expect(inboundCodeWrapper.find('.overseas-detail-title p').text()).toBe(`入仓码 ${balance.inboundCode}`) + expect(inboundCodeWrapper.text()).not.toContain(balance.id) + + inboundCodeWrapper.unmount() + vi.mocked(listInventoryBalances).mockResolvedValueOnce({ records: [{ ...balance, inboundOrderNo: '', inboundCode: '', skuCode: '' }], page: 1, size: 10, total: 1, pages: 1 }) + routing.route.query = {} + const emptyWrapper = mount(InventoryDetailView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } }) + await flushPromises() + expect(emptyWrapper.find('.overseas-detail-title p').text()).toBe('业务标识 —') + expect(emptyWrapper.text()).not.toContain(balance.id) }) }) diff --git a/src/modules/overseas-warehouse/pages/InventoryDetailView.vue b/src/modules/overseas-warehouse/pages/InventoryDetailView.vue index 6eae5485..89d55869 100644 --- a/src/modules/overseas-warehouse/pages/InventoryDetailView.vue +++ b/src/modules/overseas-warehouse/pages/InventoryDetailView.vue @@ -8,9 +8,16 @@ import { listInventoryBalances, listInventoryLedger, listInventoryPositions, typ const route = useRoute(); const router = useRouter(); const inventoryId = computed(() => String(route.params.inventoryId || '')) const balance = ref(null); const positions = ref([]); const ledger = ref([]); const loading = ref(false); const loadError = ref(null) function text(value: unknown) { return String(value ?? '').trim() || '—' } +const businessReferences = computed(() => [ + ['入库单号', balance.value?.inboundOrderNo || route.query.inboundOrderNo], + ['入仓码', balance.value?.inboundCode || route.query.inboundCode], + ['SKU', balance.value?.skuCode || route.query.skuCode], +].map(([label, value]) => [String(label), String(value ?? '').trim()] as const).filter(([, value]) => value)) +const referenceLabel = computed(() => businessReferences.value.map(([label]) => label).join(' / ') || '业务标识') +const referenceValue = computed(() => businessReferences.value.map(([, value]) => value).join(' / ') || '—') const productValues = computed(() => ({ 客户: text(balance.value?.customerName), 海外仓: text(balance.value?.overseasWarehouseName), SKU编码: text(balance.value?.skuCode), 产品名称: balance.value ? inventoryProductName(balance.value) : '—', 更新时间: formatOverseasDateTime(balance.value?.updatedAt) })) -const stockValues = computed(() => ({ 在库总量: Number(balance.value?.totalPackages || 0), 可用库存: Number(balance.value?.availablePackages || 0), 暂扣库存: Number(balance.value?.holdPackages || 0), 美本土散版: '—', 在途库存: Number(balance.value?.inTransitPackages || 0), 采购库存: Number(balance.value?.procurementPackages || 0), 预计总量: Number(balance.value?.totalPackages || 0) + Number(balance.value?.inTransitPackages || 0) + Number(balance.value?.procurementPackages || 0) })) -const productFields = ['客户', '海外仓', 'SKU编码', '产品名称', '更新时间']; const stockFields = ['在库总量', '可用库存', '暂扣库存', '美本土散版', '在途库存', '采购库存', '预计总量'] +const stockValues = computed(() => ({ 在库总量: Number(balance.value?.totalPackages || 0), 可用库存: Number(balance.value?.availablePackages || 0), 暂扣库存: Number(balance.value?.holdPackages || 0), 美本土散板: '—', 在途库存: Number(balance.value?.inTransitPackages || 0), 采购库存: Number(balance.value?.procurementPackages || 0), 预计总量: Number(balance.value?.totalPackages || 0) + Number(balance.value?.inTransitPackages || 0) + Number(balance.value?.procurementPackages || 0) })) +const productFields = ['客户', '海外仓', 'SKU编码', '产品名称', '更新时间']; const stockFields = ['在库总量', '可用库存', '暂扣库存', '美本土散板', '在途库存', '采购库存', '预计总量'] const locationRows = computed(() => positions.value.map((item) => ({ id: String(item.id), location: text(item.warehouseLocationCode), status: text(item.status), palletNo: text(item.palletNo), cartons: Number(item.totalPackages || 0), quantity: Number(item.totalPackages || 0), available: Number(item.availablePackages || 0), inboundDate: formatOverseasDateTime(item.updatedAt), ageDays: '—' }))) const palletRows = computed(() => positions.value.map((item) => ({ id: String(item.id), palletNo: text(item.palletNo), cartons: Number(item.totalPackages || 0), cartonRange: '—', location: text(item.warehouseLocationCode), quantity: Number(item.totalPackages || 0) }))) const ledgerRows = computed(() => ledger.value.map((item) => ({ id: String(item.id), occurredAt: formatOverseasDateTime(item.occurredAt), businessType: inventoryBusinessTypeLabel(item.businessType), businessOrderNo: text(item.businessOrderNo), inboundOrderNo: text(item.inboundOrderNo), outboundOrderNo: text(item.outboundOrderNo), location: text(item.warehouseLocationCode), inboundQuantity: item.deltaPackages > 0 ? item.deltaPackages : 0, outboundQuantity: item.deltaPackages < 0 ? Math.abs(item.deltaPackages) : 0, balance: Number(item.afterPackages || 0), operator: '—', remark: text(item.remark) }))) @@ -21,7 +28,7 @@ async function load() { if (!inventoryId.value) { loadError.value = { message: ' function back() { void router.push({ name: 'overseas-inventory' }) }; onMounted(load) -