Merge remote-tracking branch 'origin/release/test' into dev

# Conflicts:
#	src/modules/loading-lists/api/platformLoadingLists.test.ts
This commit is contained in:
2026-09-08 18:37:23 +08:00
24 changed files with 401 additions and 57 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; }
@@ -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 () => {
+42 -5
View File
@@ -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<string, string> = {
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"
><PencilLine :size="16" />调整初算费用</el-button>
><PencilLine :size="16" />{{ snapshot?.feeStatus === 'CALCULATED' ? '调整初算费用' : '调整已发布费用' }}</el-button>
<el-button
v-if="!displayOnly && canConfirm && snapshot?.feeStatus === 'CALCULATED'"
data-testid="confirm-fee-button"
@@ -452,12 +481,15 @@ defineExpose({ reload: loadFee })
<section class="calculation-section" aria-labelledby="calculation-title">
<div class="section-title">
<div><h4 id="calculation-title">计费依据</h4><p v-if="!compact">实际重量与体积重量取大后计费,金额以服务端快照为准。</p></div>
<el-tag effect="plain">{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
<div class="calculation-tags">
<el-tag effect="plain">订单报关:{{ taxModeLabel(snapshot.taxMode) }}</el-tag>
<el-tag effect="plain">报价税制:{{ pricingTaxModeLabel(snapshot.pricingTaxMode) }}</el-tag>
</div>
</div>
<div class="calculation-flow">
<div><span>实际重量</span><strong>{{ formatNumber(snapshot.actualWeightKg, 3) }} KG</strong></div>
<div><span>实际体积</span><strong>{{ formatNumber(snapshot.actualVolumeCbm, 4) }} CBM</strong></div>
<div><span>体积重量</span><strong>{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong><small>系数 {{ formatNumber(snapshot.conversionFactor, 4) }}</small></div>
<div><span>{{ convertedQuantityLabel }}</span><strong>{{ formatNumber(snapshot.convertedQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong><small>系数 {{ formatNumber(snapshot.conversionFactor, 4) }}</small></div>
<div class="is-chargeable"><span>取大后的计费量</span><strong>{{ formatNumber(snapshot.chargeableQuantity, snapshot.roundingScale) }} {{ chargeUnitLabel }}</strong></div>
</div>
<div class="amount-equation" aria-label="费用计算公式">
@@ -517,6 +549,10 @@ defineExpose({ reload: loadFee })
@confirm="submitAdjustment"
>
<el-alert type="warning" :closable="false" show-icon title="调整会生成新的费用版本;小计和总额由服务端按计费量、单价和附加费重算。" />
<el-alert class="adjustment-alert" type="info" :closable="false" show-icon>
<template #title>计费量允许在系统原始基准值 ±{{ formatNumber(snapshot?.tolerancePercent, 2) }}% 内调整</template>
<p>系统基准 {{ formatNumber(adjustmentBaseline, 4) }} {{ chargeUnitLabel }},服务端允许范围 {{ formatNumber(adjustmentMinimum, 4) }}–{{ formatNumber(adjustmentMaximum, 4) }} {{ chargeUnitLabel }}(包含边界);超出范围将无法提交。</p>
</el-alert>
<el-alert v-if="conflictRefreshed" class="adjustment-alert" type="warning" :closable="false" show-icon>
<template #title>费用已被其他操作员更新,系统已刷新最新快照</template>
<p>当前输入仍保留,请先核对最新版本后再提交。</p>
@@ -524,7 +560,7 @@ defineExpose({ reload: loadFee })
<el-form ref="adjustFormRef" :model="adjustmentForm" :rules="adjustmentRules" label-position="top" class="adjustment-form" :disabled="adjusting">
<div class="adjustment-amounts">
<el-form-item label="调整后计费量" prop="chargeableQuantity" required>
<el-input-number v-model="adjustmentForm.chargeableQuantity" aria-label="调整后计费量" :min="0.0001" :max="999999999999.9999" :precision="4" :step="1" controls-position="right" placeholder="请输入调整后计费量" />
<el-input-number v-model="adjustmentForm.chargeableQuantity" aria-label="调整后计费量" :min="adjustmentMinimum" :max="adjustmentMaximum" :precision="4" :step="1" controls-position="right" placeholder="请输入调整后计费量" />
<span class="adjustment-unit">{{ chargeUnitLabel }}</span>
</el-form-item>
<el-form-item label="调整后单价" prop="unitPrice" required>
@@ -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); }
+2 -2
View File
@@ -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)
+6
View File
@@ -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
}
@@ -89,6 +89,9 @@ function measurement(overrides: Partial<InboundMeasurementDetail> = {}): 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')
@@ -32,6 +32,9 @@ const emit = defineEmits<{
type EditableMeasurementItem = Omit<SaveInboundMeasurementItemRequest, 'coefficientMode'> & {
itemId: string
clientKey: string
inboundCode: string
fbaShipmentNo: string | null
scannedShipmentId: string | null
serverValues: Pick<InboundMeasurementItem, 'girthCm' | 'unitVolumeCbm' | 'totalWeightKg' | 'totalVolumeCbm' | 'volumeWeightKg'>
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
</template>
</el-table-column>
<el-table-column prop="itemNo" label="#" width="70" align="center" />
<el-table-column label="入仓码" min-width="154" show-overflow-tooltip>
<template #default="{ row }">{{ row.inboundCode || '—' }}</template>
</el-table-column>
<el-table-column label="FBA 货件号" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.fbaShipmentNo || '—' }}</template>
</el-table-column>
<el-table-column label="设备实扫货件号" min-width="164" show-overflow-tooltip>
<template #default="{ row }">{{ row.scannedShipmentId || '—' }}</template>
</el-table-column>
<el-table-column label="箱号" min-width="156" show-overflow-tooltip>
<template #default="{ row }"><span class="measurement-box-code">{{ row.boxCode || '—' }}</span></template>
</el-table-column>
@@ -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
@@ -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' })
})
@@ -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
@@ -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) {
<template #unitType="{ row }"><el-tag size="small" :type="row.unitType === 'SEALED_PALLET' ? 'success' : 'info'">{{ row.unitType === 'SEALED_PALLET' ? '封托托盘' : '箱件' }}</el-tag><small v-if="row.indivisible">整托不可拆</small></template>
<template #grossWeight="{ row }">{{ formatNumber(row.grossWeightKg) }}</template>
<template #netWeight="{ row }">{{ formatNumber(row.netWeightKg) }}</template>
<template #taxRefundFlag="{ row }">{{ row.taxRefundFlag === true ? '是' : row.taxRefundFlag === false ? '否' : '—' }}</template>
<template #volume="{ row }">{{ formatNumber(row.volumeCbm) }}</template>
<template #dimensionalWeight="{ row }">{{ formatNumber(row.dimensionalWeightKg) }}</template>
<template #customerRemark="{ row }"><div class="remark-cell"><span>{{ row.customerRemark || '—' }}</span><small>来源:客户下单</small></div></template>
@@ -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',
@@ -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()
})
@@ -24,7 +24,7 @@ const statusLabels: Record<string, string> = { WAIT_DELIVERY: '待派送', DELIV
const methodLabels: Record<string, string> = { FBA_PICKUP: 'FBA自提', CUSTOMER_PICKUP: '客户自提', TRUCK: '卡车派送', TRUCK_PALLET: '卡派打托', TRUCK_FLOOR: '卡派地板', EXPRESS: '快递', OUTSOURCED: '外配', DEDICATED_TRUCK: '专车直送', LTL: '零担派送', SELF_DELIVERY: '自送', THIRD_PARTY: '第三方配送' }
const pickupLabels: Record<string, string> = { 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<string, string> = { PALLET: '托盘', FLOOR: '地板', MIXED: '混装', CARTON: '散箱' }
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
const feeLabels: Record<string, string> = { DELIVERY_FEE: '派送费用', PRIVATE_LAST_MILE_DELIVERY: '私人仓尾程派送费', EXPRESS_FEE: '快递费用', LOADING_FEE: '装车费', APPOINTMENT_FEE: '买约费用', OTHER: '其他费用' }
const operationLabels: Record<string, string> = { CREATE_DELIVERY: '创建派送单', ASSIGN_VEHICLE: '车队分配', DISPATCH: '开始派送', SIGN: '签收', COMPLETE_DELIVERY: '完成派送', UPDATE_DELIVERY_STATUS: '更新派送状态', CUSTOMER_VISIT: '客户回访', MARK_RETURN: '标记退货', CANCEL_OUTBOUND: '取消出库' }
const resultLabels: Record<string, string> = { CREATED: '已创建' }
@@ -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)
})
})
@@ -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<InventoryBalanceDto | null>(null); const positions = ref<InventoryPositionDto[]>([]); const ledger = ref<InventoryLedgerDto[]>([]); const loading = ref(false); const loadError = ref<ApiErrorDetails | null>(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)
</script>
<template><OverseasDetailShell title="库存详情" reference-label="库存记录" :reference-value="inventoryId" back-label="返回库存管理" domain="库存详情" :pending="false" :status-text="balance ? '库存事实' : '加载中'" @back="back"><LoadErrorState v-if="loadError" title="库存详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template><OverseasDetailShell title="库存详情" :reference-label="referenceLabel" :reference-value="referenceValue" back-label="返回库存管理" domain="库存详情" :pending="false" :status-text="balance ? '库存事实' : '加载中'" @back="back"><LoadErrorState v-if="loadError" title="库存详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<template v-else><section v-loading="loading" class="overview-panel" aria-label="库存概览"><section aria-labelledby="inventory-product-heading"><h2 id="inventory-product-heading">商品信息</h2><dl class="detail-fields"><div v-for="label in productFields" :key="label"><dt>{{ label }}</dt><dd>{{ productValues[label as keyof typeof productValues] }}</dd></div></dl></section><section aria-labelledby="inventory-summary-heading"><h2 id="inventory-summary-heading">库存状态汇总</h2><dl class="detail-fields"><div v-for="label in stockFields" :key="label"><dt>{{ label }}</dt><dd>{{ stockValues[label as keyof typeof stockValues] }}</dd></div></dl></section></section>
<section class="ledger-section" aria-labelledby="inventory-location-heading"><h2 id="inventory-location-heading">库位库存</h2><DataTable :data="locationRows" :columns="locationColumns" column-settings-mode="hide" row-key="id" empty-text="暂无库位库存" /></section><section class="ledger-section" aria-labelledby="inventory-pallet-heading"><h2 id="inventory-pallet-heading">托盘箱号信息</h2><DataTable :data="palletRows" :columns="palletColumns" column-settings-mode="hide" row-key="id" empty-text="暂无托盘库存" /></section><section class="ledger-section" aria-labelledby="inventory-ledger-heading"><h2 id="inventory-ledger-heading">进销存单据明细</h2><p class="ledger-note">按该库存记录的入库单范围展示,出库字段仅为只读事实。</p><DataTable :data="ledgerRows" :columns="ledgerColumns" column-settings-mode="hide" row-key="id" empty-text="暂无进销存明细" /></section></template>
</OverseasDetailShell></template>
@@ -3,6 +3,7 @@ import { http } from '@/shared/api/http'
import {
approvePlatformUnifiedOrder,
batchApprovePlatformUnifiedOrders,
getPlatformUnifiedOrderBatchApprovalPreview,
addPlatformUnifiedOrderNote,
getPlatformUnifiedOrder,
downloadPlatformProductImage,
@@ -134,11 +135,39 @@ describe('unified orders API contract', () => {
} })
await expect(batchApprovePlatformUnifiedOrders('2089176869176389634', [
'2089223263733518337', '2089223263733518338',
{ orderId: '2089223263733518337', orderVersion: 3 },
{ orderId: '2089223263733518338', orderVersion: 8 },
])).resolves.toMatchObject({ successCount: 1, failureCount: 1 })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/orders/first-leg/batch-approve',
{ orderIds: ['2089223263733518337', '2089223263733518338'] },
{ items: [
{ orderId: '2089223263733518337', orderVersion: 3 },
{ orderId: '2089223263733518338', orderVersion: 8 },
] },
{ params: { tenantId: '2089176869176389634' } },
)
})
it('loads one batch-review preview with Chinese goods and normalizes long IDs', async () => {
vi.mocked(http.post).mockResolvedValue({ data: {
total: 1,
approvableCount: 1,
items: [{
orderId: '2089223263733518337', orderVersion: '3', inboundCode: 'NYEXX2609080001',
customerId: '2089176869176389634', customerName: '测试客户', chineseGoodsNames: [' 蓝牙音箱 ', ''],
channelCode: 'NY_EXX', channelName: '纽约海运', destinationWarehouseCode: 'ONT8',
destinationWarehouseName: 'ONT8 亚马逊仓', canApprove: true, blockReason: null,
}],
} })
const items = [{ orderId: '2089223263733518337', orderVersion: 3 }]
await expect(getPlatformUnifiedOrderBatchApprovalPreview('2089176869176389634', items)).resolves.toMatchObject({
total: 1, approvableCount: 1,
items: [{ orderId: '2089223263733518337', orderVersion: 3, chineseGoodsNames: ['蓝牙音箱'] }],
})
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/orders/first-leg/batch-review-preview',
{ items },
{ params: { tenantId: '2089176869176389634' } },
)
})
@@ -540,10 +540,59 @@ export interface UnifiedOrderBatchApprovalResult {
failedOrders: Array<{ orderId: number | string; code: string; message: string }>
}
export function batchApprovePlatformUnifiedOrders(tenantId: string, orderIds: string[]) {
export interface UnifiedOrderBatchReviewItem {
orderId: string
orderVersion: number
}
export interface UnifiedOrderBatchApprovalPreviewItem extends UnifiedOrderBatchReviewItem {
inboundCode: string | null
customerId: string | null
customerName: string | null
chineseGoodsNames: string[]
channelCode: string | null
channelName: string | null
destinationWarehouseCode: string | null
destinationWarehouseName: string | null
canApprove: boolean
blockReason: string | null
}
export interface UnifiedOrderBatchApprovalPreview {
total: number
approvableCount: number
items: UnifiedOrderBatchApprovalPreviewItem[]
}
function normalizeBatchApprovalPreview(result: UnifiedOrderBatchApprovalPreview): UnifiedOrderBatchApprovalPreview {
return {
...result,
total: integer(result.total, 0),
approvableCount: integer(result.approvableCount, 0),
items: (result.items || []).map((item) => ({
...item,
orderId: String(item.orderId),
orderVersion: integer(item.orderVersion, 0),
customerId: id(item.customerId),
chineseGoodsNames: (item.chineseGoodsNames || []).map((name) => name.trim()).filter(Boolean),
canApprove: Boolean(item.canApprove),
})),
}
}
export async function getPlatformUnifiedOrderBatchApprovalPreview(tenantId: string, items: UnifiedOrderBatchReviewItem[]) {
const response = await http.post<ApiEnvelope<UnifiedOrderBatchApprovalPreview>, ApiEnvelope<UnifiedOrderBatchApprovalPreview>>(
'/platform/v1/orders/first-leg/batch-review-preview',
{ items },
{ params: { tenantId } },
)
return normalizeBatchApprovalPreview(unwrapApiResponse(response))
}
export function batchApprovePlatformUnifiedOrders(tenantId: string, items: UnifiedOrderBatchReviewItem[]) {
return http.post<ApiEnvelope<UnifiedOrderBatchApprovalResult>, ApiEnvelope<UnifiedOrderBatchApprovalResult>>(
'/platform/v1/orders/first-leg/batch-approve',
{ orderIds },
{ items },
{ params: { tenantId } },
).then(unwrapApiResponse)
}
@@ -18,7 +18,7 @@ import type {
InboundFeeSupplierOption, SaveInboundFeeDetailRequest,
} from '@/modules/unified-orders/api/inboundFeeDetails'
const props = defineProps<{ accountId: string; inboundId: string }>()
const props = defineProps<{ accountId: string; inboundId: string; destinationType?: string | null }>()
interface OtherFeeSummary { currency: string; approvedReceivable: number; approvedPayable: number }
const emit = defineEmits<{ changed: []; summary: [value: OtherFeeSummary] }>()
const access = useAccessPreviewStore()
@@ -74,7 +74,11 @@ const directionOptions = computed(() => [
{ label: '应收', value: 'RECEIVABLE' },
{ label: '应付', value: 'PAYABLE', disabled: !canViewSuppliers.value },
])
const applicableItems = computed(() => feeItems.value.filter((item) => form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable))
const privateDeliveryFeeCode = 'PRIVATE_LAST_MILE_DELIVERY'
const applicableItems = computed(() => feeItems.value.filter((item) => {
if (item.feeCode === privateDeliveryFeeCode && props.destinationType !== 'PRIVATE') return false
return form.direction === 'RECEIVABLE' ? item.warehouseReceivable : item.warehousePayable
}))
const selectedFeeItem = computed(() => feeItems.value.find((item) => String(item.id) === String(form.feeItemId)))
function isManual(row: InboundFeeDetail) { return row.sourceType === 'MANUAL' }
@@ -298,7 +302,7 @@ onMounted(() => void load())
<el-table-column label="应收/应付" width="110"><template #default="{ row }">{{ directionLabel(row.direction) }}</template></el-table-column>
<el-table-column label="结算方式" width="120"><template #default="{ row }">{{ settlementLabel(row as InboundFeeDetail) }}</template></el-table-column>
<el-table-column label="已付摘要" min-width="290" show-overflow-tooltip><template #default="{ row }">{{ paidSummary(row as InboundFeeDetail) }}</template></el-table-column>
<el-table-column prop="feeTypeNameSnapshot" label="费用类型" min-width="150" />
<el-table-column label="费用类型" min-width="190" show-overflow-tooltip><template #default="{ row }"><span>{{ row.feeTypeNameSnapshot }}</span><el-tag v-if="row.feeTypeNameSnapshot === '私人仓尾程派送费'" class="private-delivery-tag" size="small" type="warning" effect="light">仅私人仓</el-tag></template></el-table-column>
<el-table-column label="供应商/客户" min-width="170"><template #default="{ row }">{{ row.direction === 'PAYABLE' ? row.supplierName : row.customerName || '—' }}</template></el-table-column>
<el-table-column prop="businessDate" label="业务发生日" width="130" />
<el-table-column prop="currency" label="货币" width="90" />
@@ -314,6 +318,7 @@ onMounted(() => void load())
<AppDialog v-model="editorOpen" :title="editorMode === 'create' ? '新增费用' : editorMode === 'adjust' ? '调整已审核费用' : '编辑费用'" width="min(680px, calc(100vw - 28px))" :confirm-loading="saving" confirm-text="保存" @confirm="save">
<el-alert v-if="editorMode === 'adjust'" title="调整将生成新版本并重新进入待审核状态" type="warning" :closable="false" show-icon />
<el-alert v-if="selectedFeeItem?.feeCode === privateDeliveryFeeCode" title="私人仓尾程派送费仅适用于目的仓类型为“私人仓”的订单,最终资格由服务端校验。" type="info" :closable="false" show-icon />
<el-alert v-if="!canViewFeeItems" title="当前账号缺少费用项目查看权限,无法新增或修改费用" type="warning" :closable="false" show-icon />
<el-alert v-if="!canViewSuppliers" title="当前账号缺少供应商查看权限,应付费用不可维护;应收费用不受影响" type="warning" :closable="false" show-icon />
<el-alert v-if="feeItemsError" type="error" :closable="true" @close="feeItemsError = null"><template #title>{{ feeItemsError.message }}</template><ApiErrorMeta :details="feeItemsError" /></el-alert>
@@ -360,6 +365,7 @@ onMounted(() => void load())
.voucher-field { grid-column: 1 / -1; }.voucher-control { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; width: 100%; }.voucher-picker { display: inline-flex; align-items: center; gap: 6px; padding: 7px 12px; border: 1px solid var(--cc-border); border-radius: 6px; color: var(--el-color-primary); cursor: pointer; }.voucher-picker.is-disabled { cursor: not-allowed; opacity: .6; }.voucher-picker input { position: absolute; width: 1px; height: 1px; opacity: 0; }.voucher-hint { color: var(--cc-muted); font-size: 12px; }.voucher-list { display: grid; width: 100%; gap: 6px; margin-top: 8px; }.voucher-list > span { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 9px; border-radius: 5px; background: var(--el-fill-color-light); }.voucher-list > span > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.voucher-list button { display: inline-flex; padding: 2px; border: 0; background: transparent; color: var(--el-color-danger); cursor: pointer; }
.fee-table-shell { min-width: 0; max-width: 100%; overflow: hidden; }
.fee-table-shell :deep(.el-table) { width: 100%; max-width: 100%; }
.private-delivery-tag { margin-left: 6px; vertical-align: middle; }
@keyframes fee-refresh-spin { to { transform: rotate(360deg); } }
@media (max-width: 720px) { .fee-toolbar { align-items: flex-start; flex-direction: column; } .fee-form { grid-template-columns: 1fr; } .fee-form :deep(.el-form-item:last-child) { grid-column: auto; } }
</style>
@@ -108,6 +108,9 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain('批量审核接口待接入')
expect(source).toContain('@selection-change="handleOrderSelectionChange"')
expect(source).toContain('batchApprovePlatformUnifiedOrders')
expect(source).toContain('getPlatformUnifiedOrderBatchApprovalPreview')
expect(source).toContain('中文品名')
expect(source).toContain('确认批量审核')
expect(source).toContain('批量审核通过')
expect(source).toContain('title="订单列表暂时无法加载"')
expect(source).toContain('empty-text="暂无符合条件的订单"')
@@ -8,6 +8,7 @@ import {
downloadPlatformProductImage,
approvePlatformUnifiedOrder,
batchApprovePlatformUnifiedOrders,
getPlatformUnifiedOrderBatchApprovalPreview,
addPlatformUnifiedOrderNote,
listPlatformUnifiedOrders,
updatePlatformUnifiedOrderPlanning,
@@ -16,6 +17,8 @@ import {
type UnifiedOrderCoLoadGroupSummary,
type UnifiedOrderMainStatus,
type UnifiedOrderResource,
type UnifiedOrderBatchApprovalPreviewItem,
type UnifiedOrderBatchReviewItem,
} from '@/modules/unified-orders/api/unifiedOrders'
import { cancelPlatformCoLoadGroup, createPlatformCoLoadGroup, getPlatformCoLoadGroupByInboundCode, type CoLoadBusinessSummary, type CoLoadGroup } from '@/modules/unified-orders/api/coLoadGroups'
import OrderFulfillmentTreePanel from '@/modules/unified-orders/components/OrderFulfillmentTreePanel.vue'
@@ -150,6 +153,12 @@ const approvalOpen = ref(false)
const approvalLoadingId = ref('')
const approvalSaving = ref(false)
const batchApprovalSaving = ref(false)
const batchApprovalLoading = ref(false)
const batchApprovalOpen = ref(false)
const batchApprovalError = ref<ApiErrorDetails | null>(null)
const batchApprovalPreviewRows = ref<UnifiedOrderBatchApprovalPreviewItem[]>([])
const batchApprovalSelectedRows = ref<UnifiedOrderBatchApprovalPreviewItem[]>([])
const batchApprovalTableRef = ref<{ toggleRowSelection: (row: UnifiedOrderBatchApprovalPreviewItem, selected?: boolean) => void }>()
const exportSubmitting = ref(false)
const selectedApprovalOrders = ref<UnifiedOrderResource[]>([])
const selectedOrders = ref<UnifiedOrderResource[]>([])
@@ -1809,27 +1818,62 @@ async function cancelCoLoadGroup() {
}
}
async function batchApproveOrders() {
function batchReviewItem(order: UnifiedOrderResource): UnifiedOrderBatchReviewItem {
return { orderId: String(order.orderId), orderVersion: Number(order.orderVersion || 0) }
}
function handleBatchApprovalSelection(rows: UnifiedOrderBatchApprovalPreviewItem[]) {
batchApprovalSelectedRows.value = rows
}
async function openBatchApproval() {
const selected = [...selectedApprovalOrders.value]
if (!selected.length || batchApprovalSaving.value) return
batchApprovalSaving.value = true
if (!selected.length || batchApprovalLoading.value || batchApprovalSaving.value) return
batchApprovalOpen.value = true
batchApprovalLoading.value = true
batchApprovalError.value = null
batchApprovalPreviewRows.value = []
batchApprovalSelectedRows.value = []
try {
await ElMessageBox.confirm(
`确认批量审核通过已勾选的 ${selected.length} 个订单吗?审核后订单将进入待测量。`,
'批量审核订单',
{ confirmButtonText: '确认批量审核', cancelButtonText: '取消', type: 'warning' },
)
const groups = new Map<string, UnifiedOrderResource[]>()
selected.forEach((order) => {
const tenantId = String(order.tenantId)
groups.set(tenantId, [...(groups.get(tenantId) || []), order])
})
const previews = await Promise.all([...groups].map(([tenantId, orders]) => (
getPlatformUnifiedOrderBatchApprovalPreview(tenantId, orders.map(batchReviewItem))
)))
batchApprovalPreviewRows.value = previews.flatMap((preview) => preview.items)
await nextTick()
batchApprovalPreviewRows.value.forEach((row) => {
if (row.canApprove) batchApprovalTableRef.value?.toggleRowSelection(row, true)
})
} catch (error) {
batchApprovalError.value = getApiErrorDetails(error, '批量审核预览加载失败,请刷新订单后重试')
} finally {
batchApprovalLoading.value = false
}
}
async function batchApproveOrders() {
const selected = [...batchApprovalSelectedRows.value]
if (!selected.length || batchApprovalSaving.value) return
batchApprovalSaving.value = true
try {
const groups = new Map<string, UnifiedOrderBatchApprovalPreviewItem[]>()
selected.forEach((order) => {
const tenantId = String(order.customerId)
groups.set(tenantId, [...(groups.get(tenantId) || []), order])
})
let successCount = 0
let failureCount = 0
const failures: string[] = []
for (const [tenantId, orders] of groups) {
try {
const result = await batchApprovePlatformUnifiedOrders(tenantId, orders.map((order) => String(order.orderId)))
const result = await batchApprovePlatformUnifiedOrders(tenantId, orders.map((order) => ({
orderId: order.orderId,
orderVersion: order.orderVersion,
})))
successCount += result.successCount
failureCount += result.failureCount
failures.push(...result.failedOrders.map((failure) => failure.message))
@@ -1838,13 +1882,12 @@ async function batchApproveOrders() {
failures.push(getApiErrorDetails(error, '该客户订单批量审核失败').message)
}
}
batchApprovalOpen.value = false
await loadOrders()
const reason = [...new Set(failures.filter(Boolean))].slice(0, 3).join(';')
if (!failureCount) ElMessage.success(`已批量审核通过 ${successCount} 个订单`)
else if (successCount) ElMessage.warning(`批量审核完成:成功 ${successCount} 个,失败 ${failureCount} 个${reason ? `。${reason}` : ''}`)
else ElMessage.error(`批量审核失败:${failureCount} 个订单未通过${reason ? `。${reason}` : ''}`)
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorDetails(error, '批量审核失败').message)
} finally {
batchApprovalSaving.value = false
}
@@ -2243,6 +2286,7 @@ onBeforeUnmount(() => {
<InboundFeeDetailManager
:account-id="String(detail.summary.tenantId)"
:inbound-id="normalizedInboundId"
:destination-type="detail.summary.destinationType"
@summary="otherFeeSummary = $event"
@changed="loadDetail(true)"
/>
@@ -2464,7 +2508,7 @@ onBeforeUnmount(() => {
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="records" :columns="columns" preference-key="unified-orders-table-v2" :loading="loading" :total="total" :page-sizes="[20, 50, 100]" row-key="orderId" border stripe empty-text="暂无符合条件的订单" aria-label="统一订单列表" @selection-change="handleOrderSelectionChange">
<template #toolbar-actions>
<el-button v-if="can('platform:order:export') && can('platform:download:list')" :loading="exportSubmitting" :disabled="loading || exportSubmitting" @click="exportOrders"><Download :size="16" />导出<span v-if="selectedOrders.length">(已选 {{ selectedOrders.length }})</span></el-button>
<el-button v-if="can('platform:order:approve')" type="success" :loading="batchApprovalSaving" :disabled="loading || !selectedApprovalOrders.length" @click="batchApproveOrders"><CircleCheck :size="16" />批量审核通过<span v-if="selectedApprovalOrders.length">({{ selectedApprovalOrders.length }})</span></el-button>
<el-button v-if="can('platform:order:approve')" type="success" :loading="batchApprovalLoading || batchApprovalSaving" :disabled="loading || !selectedApprovalOrders.length" @click="openBatchApproval"><CircleCheck :size="16" />批量审核通过<span v-if="selectedApprovalOrders.length">({{ selectedApprovalOrders.length }})</span></el-button>
<div v-if="can('platform:firstleg:smart-loading:assign')" class="coload-toolbar-action">
<el-button type="primary" plain :loading="coLoadSaving" :disabled="loading || !canCreateCoLoadGroup" aria-label="合柜报关" @click="openCoLoadCreate"><Link2 :size="16" />合柜报关<span v-if="selectedOrders.length">({{ selectedOrders.length }})</span></el-button>
</div>
@@ -2554,6 +2598,22 @@ onBeforeUnmount(() => {
</template>
</ListPageLayout>
<SmartLoadingDialog v-model="smartLoadingOpen" />
<AppDialog v-model="batchApprovalOpen" title="批量审核订单" confirm-text="确认批量审核" confirm-type="success" width="min(1100px, calc(100vw - 28px))" :loading="batchApprovalLoading" :confirm-loading="batchApprovalSaving" :confirm-disabled="batchApprovalLoading || !batchApprovalSelectedRows.length" destroy-on-close @confirm="batchApproveOrders">
<el-alert title="请逐票核对入仓码、客户、中文品名、渠道和目的仓;审核通过后订单进入待测量。" type="info" :closable="false" show-icon />
<LoadErrorState v-if="batchApprovalError" compact title="批量审核预览加载失败" :message="batchApprovalError.message" :loading="batchApprovalLoading" @retry="openBatchApproval"><template #details><ApiErrorMeta :details="batchApprovalError" /></template></LoadErrorState>
<template v-else>
<div class="batch-approval-summary"><span>预览 {{ batchApprovalPreviewRows.length }} 票</span><strong>已勾选 {{ batchApprovalSelectedRows.length }} 票</strong></div>
<el-table ref="batchApprovalTableRef" class="batch-approval-table" :data="batchApprovalPreviewRows" row-key="orderId" border stripe max-height="520" empty-text="暂无可审核订单" @selection-change="handleBatchApprovalSelection">
<el-table-column type="selection" width="48" fixed="left" :selectable="(row: UnifiedOrderBatchApprovalPreviewItem) => row.canApprove" />
<el-table-column label="入仓码" min-width="160" show-overflow-tooltip><template #default="{ row }"><strong>{{ row.inboundCode || '—' }}</strong></template></el-table-column>
<el-table-column label="客户" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.customerName || '—' }}</template></el-table-column>
<el-table-column label="中文品名" min-width="220" show-overflow-tooltip><template #default="{ row }">{{ row.chineseGoodsNames.length ? row.chineseGoodsNames.join('、') : '—' }}</template></el-table-column>
<el-table-column label="渠道" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.channelName || row.channelCode || '—' }}</template></el-table-column>
<el-table-column label="目的仓" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.destinationWarehouseName || row.destinationWarehouseCode || '—' }}</template></el-table-column>
<el-table-column label="校验结果" min-width="180" show-overflow-tooltip><template #default="{ row }"><el-tag :type="row.canApprove ? 'success' : 'danger'" effect="light">{{ row.canApprove ? '可审核' : row.blockReason || '不可审核' }}</el-tag></template></el-table-column>
</el-table>
</template>
</AppDialog>
<AppDialog v-model="coLoadCreateOpen" title="合柜报关" confirm-text="确认建组" :confirm-loading="coLoadSaving" :confirm-disabled="!canCreateCoLoadGroup" @confirm="createCoLoadGroup">
<el-alert type="warning" :closable="false" show-icon :title="`所选 ${selectedOrders.length} 票须属于同一国内仓、同一渠道,并全量进入同一柜;目的仓或目的港可以不同。资格由服务端按完整入仓片段校验。`" />
<div class="coload-selection"><span v-for="row in selectedOrders" :key="String(row.orderId)">{{ row.inboundCode || '待生成入仓码' }}</span></div>
@@ -2675,6 +2735,9 @@ onBeforeUnmount(() => {
.subsequent-fulfillment { min-height: 72px; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--cc-border); }
.subsequent-fulfillment h3 { margin: 0 0 10px; font-size: 15px; }
.fulfillment-facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0 0 10px; border: 1px solid var(--cc-border); border-radius: 6px; overflow: hidden; }
.batch-approval-summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 12px 0 8px; color: var(--cc-muted); font-size: 13px; white-space: nowrap; }
.batch-approval-summary strong { color: var(--cc-text); }
.batch-approval-table :deep(.cell) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fulfillment-facts > div { min-width: 0; padding: 9px 10px; border-right: 1px solid var(--cc-border); background: #fff; }
.fulfillment-facts > div.wide { grid-column: span 2; }
.fulfillment-facts dt { color: var(--cc-muted); font-size: 12px; }