Merge test into dev

This commit is contained in:
2026-09-14 12:06:46 +08:00
43 changed files with 788 additions and 102 deletions
+1
View File
@@ -113,6 +113,7 @@ export interface FirstLegOrderCargoPayload {
shipmentId?: string
referenceId?: string
productImageFileId?: number | string
packingGroupNo?: string
}
export interface ProductImageUploadResult {
@@ -25,7 +25,7 @@ test('supports a standalone dialog and keeps the unpushed billing-weight state v
})
test('shows the published merchant inbound, measurement details and customer confirmation entry', () => {
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '基础费用', '附加费用', '费用合计', '平台备注']) {
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '对客单价', '基础费用', '附加费用', '费用合计', '平台备注']) {
assert.match(source, new RegExp(label))
}
assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
@@ -42,6 +42,7 @@ test('shows the published merchant inbound, measurement details and customer con
assert.match(source, /latestFee\.rateCode/)
assert.match(source, /latestFee\.chargeRuleVersion/)
assert.match(source, /latestFee\.carrierRateVersion/)
assert.doesNotMatch(source, /箱规规则|calculationRuleVersion/)
})
test('stacks core information for 720 and 360 pixel layouts', () => {
@@ -227,6 +227,10 @@ async function confirmFee() {
<div><dt>外箱规总材积重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.volumeWeightKg, 'kg') }}</dd></div>
<div><dt>计费体积</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableVolumeCbm, 'm³') }}</dd></div>
<div><dt>计费重量</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableWeightKg, 'kg') }}</dd></div>
<div><dt>对客单价</dt><dd>{{ formatInboundMoney(props.data.latestFee.unitPrice, props.data.latestFee.currency) }} / {{ props.data.latestFee.chargeUnit }}</dd></div>
<div><dt>基础费用</dt><dd>{{ formatInboundMoney(props.data.latestFee.subtotalAmount, props.data.latestFee.currency) }}</dd></div>
<div><dt>附加费用</dt><dd>{{ formatInboundMoney(props.data.latestFee.additionalAmount, props.data.latestFee.currency) }}</dd></div>
<div><dt>费用合计</dt><dd>{{ formatInboundMoney(props.data.latestFee.totalAmount, props.data.latestFee.currency) }}</dd></div>
</dl>
<el-table v-if="props.data?.measurementItems.length" class="measurement-detail-table" :data="props.data.measurementItems" border size="small">
<el-table-column type="index" label="#" width="54" />
@@ -237,7 +241,6 @@ async function confirmFee() {
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
<el-table-column prop="calculationRuleVersion" label="箱规规则" min-width="180" />
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
</el-table>
<el-alert
@@ -248,11 +251,11 @@ async function confirmFee() {
:closable="false"
show-icon
/>
<el-alert v-else class="fee-confirmed-alert" title="请核对全部计费数据。点击确认后还会进行一次确认,完成后费用将锁定。" type="warning" :closable="false" show-icon />
<el-alert v-else class="fee-confirmed-alert" title="请核对计费量、对客单价和费用合计。确认后当前费用版本将锁定。" type="warning" :closable="false" show-icon />
</template>
<template #footer>
<el-button @click="feeDialogOpen = false">关闭</el-button>
<el-button v-if="props.data?.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" :loading="confirming" @click="confirmFee">确认费用</el-button>
<el-button v-if="props.data?.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" :loading="confirming" @click="confirmFee">确认计费量和费用</el-button>
</template>
</el-dialog>
</template>
+1
View File
@@ -1,3 +1,4 @@
export { useFirstLegOrdersStore } from './store/firstLegOrders'
export { getMerchantWarehouses, type MerchantWarehouse } from './api/firstLeg'
export { getFirstLegStatusLabel, getFirstLegStatusTone } from './utils/firstLegOrder'
export { default as InboundCodeAssociationDialog } from './components/InboundCodeAssociationDialog.vue'
@@ -90,6 +90,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust
customsHsCode: item.customsHsCode || undefined,
packageType: item.packageType,
packageCount: item.packageCount,
packingGroupNo: draft.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? item.packingGroupNo.trim() || undefined : undefined,
grossWeightKg: item.grossWeightKg,
volumeCbm: item.volumeCbm,
lengthCm: item.lengthCm || undefined,
@@ -296,7 +297,7 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
productImageName: '', productImagePreviewUrl: '', productImageUploading: false, productImageError: '',
customsHsCode: item.customsHsCode ?? '', lengthCm: item.lengthCm ?? 0, widthCm: item.widthCm ?? 0,
heightCm: item.heightCm ?? 0, unitsPerPackage: item.unitsPerPackage ?? 1,
pieces: item.totalQuantity ?? item.packageCount, packageCount: item.packageCount, grossWeightKg: item.grossWeightKg,
pieces: item.totalQuantity ?? item.packageCount, packageCount: item.packageCount, packingGroupNo: item.packingGroupNo ?? '', grossWeightKg: item.grossWeightKg,
volumeCbm: item.volumeCbm, remark: item.cargoRemark ?? '',
packageType: item.packageType, hsCode: item.hsCode ?? '', declaredValue: item.declaredValue ?? 0,
currency: item.currency ?? 'USD', dangerousGoods: item.dangerousGoods ?? false,
@@ -26,6 +26,7 @@ export interface FirstLegCargoItem {
unitsPerPackage: number
pieces: number
packageCount: number
packingGroupNo: string
grossWeightKg: number
volumeCbm: number
netWeightKg: number
@@ -54,7 +54,7 @@ function validDraft() {
destinationType: 'FBA',
amazonWarehouseCode: 'ONT8',
shipmentId: 'FBA-001',
items: [{ ...createEmptyCargoItem('cargo-1'), sku: 'SKU-001', upc: '012345678905', cargoNameCn: '家居用品', pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, shipmentId: 'FBA-001', referenceId: 'REF-001' }],
items: [{ ...createEmptyCargoItem('cargo-1'), sku: 'SKU-001', upc: '012345678905', cargoNameCn: '家居用品', unitsPerPackage: 100, pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, shipmentId: 'FBA-001', referenceId: 'REF-001' }],
}
}
@@ -94,6 +94,17 @@ test('validates the confirmed minimum submission fields', () => {
assert.ok(errors.includes('第 1 条货物毛重必须大于 0'))
})
test('rejects a total quantity that does not equal units per package multiplied by package count', () => {
const draft = validDraft()
draft.items[0].unitsPerPackage = 24
draft.items[0].packageCount = 5
draft.items[0].pieces = 119
assert.ok(validateFirstLegOrderDraft(draft).includes('第 1 条货物总数量应等于每箱数量 × 箱数(120)'))
draft.items[0].pieces = 120
assert.ok(!validateFirstLegOrderDraft(draft).some((message) => message.includes('总数量应等于')))
})
test('only requires sender for pickup and receiver for a private destination', () => {
const publicDraft = validDraft()
publicDraft.shipperPartySnapshot = null
@@ -169,3 +180,20 @@ test('summarizes cargo values from line items', () => {
volumeCbm: 4.75,
})
})
test('counts one physical carton series for SKU lines in the same mixed-carton group', () => {
const first = { ...validDraft().items[0], packingGroupNo: 'A', packageCount: 10, pieces: 20, unitsPerPackage: 2 }
const second = { ...first, id: 'cargo-2', sku: 'SKU-002', pieces: 30, unitsPerPackage: 3 }
assert.deepEqual(summarizeFirstLegCargo([first, second]), {
pieces: 50,
packageCount: 10,
grossWeightKg: first.grossWeightKg,
volumeCbm: first.volumeCbm,
})
const draft = { ...validDraft(), fulfillmentType: 'ONE_CLICK_DROPSHIP', items: [first, second] }
assert.ok(!validateFirstLegOrderDraft(draft).some((message) => message.includes('同箱 SKU')))
second.packageCount = 9
assert.ok(validateFirstLegOrderDraft(draft).includes('同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致'))
})
+35 -5
View File
@@ -65,6 +65,7 @@ export function createEmptyCargoItem(id = `cargo-${Date.now()}`): FirstLegCargoI
unitsPerPackage: 1,
pieces: 1,
packageCount: 1,
packingGroupNo: '',
grossWeightKg: 0,
volumeCbm: 0,
netWeightKg: 0,
@@ -130,22 +131,51 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
if (!Number.isInteger(item.pieces) || item.pieces <= 0) errors.push(`${line}件数必须为正整数`)
if (!Number.isInteger(item.packageCount) || item.packageCount <= 0) errors.push(`${line}箱数必须为正整数`)
if (!Number.isInteger(item.unitsPerPackage) || item.unitsPerPackage <= 0) errors.push(`${line}每箱数量必须为正整数`)
if (Number.isInteger(item.unitsPerPackage) && item.unitsPerPackage > 0
&& Number.isInteger(item.packageCount) && item.packageCount > 0
&& Number.isInteger(item.pieces) && item.pieces > 0
&& item.pieces !== item.unitsPerPackage * item.packageCount) {
errors.push(`${line}总数量应等于每箱数量 × 箱数(${item.unitsPerPackage * item.packageCount})`)
}
if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`)
if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`)
if (item.woodenProduct && draft.customsInspection !== true) errors.push(`${line}为木制品,“是否商检”必须选择“是”`)
})
if (draft.fulfillmentType === 'ONE_CLICK_DROPSHIP') {
const groups = new Map<string, FirstLegCargoItem>()
draft.items.forEach((item) => {
const group = item.packingGroupNo.trim()
if (!group) return
const first = groups.get(group)
if (!first) groups.set(group, item)
else if (first.packageCount !== item.packageCount || first.packageType !== item.packageType
|| first.lengthCm !== item.lengthCm || first.widthCm !== item.widthCm || first.heightCm !== item.heightCm
|| first.grossWeightKg !== item.grossWeightKg || first.netWeightKg !== item.netWeightKg
|| first.volumeCbm !== item.volumeCbm) {
errors.push(`同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致`)
}
})
}
return errors
}
export function summarizeFirstLegCargo(items: FirstLegCargoItem[]) {
const countedGroups = new Set<string>()
return items.reduce(
(summary, item) => ({
(summary, item) => {
const group = item.packingGroupNo.trim()
const alreadyCounted = Boolean(group && countedGroups.has(group))
const packageCount = alreadyCounted ? 0 : item.packageCount
if (group) countedGroups.add(group)
return ({
pieces: summary.pieces + item.pieces,
packageCount: summary.packageCount + item.packageCount,
grossWeightKg: summary.grossWeightKg + item.grossWeightKg,
volumeCbm: summary.volumeCbm + item.volumeCbm,
}),
packageCount: summary.packageCount + packageCount,
grossWeightKg: summary.grossWeightKg + (alreadyCounted ? 0 : item.grossWeightKg),
volumeCbm: summary.volumeCbm + (alreadyCounted ? 0 : item.volumeCbm),
})
},
{ pieces: 0, packageCount: 0, grossWeightKg: 0, volumeCbm: 0 },
)
}
@@ -281,6 +281,11 @@ test('requires an enabled platform receiving warehouse in the order form', () =>
})
test('selects platform destinations and uses customer-scoped server address books', () => {
assert.match(source, /v-if="form\.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="FBA"/)
assert.match(source, /<el-radio-button value="OVERSEAS">海外仓<\/el-radio-button>/)
assert.match(source, /v-if="form\.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="PRIVATE"/)
assert.match(source, /@change="changeFulfillmentType"/)
assert.match(source, /form\.destinationType = 'OVERSEAS'\s*\n\s*await changeDestinationType\(\)/)
assert.match(source, /warehouse\.warehouseType === form\.destinationType/)
assert.match(source, /await store\.loadWarehouseType\(type\)/)
assert.match(source, /await loadDestinationWarehouses\(form\.destinationType\)/)
@@ -357,6 +362,13 @@ test('distills order creation into the confirmed prototype sections with server
assert.doesNotMatch(source, /未填写行标识/)
assert.match(source, /batchImportError[\s\S]*requestId[\s\S]*traceId/)
assert.match(source, /添加行[\s\S]*删除所选行/)
assert.match(source, /添加 SKU/)
assert.match(source, /addSkuToPackingGroup/)
assert.match(source, /shared-carton-value">同箱/)
assert.match(source, /syncSkuQuantity/)
assert.match(source, /每个箱组默认装一个 SKU/)
assert.doesNotMatch(source, /v-model="item\.packingGroupNo"/)
assert.doesNotMatch(cargoTableSource, /<th[^>]*>混箱组合/)
assert.doesNotMatch(source, /货物明细导入模板\.xls/)
for (const heading of ['SKU NO.', '中文品名', '箱规长 cm', '总毛重', '报关 HS CODE', '货件编号', '跟踪编号', 'UPC NO.']) {
assert.ok(cargoTableSource.includes(heading))
@@ -430,6 +442,8 @@ test('shows field-level validation and concise guidance for required order input
assert.match(source, /prop="consigneePartySnapshot\.address" required/)
assert.match(source, /:prop="`items\.\$\{index\}\.cargoNameCn`" required/)
assert.match(source, /:prop="`items\.\$\{index\}\.grossWeightKg`" required/)
assert.match(source, /<span class="required-field-mark" aria-hidden="true">\*<\/span>总毛重/)
assert.match(source, /<span class="required-field-mark" aria-hidden="true">\*<\/span>总体积/)
assert.match(source, /:prop="`items\.\$\{index\}\.upc`">\s*<el-input v-model="item\.upc"[^>]*placeholder="UPC NO\.(选填)"/)
assert.doesNotMatch(source, /:prop="`items\.\$\{index\}\.upc`"[^>]*(?:required|:rules)/)
assert.match(source, /UPC NO\. 选填/)
@@ -215,6 +215,7 @@ const destinationWarehousesLoading = ref(false)
const pageDataLoading = computed(() => supportingDataLoading.value || (!isCreatePage.value && store.loading))
const editorBaseline = ref('')
let cargoSequence = 0
let packingGroupSequence = 0
let inboundBillingRequestSequence = 0
let channelRequestSequence = 0
let estimateRequestSequence = 0
@@ -1332,6 +1333,77 @@ function addCargoItem() {
resetEditorValidation()
}
async function changeFulfillmentType() {
if (form.fulfillmentType !== 'ONE_CLICK_DROPSHIP' || form.destinationType === 'OVERSEAS') return
form.destinationType = 'OVERSEAS'
await changeDestinationType()
}
const sharedPackingFields = [
'packageType', 'packageCount', 'lengthCm', 'widthCm', 'heightCm',
'grossWeightKg', 'netWeightKg', 'volumeCbm',
] as const
function packingGroupFor(item: FirstLegCargoItem) {
return item.packingGroupNo.trim()
}
function mixedSkuCount(item: FirstLegCargoItem) {
const group = packingGroupFor(item)
return group ? form.items.filter((candidate) => packingGroupFor(candidate) === group).length : 1
}
function isAdditionalMixedSku(item: FirstLegCargoItem, index: number) {
const group = packingGroupFor(item)
return Boolean(group && form.items.slice(0, index).some((candidate) => packingGroupFor(candidate) === group))
}
function syncSkuQuantity(item: FirstLegCargoItem) {
if (Number.isInteger(item.unitsPerPackage) && item.unitsPerPackage > 0
&& Number.isInteger(item.packageCount) && item.packageCount > 0) {
item.pieces = item.unitsPerPackage * item.packageCount
}
}
function syncPackingGroupFields(source: FirstLegCargoItem) {
if (form.fulfillmentType !== 'ONE_CLICK_DROPSHIP') return
syncSkuQuantity(source)
const group = packingGroupFor(source)
if (!group) return
form.items.forEach((item) => {
if (item === source || packingGroupFor(item) !== group) return
sharedPackingFields.forEach((field) => { item[field] = source[field] as never })
syncSkuQuantity(item)
})
}
function normalizeSingleSkuGroups() {
const counts = new Map<string, number>()
form.items.forEach((item) => {
const group = packingGroupFor(item)
if (group) counts.set(group, (counts.get(group) ?? 0) + 1)
})
form.items.forEach((item) => {
const group = packingGroupFor(item)
if (group && counts.get(group) === 1) item.packingGroupNo = ''
})
}
function addSkuToPackingGroup(index: number) {
const source = form.items[index]
if (!source) return
const group = packingGroupFor(source) || `PG-${Date.now().toString(36)}-${++packingGroupSequence}`
source.packingGroupNo = group
const added = createEmptyCargoItem(`new-cargo-${++cargoSequence}`)
added.packingGroupNo = group
sharedPackingFields.forEach((field) => { added[field] = source[field] as never })
syncSkuQuantity(added)
let insertAt = index + 1
while (insertAt < form.items.length && packingGroupFor(form.items[insertAt]!) === group) insertAt++
form.items.splice(insertAt, 0, added)
resetEditorValidation()
}
function removeCargoItem(index: number) {
if (form.items.length === 1) {
ElMessage.warning('订单至少需要保留一条货物明细')
@@ -1339,6 +1411,7 @@ function removeCargoItem(index: number) {
}
const [removed] = form.items.splice(index, 1)
if (removed) releaseProductImagePreviews([removed])
normalizeSingleSkuGroups()
resetEditorValidation()
}
@@ -1503,6 +1576,7 @@ function removeSelectedCargoItems() {
const selectedIds = new Set(selectedCargoIds.value)
const removedItems = form.items.filter((item) => selectedIds.has(item.id))
form.items.splice(0, form.items.length, ...form.items.filter((item) => !selectedIds.has(item.id)))
normalizeSingleSkuGroups()
releaseProductImagePreviews(removedItems)
selectedCargoIds.value = []
resetEditorValidation()
@@ -1897,7 +1971,7 @@ async function searchOrders(model: QueryModel) {
<section class="order-form-section prototype-section">
<div class="order-section-heading"><div><h3>客户下单</h3><p>先选择货物如何送到国内收货仓,并填写本次运输安排。</p></div></div>
<div class="prototype-form-flow shipping-form-flow">
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options" @change="changeFulfillmentType"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
<el-form-item v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX入库单号" prop="lxCode" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.lxCode" maxlength="128" placeholder="请输入LX入库单号" /><small class="field-guidance">用于绑定领星入库单,提交后可在尾程订单、入库单和出库单中查询。</small></el-form-item>
<el-form-item label="取货方式" prop="pickupMethod" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.pickupMethod" class="segmented-options" @change="changePickupMethod"><el-radio-button value="SELF_EXPRESS">快递自送</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_TRUCK">卡车自送</el-radio-button></el-radio-group></el-form-item>
<template v-if="form.pickupMethod === 'PICKUP' && form.shipperPartySnapshot">
@@ -1937,7 +2011,7 @@ async function searchOrders(model: QueryModel) {
<section class="order-form-section prototype-section destination-section">
<div class="order-section-heading destination-section-heading"><div><h3>目的地信息</h3></div></div>
<div class="prototype-form-flow destination-form-flow">
<el-form-item label="目的仓类型" prop="destinationType" required class="prototype-form-row prototype-form-row--tabs destination-type-row"><el-radio-group v-model="form.destinationType" class="segmented-options destination-type-options" @change="changeDestinationType"><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="OVERSEAS">海外仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="目的仓类型" prop="destinationType" required class="prototype-form-row prototype-form-row--tabs destination-type-row"><el-radio-group v-model="form.destinationType" class="segmented-options destination-type-options" @change="changeDestinationType"><el-radio-button v-if="form.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="FBA">FBA 仓</el-radio-button><el-radio-button value="OVERSEAS">海外仓</el-radio-button><el-radio-button v-if="form.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="form.destinationType === 'PRIVATE'" label="私人地址簿" class="prototype-form-row prototype-form-row--wide address-book-row">
<div class="address-book-control"><el-select v-model="form.privateDestinationAddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业保存的私人仓地址" @change="selectPrivateAddress"><el-option v-for="address in privateAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" /></el-select><el-button @click="savePrivateAddress">保存当前地址</el-button><el-button v-if="form.privateDestinationAddressId" link type="danger" @click="removeOrderAddress('PRIVATE_DESTINATION')">停用</el-button></div>
@@ -1969,31 +2043,31 @@ async function searchOrders(model: QueryModel) {
<span v-else class="cargo-table-hint">可逐行维护货物明细。</span>
<div class="cargo-table-actions"><el-button @click="addCargoItem"><Plus :size="16" />添加行</el-button><el-button type="danger" plain :disabled="!selectedCargoIds.length" @click="removeSelectedCargoItems"><Trash2 :size="15" />删除所选行</el-button></div>
</div>
<div class="cargo-table-wrap">
<div class="cargo-table-wrap" :class="{ 'is-dropship': form.fulfillmentType === 'ONE_CLICK_DROPSHIP' }">
<table class="cargo-edit-table">
<thead><tr>
<th class="cargo-check-cell"><el-checkbox :model-value="allCargoSelected" :indeterminate="someCargoSelected" aria-label="全选货物" @change="toggleAllCargoItems" /></th><th class="cargo-index-cell">#</th>
<th>SKU NO.<small>SKU必填</small></th><th>中文品名</th><th class="cargo-image-column">图片</th><th>英文品名</th><th>报关 HS CODE</th><th>清关 HS CODE</th><th>包装类型</th><th>材质</th><th class="cargo-switch-column">危险品</th><th class="cargo-switch-column">木制品<small>勾选后须商检</small></th>
<th class="cargo-number-column">箱规长 cm</th><th class="cargo-number-column">箱规宽 cm</th><th class="cargo-number-column">箱规高 cm</th><th class="cargo-number-column">每箱数量</th><th class="cargo-number-column">箱数</th><th class="cargo-number-column">总毛重</th><th class="cargo-number-column">总体积</th><th class="cargo-number-column">总数量</th><th class="cargo-number-column">总净重</th>
<th class="cargo-number-column">箱规长 cm</th><th class="cargo-number-column">箱规宽 cm</th><th class="cargo-number-column">箱规高 cm</th><th class="cargo-number-column">每箱数量</th><th class="cargo-number-column">箱数</th><th class="cargo-number-column"><span class="required-field-mark" aria-hidden="true">*</span>总毛重</th><th class="cargo-number-column"><span class="required-field-mark" aria-hidden="true">*</span>总体积</th><th class="cargo-number-column">总数量</th><th class="cargo-number-column">总净重</th>
<th>清关申报单价 USD</th><th>清关申报金额 USD</th><th>用途</th><th>型号</th><th>品牌</th><th class="cargo-dimensions-column">产品规格 长×宽×高</th><th>货件编号<small>货件编号必填</small></th><th>跟踪编号<small>跟踪编号必填</small></th><th>UPC NO.</th><th>备注</th><th class="cargo-operation-column">操作</th>
</tr></thead>
<tbody><tr v-for="(item, index) in form.items" :key="item.id">
<tbody><tr v-for="(item, index) in form.items" :key="item.id" :class="{ 'mixed-sku-detail-row': isAdditionalMixedSku(item, index) }">
<td class="cargo-check-cell"><el-checkbox :model-value="selectedCargoIds.includes(item.id)" :aria-label="`选择货物 ${index + 1}`" @change="toggleCargoItem(item.id, $event)" /></td><td class="cargo-index-cell">{{ index + 1 }}</td>
<td><el-form-item :prop="`items.${index}.sku`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入 SKU NO.') : undefined"><el-input v-model="item.sku" maxlength="64" placeholder="SKU编号" /></el-form-item></td>
<td><el-form-item :prop="`items.${index}.sku`" :required="form.destinationType === 'FBA' || form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" :rules="form.destinationType === 'FBA' || form.fulfillmentType === 'ONE_CLICK_DROPSHIP' ? requiredTextRules('请输入 SKU NO.') : undefined"><div class="cargo-sku-input"><el-input v-model="item.sku" maxlength="64" placeholder="SKU编号" /><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP' && mixedSkuCount(item) > 1" class="mixed-sku-badge">同箱</small></div></el-form-item></td>
<td><el-form-item :prop="`items.${index}.cargoNameCn`" required :rules="requiredTextRules('请输入中文品名')"><el-input v-model="item.cargoNameCn" maxlength="128" placeholder="中文品名" /></el-form-item></td>
<td class="cargo-image-column"><div class="cargo-table-image"><img v-if="item.productImagePreviewUrl" :src="item.productImagePreviewUrl" :alt="`${item.cargoNameCn || `货物 ${index + 1}`}产品图片`" /><el-upload v-else :show-file-list="false" accept=".jpg,.jpeg,.png,image/jpeg,image/png" :disabled="item.productImageUploading" :http-request="(options: UploadRequestOptions) => uploadCargoProductImage(item, options)"><el-button text :loading="item.productImageUploading" aria-label="上传图片"><ImagePlus :size="17" /></el-button></el-upload><el-button v-if="item.productImagePreviewUrl" class="cargo-image-remove" text type="danger" aria-label="移除图片" @click="removeCargoProductImage(item)"><Trash2 :size="12" /></el-button></div></td>
<td><el-input v-model="item.cargoNameEn" maxlength="128" placeholder="English Name" /></td><td><el-input v-model="item.hsCode" maxlength="20" placeholder="HS Code" /></td><td><el-input v-model="item.customsHsCode" maxlength="20" placeholder="HS Code" /></td>
<td><el-form-item :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item></td><td><el-input v-model="item.material" maxlength="128" placeholder="材质" /></td>
<td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType" @change="syncPackingGroupFields(item)"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item></td><td><el-input v-model="item.material" maxlength="128" placeholder="材质" /></td>
<td class="cargo-switch-column"><el-switch v-model="item.dangerousGoods" :aria-label="`第 ${index + 1} 条货物是否危险品`" /></td><td class="cargo-switch-column"><div class="cargo-switch-cell"><el-switch v-model="item.woodenProduct" :aria-label="`第 ${index + 1} 条货物是否木制品`" /><small v-if="item.woodenProduct && form.customsInspection !== true">第 {{ index + 1 }} 条须商检</small></div></td>
<td><el-input-number v-model="item.lengthCm" :min="0" :precision="2" :controls="false" placeholder="长" /></td><td><el-input-number v-model="item.widthCm" :min="0" :precision="2" :controls="false" placeholder="宽" /></td><td><el-input-number v-model="item.heightCm" :min="0" :precision="2" :controls="false" placeholder="高" /></td>
<td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" placeholder="每箱数量" /></el-form-item></td><td><el-form-item :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" placeholder="箱数" /></el-form-item></td><td><el-form-item :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" placeholder="总毛重" /></el-form-item></td><td><el-form-item :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" placeholder="总体积" /></el-form-item></td><td><el-form-item :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" placeholder="总数量" /></el-form-item></td><td><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" placeholder="净重" /></td>
<td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同上</span><el-input-number v-else v-model="item.lengthCm" :min="0" :precision="2" :controls="false" placeholder="长" @change="syncPackingGroupFields(item)" /></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同上</span><el-input-number v-else v-model="item.widthCm" :min="0" :precision="2" :controls="false" placeholder="宽" @change="syncPackingGroupFields(item)" /></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同上</span><el-input-number v-else v-model="item.heightCm" :min="0" :precision="2" :controls="false" placeholder="高" @change="syncPackingGroupFields(item)" /></td>
<td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" placeholder="每箱数量" @change="syncSkuQuantity(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" placeholder="箱数" @change="syncPackingGroupFields(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" placeholder="总毛重" @change="syncPackingGroupFields(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-form-item v-else :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" placeholder="总体积" @change="syncPackingGroupFields(item)" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="derived-sku-value">{{ item.pieces }}</span><el-form-item v-else :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" placeholder="总数量" /></el-form-item></td><td><span v-if="isAdditionalMixedSku(item, index)" class="shared-carton-value">同箱</span><el-input-number v-else v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" placeholder="净重" @change="syncPackingGroupFields(item)" /></td>
<td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" placeholder="申报单价" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" placeholder="申报总价" /></td><td><el-input v-model="item.usage" maxlength="128" placeholder="用途" /></td><td><el-input v-model="item.model" maxlength="64" placeholder="型号" /></td><td><el-input v-model="item.brand" maxlength="64" placeholder="品牌" /></td>
<td class="cargo-dimensions-column"><div class="cargo-table-dimensions"><el-input-number v-model="item.productLengthCm" :min="0" :precision="2" :controls="false" placeholder="长" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" placeholder="宽" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" placeholder="高" /></div></td>
<td><el-form-item :prop="`items.${index}.shipmentId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入货件编号') : undefined"><el-input v-model="item.shipmentId" maxlength="64" placeholder="货件编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.referenceId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入跟踪编号') : undefined"><el-input v-model="item.referenceId" maxlength="64" placeholder="跟踪编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.upc`"><el-input v-model="item.upc" maxlength="64" placeholder="UPC NO.(选填)" /></el-form-item></td><td><el-input v-model="item.remark" maxlength="500" placeholder="备注" /></td><td class="cargo-operation-column"><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="15" />删除</el-button></td>
<td><el-form-item :prop="`items.${index}.shipmentId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入货件编号') : undefined"><el-input v-model="item.shipmentId" maxlength="64" placeholder="货件编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.referenceId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入跟踪编号') : undefined"><el-input v-model="item.referenceId" maxlength="64" placeholder="跟踪编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.upc`"><el-input v-model="item.upc" maxlength="64" placeholder="UPC NO.(选填)" /></el-form-item></td><td><el-input v-model="item.remark" maxlength="500" placeholder="备注" /></td><td class="cargo-operation-column"><el-button v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" link type="primary" :aria-label="`为货物 ${index + 1} 添加同箱 SKU`" @click="addSkuToPackingGroup(index)"><Plus :size="15" />添加 SKU</el-button><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="15" />删除</el-button></td>
</tr></tbody>
</table>
</div>
<div class="cargo-table-hint"><span>SKU NO.、货件编号、跟踪编号为 FBA 货物必填项,UPC NO. 选填</span></div>
<div class="cargo-table-hint"><span v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'">每个箱组默认装一个 SKU;同一箱内有多个 SKU 时点击“添加 SKU”,箱数、箱规、重量和体积只填写一次。</span><span v-else>SKU NO.、货件编号、跟踪编号为 FBA 货物必填项,UPC NO. 选填</span></div>
</section>
<section class="order-form-section prototype-section cost-estimate-section" aria-label="费用预估">
@@ -2138,6 +2212,7 @@ async function searchOrders(model: QueryModel) {
<el-table :data="pagedDetailCargoItems" border stripe class="cargo-detail-table" row-key="id">
<el-table-column type="index" label="#" width="54" align="center" :index="(index: number) => (detailCargoPage - 1) * detailCargoPageSize + index + 1" />
<el-table-column label="SKU NO." width="150" show-overflow-tooltip><template #default="{ row }"><span class="table-code table-ellipsis">{{ row.sku || '-' }}</span></template></el-table-column>
<el-table-column v-if="detailOrder.fulfillmentType === 'ONE_CLICK_DROPSHIP'" prop="packingGroupNo" label="混箱组合" width="100" align="center"><template #default="{ row }">{{ row.packingGroupNo || '-' }}</template></el-table-column>
<el-table-column label="中文品名" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.cargoNameCn || '-' }}</template></el-table-column>
<el-table-column label="图片" width="66" align="center"><template #default="{ row }"><div class="cargo-detail-thumbnail"><img v-if="row.productImagePreviewUrl" :src="row.productImagePreviewUrl" :alt="`${row.cargoNameCn || '货物'}产品图片`" /><span v-else>-</span></div></template></el-table-column>
<el-table-column label="英文品名" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.cargoNameEn || '-' }}</template></el-table-column>
@@ -2425,8 +2500,15 @@ async function searchOrders(model: QueryModel) {
.cargo-check-cell, .cargo-index-cell { width: 38px !important; text-align: center !important; vertical-align: middle !important; }
.cargo-image-column { width: 62px !important; text-align: center !important; }
.cargo-number-column { width: 92px !important; }
.required-field-mark { margin-right: 2px; color: var(--el-color-danger); }
.cargo-dimensions-column { width: 240px !important; }
.cargo-operation-column { width: 76px !important; text-align: center !important; }
.cargo-table-wrap.is-dropship .cargo-operation-column { width: 148px !important; }
.cargo-sku-input { position: relative; width: 100%; }
.mixed-sku-badge { position: absolute; top: -10px; right: 2px; padding: 1px 5px; border-radius: 999px; background: #eaf6f2; color: var(--primary); font-size: 9px; font-weight: 650; line-height: 16px; white-space: nowrap; }
.mixed-sku-detail-row { background: #f8fbff; }
.shared-carton-value { display: inline-flex; min-height: 30px; align-items: center; color: #8a97a8; font-size: 12px; white-space: nowrap; }
.derived-sku-value { display: inline-flex; min-height: 30px; align-items: center; color: #172b4d; font-weight: 650; }
.cargo-switch-column { width: 112px !important; text-align: center !important; vertical-align: middle !important; }
.cargo-switch-cell { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 4px; }
.cargo-switch-cell small { color: #b54708; font-size: 9px; line-height: 1.25; white-space: normal; }