Release/test 合并上生产 #2

Merged
admin merged 736 commits from release/test into main 2026-09-17 11:50:56 +08:00
5 changed files with 334 additions and 66 deletions
Showing only changes of commit 2f7c122c44 - Show all commits
@@ -70,8 +70,25 @@ function measurement(overrides: Partial<InboundMeasurementDetail> = {}): Inbound
actualVolumeCbm: 0.6,
volumeWeightKg: 100.2,
actualGoodsName: '蓝牙音箱',
latestDeclaration: {
referenceId: 'DOC-001',
totalPackageCount: 10,
grossWeightKg: 118.5,
volumeCbm: 0.8,
goodsName: '蓝牙音箱',
items: [{
inboundDetailId: '8101',
itemNo: 1,
cargoName: '蓝牙音箱',
packageCount: 10,
lengthCm: 50,
widthCm: 40,
heightCm: 30,
}],
},
items: [{
id: '9101',
inboundDetailId: '8101',
boxCode: 'RK202608040001#10-1',
itemNo: '001',
boxSpecification: '50×40×30cm',
@@ -162,6 +179,33 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.find('button[aria-label="预览测量图片"]').exists()).toBe(true)
})
it('warns when declared dimensions or gross weight differ from measurements by at least ten percent', async () => {
vi.mocked(getInboundMeasurement).mockResolvedValueOnce(measurement({
actualGrossWeightKg: 132,
items: [{
...measurement().items[0],
lengthCm: 55,
widthCm: 36,
}],
}))
const wrapper = await mountPanel()
const alert = wrapper.find('.measurement-deviation-alert')
expect(alert.exists()).toBe(true)
expect(alert.text()).toContain('下单箱规与测量数据差异达到 10%')
expect(alert.text()).toContain('长偏差 10%')
expect(alert.text()).toContain('下单 50 cm → 实测 55 cm')
expect(alert.text()).toContain('宽偏差 10%')
expect(alert.text()).toContain('重量偏差 11.4%')
expect(alert.text()).not.toContain('高偏差')
})
it('does not warn when every declared and measured value differs by less than ten percent', async () => {
const wrapper = await mountPanel()
expect(wrapper.find('.measurement-deviation-alert').exists()).toBe(false)
})
it('opens the manual carton entry form in an editable measurement state', async () => {
const wrapper = await mountPanel()
const button = wrapper.findAll('button').find((candidate) => candidate.text().includes('手动录入箱规'))
@@ -113,6 +113,64 @@ const registrationOpen = computed(() => hasContext.value && canView.value && Boo
const requiredPermission = computed(() => canEdit.value ? 'platform:firstleg:inbound:measure:edit' : null)
const sourceLabel = computed(() => ({ THIRD_PARTY: '第三方设备', MANUAL: '平台人工', OPENAPI: '第三方设备' }[detail.value?.sourceType || ''] || (detail.value?.sourceType ? '未知来源' : '尚未测量')))
interface MeasurementDeviationWarning {
key: string
text: string
}
const measurementDeviationWarnings = computed<MeasurementDeviationWarning[]>(() => {
const resource = detail.value
if (!resource) return []
const warnings: MeasurementDeviationWarning[] = []
const declaredItems = resource.latestDeclaration?.items || []
const dimensions = [
{ key: 'lengthCm', label: '长' },
{ key: 'widthCm', label: '宽' },
{ key: 'heightCm', label: '高' },
] as const
for (const dimension of dimensions) {
const differences = resource.items.flatMap((measuredItem) => {
const declaredItem = declaredItems.find((candidate) => (
measuredItem.inboundDetailId != null
&& candidate.inboundDetailId != null
&& String(candidate.inboundDetailId) === String(measuredItem.inboundDetailId)
)) || declaredItems.find((candidate) => candidate.itemNo != null && Number(candidate.itemNo) === Number(measuredItem.itemNo))
if (!declaredItem) return []
const declaredValue = declaredItem[dimension.key]
const measuredValue = measuredItem[dimension.key]
const ratio = deviationRatio(measuredValue, declaredValue)
if (ratio == null || ratio < 0.1 - 1e-9) return []
return [{
measuredItem,
declaredValue,
measuredValue,
ratio,
}]
}).sort((left, right) => right.ratio - left.ratio)
const largest = differences[0]
if (!largest) continue
const boxLabel = largest.measuredItem.boxCode || `第 ${largest.measuredItem.itemNo} 条箱规`
const countSuffix = differences.length > 1 ? `,共 ${differences.length} 条箱规` : ''
warnings.push({
key: dimension.key,
text: `${dimension.label}偏差 ${formatPercentage(largest.ratio)}(${boxLabel}:下单 ${formatNumber(largest.declaredValue)} cm → 实测 ${formatNumber(largest.measuredValue)} cm${countSuffix})`,
})
}
const declaredGrossWeight = resource.latestDeclaration?.grossWeightKg ?? resource.originalGrossWeightKg
const weightRatio = deviationRatio(resource.actualGrossWeightKg, declaredGrossWeight)
if (weightRatio != null && weightRatio >= 0.1 - 1e-9) {
warnings.push({
key: 'grossWeightKg',
text: `重量偏差 ${formatPercentage(weightRatio)}(下单 ${formatNumber(declaredGrossWeight)} kg → 实测 ${formatNumber(resource.actualGrossWeightKg)} kg)`,
})
}
return warnings
})
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'p2-inbound-measurement-editor',
label: computed(() => `入仓码 ${detail.value?.inboundCode || '待补充'} 的测量草稿`),
@@ -419,6 +477,15 @@ function formatNumber(value: number | null | undefined, maximumFractionDigits =
return new Intl.NumberFormat('zh-CN', { maximumFractionDigits }).format(value)
}
function deviationRatio(actual: number | null | undefined, declared: number | null | undefined) {
if (actual == null || declared == null || !Number.isFinite(actual) || !Number.isFinite(declared) || declared <= 0) return null
return Math.abs(actual - declared) / Math.abs(declared)
}
function formatPercentage(ratio: number) {
return `${formatNumber(ratio * 100, 1)}%`
}
function formatDifference(actual: number | null | undefined, declared: number | null | undefined, maximumFractionDigits = 3, unit = '') {
if (actual == null || declared == null || !Number.isFinite(actual) || !Number.isFinite(declared)) return null
const difference = actual - declared
@@ -539,6 +606,17 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
</div>
</header>
<el-alert
v-if="measurementDeviationWarnings.length"
class="measurement-deviation-alert"
type="warning"
:closable="false"
show-icon
title="下单箱规与测量数据差异达到 10%"
>
<p v-for="warning in measurementDeviationWarnings" :key="warning.key">{{ warning.text }}</p>
</el-alert>
<h4 class="measurement-subheading">平台实测</h4>
<dl class="measurement-metrics" aria-label="平台实测结果汇总">
<div :class="{ 'measurement-metric--warning': !packageCountMatched }">
@@ -702,6 +780,8 @@ defineExpose({ saveDraft, submitForReview, updateActualWeights, mayMaintain, may
.measurement-summary-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.measurement-summary-context { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 8px 20px; }
.measurement-summary-header span { color: var(--cc-muted); font-size: 11px; }
.measurement-deviation-alert { margin: 0; width: auto; }
.measurement-deviation-alert p { margin: 3px 0 0; line-height: 1.55; }
.measurement-summary-context p { display: inline-flex; min-width: 0; align-items: baseline; gap: 6px; margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: 12px; font-weight: 600; }
.measurement-summary-status { display: flex; flex: 0 0 auto; align-items: center; gap: 9px; }
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 2px 0; }
+21
View File
@@ -13,6 +13,7 @@ export type InboundMeasurementProcessStatus =
export interface InboundMeasurementItem {
id: MeasurementIdentifier
inboundDetailId?: MeasurementIdentifier | null
boxCode: string | null
itemNo: string
boxSpecification: string
@@ -36,6 +37,25 @@ export interface InboundMeasurementItem {
remark: string | null
}
export interface InboundMeasurementDeclarationItem {
inboundDetailId: MeasurementIdentifier | null
itemNo: number | null
cargoName: string | null
packageCount: number | null
lengthCm: number | null
widthCm: number | null
heightCm: number | null
}
export interface InboundMeasurementDeclarationSnapshot {
referenceId: string | null
totalPackageCount: number | null
grossWeightKg: number | null
volumeCbm: number | null
goodsName: string | null
items: InboundMeasurementDeclarationItem[]
}
export interface InboundMeasurementWeightResult {
id: MeasurementIdentifier
fileId: MeasurementIdentifier
@@ -88,6 +108,7 @@ export interface InboundMeasurementDetail {
actualVolumeCbm: number | null
volumeWeightKg: number | null
actualGoodsName: string | null
latestDeclaration?: InboundMeasurementDeclarationSnapshot | null
items: InboundMeasurementItem[]
weightResults: InboundMeasurementWeightResult[]
}
@@ -1,21 +1,23 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import ElementPlus, { ElMessage, ElMessageBox } from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { updatePlatformUnifiedOrderDeclaration } from '@/modules/unified-orders/api/unifiedOrders'
import { listWarehouseLogisticsChannels } from '@/modules/ocean-bookings'
import OrderDeclarationEditorDialog from './OrderDeclarationEditorDialog.vue'
vi.mock('@/modules/unified-orders/api/unifiedOrders', () => ({ updatePlatformUnifiedOrderDeclaration: vi.fn() }))
vi.mock('@/modules/ocean-bookings', () => ({ listWarehouseLogisticsChannels: vi.fn() }))
const detail = {
summary: {
orderId: '90001', tenantId: '208', inboundCode: 'RK-001', mainStatus: 'WAIT_MEASURE',
receivingWarehouseName: '深圳仓', destinationWarehouseName: '洛杉矶中转仓',
receivingWarehouseId: '700000000000000001', receivingWarehouseName: '深圳仓', destinationWarehouseName: '洛杉矶中转仓',
channelCode: 'OLD', channelName: '原渠道', channelChanged: false,
expectedArrivalAt: '2026-09-06T10:00:00', customerRemark: '原备注',
},
order: {
order: { version: 7, channelCode: 'OLD', channelName: '原渠道', customerRemark: '原备注' },
cargoes: [{ itemNo: 1, cargoName: '台灯', packageCount: 2, totalQuantity: 20, lengthCm: 40, widthCm: 30, heightCm: 20, grossWeightKg: 18, volumeCbm: 0.048 }],
order: { version: 7, receivingWarehouseId: '700000000000000001', channelCode: 'OLD', channelName: '原渠道', customerRemark: '原备注' },
cargoes: [{ itemNo: 1, cargoName: '台灯', packageCount: 2, totalQuantity: 20, lengthCm: 40, widthCm: 30, heightCm: 20, grossWeightKg: 18, volumeCbm: 0.048, currency: 'USD', material: '铝', usage: '照明' }],
parties: [], internalNotes: [], statusHistory: [],
},
timeline: [],
@@ -28,8 +30,8 @@ function createWrapper() {
plugins: [ElementPlus],
stubs: {
AppDialog: {
props: ['modelValue', 'confirmLoading'], emits: ['confirm', 'update:modelValue'],
template: '<section v-if="modelValue"><slot /><button data-testid="save" @click="$emit(\'confirm\')">保存</button></section>',
props: ['modelValue', 'confirmLoading', 'confirmDisabled'], emits: ['confirm', 'update:modelValue'],
template: '<section v-if="modelValue"><slot /><button data-testid="save" :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button></section>',
},
LoadErrorState: { props: ['title', 'message'], template: '<div class="load-error">{{ title }} {{ message }}<slot name="details" /></div>' },
ApiErrorMeta: true,
@@ -41,35 +43,72 @@ function createWrapper() {
describe('OrderDeclarationEditorDialog', () => {
beforeEach(() => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockReset()
vi.mocked(listWarehouseLogisticsChannels).mockReset()
vi.mocked(listWarehouseLogisticsChannels).mockResolvedValue([
{ channelCode: 'OLD', channelName: '原渠道' },
{ channelCode: 'NEW', channelName: '新渠道' },
])
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as never)
vi.spyOn(ElMessage, 'success').mockImplementation(() => ({ close: vi.fn() }) as never)
})
it('requires a second confirmation when the channel changes and keeps inbound/warehouse facts read-only', async () => {
it('loads formal warehouse channels and submits the code/name pair from one selected option', async () => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockResolvedValue({ version: 8 } as never)
const wrapper = createWrapper()
await flushPromises()
expect(listWarehouseLogisticsChannels).toHaveBeenCalledWith('700000000000000001')
expect(wrapper.text()).toContain('国内收货仓和中转仓不可修改,如需变更请重新下单')
expect(wrapper.text()).toContain('RK-001')
const inputs = wrapper.findAll('input')
await inputs[0]!.setValue('NEW')
const channelSelect = wrapper.findAllComponents({ name: 'ElSelect' })[0]!
channelSelect.vm.$emit('update:modelValue', 'NEW')
channelSelect.vm.$emit('change', 'NEW')
await wrapper.vm.$nextTick()
await wrapper.get('[data-testid="save"]').trigger('click')
await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('现有入仓码和箱唛不会改变'), '确认变更渠道', expect.any(Object))
expect(updatePlatformUnifiedOrderDeclaration).toHaveBeenCalledWith('208', '90001', expect.objectContaining({ expectedVersion: 7, channelCode: 'NEW' }))
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('原箱码继续有效,无需更换已打印箱唛'), '确认变更渠道', expect.any(Object))
expect(ElMessageBox.confirm).toHaveBeenCalledWith(expect.stringContaining('纸面上的旧渠道文字不会自动变化'), '确认变更渠道', expect.any(Object))
expect(updatePlatformUnifiedOrderDeclaration).toHaveBeenCalledWith('208', '90001', expect.objectContaining({ expectedVersion: 7, channelCode: 'NEW', channelName: '新渠道' }))
expect(ElMessage.success).toHaveBeenCalledWith(expect.stringContaining('订单已标记为“曾变更渠道”'))
expect(wrapper.emitted('updated')).toHaveLength(1)
expect(wrapper.emitted('update:modelValue')).toContainEqual([false])
})
it('preserves the editor and entered values when the versioned update fails', async () => {
it('does not expose free-text channel inputs and presents cargoes as a compact table with row editing', async () => {
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.find('input[aria-label="渠道编码"]').attributes('readonly')).toBeDefined()
expect(wrapper.findAllComponents({ name: 'ElSelect' })).toHaveLength(1)
expect(wrapper.find('.cargo-table').exists()).toBe(true)
expect(wrapper.text()).toContain('箱数 / 数量')
expect(wrapper.text()).toContain('材质 / 用途')
expect(wrapper.text()).toContain('2 箱 / 20')
const editButton = wrapper.findAll('button').find((button) => button.text() === '编辑')!
await editButton.trigger('click')
expect(wrapper.find('input[aria-label="币种"]').attributes('readonly')).toBeDefined()
})
it('preserves the editor when the versioned update fails', async () => {
vi.mocked(updatePlatformUnifiedOrderDeclaration).mockRejectedValue(new Error('订单版本已变化,请刷新详情后重试'))
const wrapper = createWrapper()
const inputs = wrapper.findAll('input')
await inputs[1]!.setValue('保留填写的渠道名')
await flushPromises()
await wrapper.get('[data-testid="save"]').trigger('click')
await flushPromises()
expect(wrapper.find('.load-error').exists()).toBe(true)
expect((inputs[1]!.element as HTMLInputElement).value).toBe('保留填写的渠道名')
expect(wrapper.text()).toContain('原渠道')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
})
it('fails closed and keeps API error evidence when formal channel candidates cannot load', async () => {
vi.mocked(listWarehouseLogisticsChannels).mockRejectedValue(Object.assign(new Error('无权限'), { requestId: 'req-1', traceId: 'trace-1' }))
const wrapper = createWrapper()
await flushPromises()
expect(wrapper.text()).toContain('渠道候选加载失败')
expect(wrapper.get('[data-testid="save"]').attributes('disabled')).toBeDefined()
expect(updatePlatformUnifiedOrderDeclaration).not.toHaveBeenCalled()
})
})
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listWarehouseLogisticsChannels, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
import {
updatePlatformUnifiedOrderDeclaration,
type UnifiedOrderDeclarationCargo,
@@ -23,6 +24,13 @@ const emit = defineEmits<{
type EditableCargo = UnifiedOrderDeclarationCargo & { key: string }
const saving = ref(false)
const submitError = ref<ApiErrorDetails | null>(null)
const channels = ref<OceanLogisticsChannel[]>([])
const channelsLoading = ref(false)
const channelsError = ref<ApiErrorDetails | null>(null)
let channelRequestSerial = 0
const cargoEditorOpen = ref(false)
const cargoEditorIndex = ref(-1)
const cargoDraft = ref<EditableCargo | null>(null)
const originalChannel = reactive({ code: '', name: '' })
const form = reactive({
expectedVersion: 0,
@@ -50,6 +58,10 @@ function optionalNumber(row: Record<string, unknown>, key: string) {
return Number.isFinite(parsed) ? parsed : null
}
function receivingWarehouseId() {
return String(props.detail.order.order?.receivingWarehouseId || props.detail.summary.receivingWarehouseId || '').trim()
}
function resetForm() {
const snapshot = props.detail.order.order
form.expectedVersion = Number(snapshot?.version ?? 0)
@@ -81,21 +93,78 @@ function resetForm() {
submitError.value = null
}
watch(() => props.modelValue, (open) => { if (open) resetForm() }, { immediate: true })
const selectedChannel = computed(() => channels.value.find((item) => item.channelCode === form.channelCode) || null)
async function loadChannels() {
const warehouseId = receivingWarehouseId()
const serial = ++channelRequestSerial
channels.value = []
channelsError.value = null
if (!warehouseId) {
channelsError.value = getApiErrorDetails(new Error('订单缺少国内收货仓,无法加载正式渠道候选'), '渠道候选加载失败')
return
}
channelsLoading.value = true
try {
const result = await listWarehouseLogisticsChannels(warehouseId)
if (serial !== channelRequestSerial) return
channels.value = result
const current = result.find((item) => item.channelCode === form.channelCode)
if (current) form.channelName = current.channelName
} catch (error) {
if (serial !== channelRequestSerial) return
channelsError.value = getApiErrorDetails(error, '正式渠道候选加载失败,请重试')
} finally {
if (serial === channelRequestSerial) channelsLoading.value = false
}
}
watch(() => props.modelValue, (open) => {
if (!open) return
resetForm()
void loadChannels()
}, { immediate: true })
function selectChannel(channelCode: string) {
const channel = channels.value.find((item) => item.channelCode === channelCode)
form.channelCode = channel?.channelCode || ''
form.channelName = channel?.channelName || ''
}
function editCargo(index: number) {
cargoEditorIndex.value = index
cargoDraft.value = { ...form.cargoes[index]! }
cargoEditorOpen.value = true
}
function cargoValidation(cargo: EditableCargo) {
if (!cargo.cargoName.trim()) return `第 ${cargo.itemNo} 项中文品名不能为空`
if (cargo.packageCount < 1 || cargo.totalQuantity < 1) return `第 ${cargo.itemNo} 项箱数和总数量必须大于 0`
if ([cargo.lengthCm, cargo.widthCm, cargo.heightCm, cargo.grossWeightKg, cargo.volumeCbm].some((value) => !(value > 0))) {
return `第 ${cargo.itemNo} 项尺寸、毛重和体积必须大于 0`
}
return ''
}
function applyCargo() {
if (!cargoDraft.value || cargoEditorIndex.value < 0) return
const message = cargoValidation(cargoDraft.value)
if (message) { ElMessage.warning(message); return }
Object.assign(form.cargoes[cargoEditorIndex.value]!, cargoDraft.value)
cargoEditorOpen.value = false
}
function nullable(value: string | null | undefined) {
return value?.trim() || null
}
function validate() {
if (!form.channelCode.trim() || !form.channelName.trim()) return '渠道编码和渠道名称均不能为空'
if (channelsError.value) return '正式渠道候选尚未加载成功,请重试'
if (!selectedChannel.value || selectedChannel.value.channelName !== form.channelName) return '请选择当前国内收货仓的正式渠道'
if (!form.cargoes.length) return '订单缺少货品明细,不能保存'
for (const cargo of form.cargoes) {
if (!cargo.cargoName.trim()) return `第 ${cargo.itemNo} 项中文品名不能为空`
if (cargo.packageCount < 1 || cargo.totalQuantity < 1) return `第 ${cargo.itemNo} 项箱数和总数量必须大于 0`
if ([cargo.lengthCm, cargo.widthCm, cargo.heightCm, cargo.grossWeightKg, cargo.volumeCbm].some((value) => !(value > 0))) {
return `第 ${cargo.itemNo} 项尺寸、毛重和体积必须大于 0`
}
const message = cargoValidation(cargo)
if (message) return message
}
return ''
}
@@ -107,7 +176,7 @@ async function save() {
if (channelChanged) {
try {
await ElMessageBox.confirm(
'渠道变更后,系统会重新校验分柜、同柜及计费约束;现有入仓码和箱唛不会改变。确认继续吗?',
'渠道变更后,系统会重新校验分柜、同柜及计费约束。原箱码继续有效,无需更换已打印箱唛;纸面上的旧渠道文字不会自动变化,请以系统当前渠道为准。订单将标记为“曾变更渠道”。确认继续吗?',
'确认变更渠道',
{ type: 'warning', confirmButtonText: '确认变更', cancelButtonText: '返回检查' },
)
@@ -133,7 +202,9 @@ async function save() {
cargoRemark: nullable(cargo.cargoRemark),
})),
})
ElMessage.success('客户申报资料已更新')
ElMessage.success(channelChanged
? '渠道已更新,订单已标记为“曾变更渠道”;原箱码继续有效,无需更换已打印箱唛'
: '客户申报资料已更新')
emit('update:modelValue', false)
emit('updated')
} catch (error) {
@@ -148,18 +219,22 @@ async function save() {
<AppDialog
:model-value="modelValue"
title="编辑客户申报资料"
width="min(1500px, calc(100vw - 36px))"
width="min(1180px, calc(100vw - 36px))"
confirm-text="保存申报资料"
:confirm-loading="saving"
:confirm-disabled="channelsLoading || Boolean(channelsError) || !selectedChannel"
:close-on-click-modal="false"
@update:model-value="emit('update:modelValue', $event)"
@confirm="save"
>
<div class="declaration-editor">
<el-alert type="warning" :closable="false" title="仅修改客户原始申报资料;仓库/PDA 实测数据不会被覆盖。国内收货仓和中转仓不可修改,如需变更请重新下单。" />
<el-alert type="warning" :closable="false" title="仅修改客户原始申报资料;仓库/PDA 实测数据不会被覆盖。变更渠道不作废原箱码,订单会标记为“曾变更渠道”。国内收货仓和中转仓不可修改,如需变更请重新下单。" />
<LoadErrorState v-if="submitError" compact title="保存失败,当前填写内容已保留" :message="submitError.message" :show-retry="false">
<template #details><ApiErrorMeta :details="submitError" /></template>
</LoadErrorState>
<LoadErrorState v-if="channelsError" compact title="渠道候选加载失败" :message="channelsError.message" @retry="loadChannels">
<template #details><ApiErrorMeta :details="channelsError" /></template>
</LoadErrorState>
<el-descriptions :column="4" border class="readonly-facts">
<el-descriptions-item label="入仓码">{{ detail.summary.inboundCode || '—' }}</el-descriptions-item>
@@ -170,43 +245,52 @@ async function save() {
<el-form label-position="top" class="order-fields">
<el-row :gutter="14">
<el-col :span="6"><el-form-item label="渠道编码" required><el-input v-model="form.channelCode" maxlength="64" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="渠道名称" required><el-input v-model="form.channelName" maxlength="128" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="渠道名称" required><el-select v-model="form.channelCode" aria-label="渠道名称" filterable clearable :loading="channelsLoading" placeholder="请选择正式渠道" @change="selectChannel"><el-option v-for="channel in channels" :key="channel.channelCode" :label="`${channel.channelName} · ${channel.channelCode}`" :value="channel.channelCode" /></el-select></el-form-item></el-col>
<el-col :span="4"><el-form-item label="渠道编码"><el-input :model-value="form.channelCode" readonly aria-label="渠道编码" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="预计到货时间"><el-date-picker v-model="form.expectedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" format="YYYY-MM-DD HH:mm" clearable /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="客户备注"><el-input v-model="form.customerRemark" maxlength="1000" show-word-limit /></el-form-item></el-col>
</el-row>
</el-form>
<h3>货品申报明细 <small>不允许新增、删除或调整明细序号</small></h3>
<div class="cargo-list">
<section v-for="cargo in form.cargoes" :key="cargo.key" class="cargo-card">
<header>第 {{ cargo.itemNo }} 项</header>
<div class="cargo-grid">
<label><span>中文品名 *</span><el-input v-model="cargo.cargoName" maxlength="128" /></label>
<label><span>英文品名</span><el-input v-model="cargo.cargoNameEn" maxlength="128" /></label>
<label><span>SKU NO.</span><el-input v-model="cargo.skuNo" maxlength="64" /></label>
<label><span>型号</span><el-input v-model="cargo.model" maxlength="64" /></label>
<label><span>箱数 *</span><el-input-number v-model="cargo.packageCount" :min="1" :precision="0" controls-position="right" /></label>
<label><span>总数量 *</span><el-input-number v-model="cargo.totalQuantity" :min="1" :precision="0" controls-position="right" /></label>
<label><span>长(cm) *</span><el-input-number v-model="cargo.lengthCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>宽(cm) *</span><el-input-number v-model="cargo.widthCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>高(cm) *</span><el-input-number v-model="cargo.heightCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>毛重(kg) *</span><el-input-number v-model="cargo.grossWeightKg" :min="0.001" :precision="3" controls-position="right" /></label>
<label><span>净重(kg)</span><el-input-number v-model="cargo.netWeightKg" :min="0.001" :precision="3" controls-position="right" /></label>
<label><span>体积(m³) *</span><el-input-number v-model="cargo.volumeCbm" :min="0.0001" :precision="4" controls-position="right" /></label>
<label><span>报关 HS CODE</span><el-input v-model="cargo.hsCode" maxlength="20" /></label>
<label><span>清关 HS CODE</span><el-input v-model="cargo.customsHsCode" maxlength="20" /></label>
<label><span>每箱数量</span><el-input-number v-model="cargo.unitsPerPackage" :min="1" :precision="0" controls-position="right" /></label>
<label><span>单价</span><el-input-number v-model="cargo.unitDeclaredValue" :min="0" :precision="2" controls-position="right" /></label>
<label><span>申报总价</span><el-input-number v-model="cargo.declaredValue" :min="0" :precision="2" controls-position="right" /></label>
<label><span>币种</span><el-input v-model="cargo.currency" maxlength="3" placeholder="USD" /></label>
<label><span>材质</span><el-input v-model="cargo.material" maxlength="128" /></label>
<label><span>用途</span><el-input v-model="cargo.usage" maxlength="128" /></label>
<label><span>品牌</span><el-input v-model="cargo.brand" maxlength="64" /></label>
<label class="wide"><span>货品备注</span><el-input v-model="cargo.cargoRemark" maxlength="500" /></label>
</div>
</section>
</div>
<el-table :data="form.cargoes" border stripe size="small" class="cargo-table">
<el-table-column label="操作" width="82" fixed="left"><template #default="{ $index }"><el-button link type="primary" @click="editCargo($index)">编辑</el-button></template></el-table-column>
<el-table-column prop="itemNo" label="项" width="58" align="center" />
<el-table-column label="货品" min-width="190"><template #default="{ row }"><strong class="cell-primary" :title="row.cargoName">{{ row.cargoName }}</strong><small :title="row.cargoNameEn || row.skuNo">{{ row.cargoNameEn || row.skuNo || '—' }}</small></template></el-table-column>
<el-table-column label="箱数 / 数量" width="118" align="right"><template #default="{ row }">{{ row.packageCount }} 箱 / {{ row.totalQuantity }}</template></el-table-column>
<el-table-column label="申报尺寸(cm)" width="148"><template #default="{ row }">{{ row.lengthCm }} × {{ row.widthCm }} × {{ row.heightCm }}</template></el-table-column>
<el-table-column label="毛重 / 净重(kg)" width="145"><template #default="{ row }">{{ row.grossWeightKg }} / {{ row.netWeightKg ?? '—' }}</template></el-table-column>
<el-table-column prop="volumeCbm" label="体积(m³)" width="100" align="right" />
<el-table-column label="申报金额" width="125" align="right"><template #default="{ row }">{{ row.currency || '—' }} {{ row.declaredValue ?? '—' }}</template></el-table-column>
<el-table-column label="材质 / 用途" min-width="170"><template #default="{ row }"><span :title="[row.material, row.usage].filter(Boolean).join(' / ')">{{ [row.material, row.usage].filter(Boolean).join(' / ') || '—' }}</span></template></el-table-column>
</el-table>
</div>
</AppDialog>
<AppDialog v-model="cargoEditorOpen" title="编辑货品申报" width="min(980px, calc(100vw - 36px))" confirm-text="应用本项" :close-on-click-modal="false" @confirm="applyCargo">
<div v-if="cargoDraft" class="cargo-editor-grid">
<label><span>中文品名 *</span><el-input v-model="cargoDraft.cargoName" maxlength="128" /></label>
<label><span>英文品名</span><el-input v-model="cargoDraft.cargoNameEn" maxlength="128" /></label>
<label><span>SKU NO.</span><el-input v-model="cargoDraft.skuNo" maxlength="64" /></label>
<label><span>型号</span><el-input v-model="cargoDraft.model" maxlength="64" /></label>
<label><span>箱数 *</span><el-input-number v-model="cargoDraft.packageCount" :min="1" :precision="0" controls-position="right" /></label>
<label><span>总数量 *</span><el-input-number v-model="cargoDraft.totalQuantity" :min="1" :precision="0" controls-position="right" /></label>
<label><span>每箱数量</span><el-input-number v-model="cargoDraft.unitsPerPackage" :min="1" :precision="0" controls-position="right" /></label>
<label><span>币种</span><el-input :model-value="cargoDraft.currency || '—'" readonly aria-label="币种" /></label>
<label><span>长(cm) *</span><el-input-number v-model="cargoDraft.lengthCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>宽(cm) *</span><el-input-number v-model="cargoDraft.widthCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>高(cm) *</span><el-input-number v-model="cargoDraft.heightCm" :min="0.01" :precision="2" controls-position="right" /></label>
<label><span>体积(m³) *</span><el-input-number v-model="cargoDraft.volumeCbm" :min="0.0001" :precision="4" controls-position="right" /></label>
<label><span>毛重(kg) *</span><el-input-number v-model="cargoDraft.grossWeightKg" :min="0.001" :precision="3" controls-position="right" /></label>
<label><span>净重(kg)</span><el-input-number v-model="cargoDraft.netWeightKg" :min="0.001" :precision="3" controls-position="right" /></label>
<label><span>单价</span><el-input-number v-model="cargoDraft.unitDeclaredValue" :min="0" :precision="2" controls-position="right" /></label>
<label><span>申报总价</span><el-input-number v-model="cargoDraft.declaredValue" :min="0" :precision="2" controls-position="right" /></label>
<label><span>报关 HS CODE</span><el-input v-model="cargoDraft.hsCode" maxlength="20" /></label>
<label><span>清关 HS CODE</span><el-input v-model="cargoDraft.customsHsCode" maxlength="20" /></label>
<label><span>材质</span><el-input v-model="cargoDraft.material" maxlength="128" /></label>
<label><span>用途</span><el-input v-model="cargoDraft.usage" maxlength="128" /></label>
<label><span>品牌</span><el-input v-model="cargoDraft.brand" maxlength="64" /></label>
<label class="wide"><span>货品备注</span><el-input v-model="cargoDraft.cargoRemark" maxlength="500" /></label>
</div>
</AppDialog>
</template>
@@ -216,12 +300,12 @@ async function save() {
.readonly-facts { margin-top: 2px; }
.order-fields :deep(.el-date-editor), .order-fields :deep(.el-input) { width: 100%; }
h3 { margin: 0; color: #344054; font-size: 15px; } h3 small { margin-left: 8px; color: #98a2b3; font-weight: 400; }
.cargo-list { display: grid; gap: 12px; }
.cargo-card { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
.cargo-card > header { padding: 9px 12px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; color: #475467; font-weight: 600; }
.cargo-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px 12px; padding: 12px; }
.cargo-grid label { display: grid; min-width: 0; gap: 5px; color: #667085; font-size: 12px; }
.cargo-grid label.wide { grid-column: span 2; }
.cargo-grid :deep(.el-input-number), .cargo-grid :deep(.el-input) { width: 100%; }
@media (max-width: 1200px) { .cargo-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.cargo-table { width: 100%; }
.cargo-table small, .cargo-table .cell-primary { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cargo-table small { margin-top: 3px; color: #98a2b3; }
.cargo-editor-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 14px; }
.cargo-editor-grid label { display: grid; min-width: 0; gap: 5px; color: #667085; font-size: 12px; }
.cargo-editor-grid label.wide { grid-column: span 3; }
.cargo-editor-grid :deep(.el-input-number), .cargo-editor-grid :deep(.el-input), .order-fields :deep(.el-select) { width: 100%; }
@media (max-width: 900px) { .cargo-editor-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .cargo-editor-grid label.wide { grid-column: span 2; } }
</style>