@@ -16,11 +16,22 @@ import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBillin
import { getMerchantCustomsForOrder , isMerchantCustomsUnavailable , type MerchantCustomsContainer } from '@/modules/first-leg/api/customs'
import {
estimateMerchantFirstLegPrice ,
createIdempotencyKey ,
createMerchantOrderAddress ,
disableMerchantOrderAddress ,
downloadMerchantFirstLegBatchImportTemplate ,
downloadMerchantOrderCartonLabelsBatch ,
downloadMerchantProductImage ,
getMerchantInboundCodePolicy ,
getMerchantOrderAddresses ,
getMerchantPricingChannels ,
importMerchantFirstLegOrders ,
updateMerchantOrderAddress ,
uploadMerchantProductImage ,
type CustomerOrderAddress ,
type CustomerOrderAddressSaveRequest ,
type MerchantBatchImportMode ,
type MerchantBatchImportResponse ,
type MerchantPriceEstimate ,
type MerchantPriceEstimateRequest ,
type MerchantPricingChannel ,
@@ -83,18 +94,14 @@ const priceEstimate = ref<MerchantPriceEstimate | null>(null)
const priceEstimateLoading = ref ( false )
const priceEstimateError = ref ( '' )
const selectedCargoIds = ref < string [ ] > ( [ ] )
const cargoImportInput = ref < HTMLInputElement > ( )
const cargoTemplateColumns = [
[ 'SKU NO.' , 'sku' ] , [ '中文品名' , 'cargoNameCn' ] , [ '英文品名' , 'cargoNameEn' ] , [ '报关 HS CODE' , 'hsCode' ] , [ '清关 HS CODE' , 'customsHsCode' ] ,
[ '包装类型' , 'packageType' ] , [ '箱规长 cm' , 'lengthCm' ] , [ '箱规宽 cm' , 'widthCm' ] , [ '箱规高 cm' , 'heightCm' ] , [ '每箱数量' , 'unitsPerPackage' ] ,
[ '箱数' , 'packageCount' ] , [ '总毛重' , 'grossWeightKg' ] , [ '总体积' , 'volumeCbm' ] , [ '总数量' , 'pieces' ] , [ '总净重' , 'netWeightKg' ] ,
[ '清关申报单价 USD' , 'unitDeclaredValue' ] , [ '清关申报金额 USD' , 'declaredValue' ] , [ '材质' , 'material' ] , [ '用途' , 'usage' ] , [ '型号' , 'model' ] ,
[ '品牌' , 'brand' ] , [ '产品长 cm' , 'productLengthCm' ] , [ '产品宽 cm' , 'productWidthCm' ] , [ '产品高 cm' , 'productHeightCm' ] , [ '危险品' , 'dangerousGoods' ] ,
[ '货件编号' , 'shipmentId' ] , [ '跟踪编号' , 'referenceId' ] , [ 'UPC NO.' , 'upc' ] , [ '备注' , 'remark' ] ,
] as const
const cargoNumberKeys = new Set < string > ( [ 'lengthCm' , 'widthCm' , 'heightCm' , 'unitsPerPackage' , 'packageCount' , 'grossWeightKg' , 'volumeCbm' , 'pieces' , 'netWeightKg' , 'unitDeclaredValue' , 'declaredValue' , 'productLengthCm' , 'productWidthCm' , 'productHeightCm' ] )
const batchImportInput = ref < HTMLInputElement > ( )
const batchImportOpen = ref ( false )
const batchImportMode = ref < MerchantBatchImportMode > ( 'PARTIAL' )
const batchImportFile = ref < File | null > ( null )
const batchImportIdempotencyKey = ref ( '' )
const batchImportSubmitting = ref ( false )
const batchTemplateDownloading = ref ( false )
const batchImportResult = ref < MerchantBatchImportResponse | null > ( null )
const fixedOrderColumns : DataTableColumn [ ] = [
{ key : 'selection' , type : 'selection' , width : 48 , fixed : 'left' , reserveSelection : true } ,
{ key : 'actions' , label : '操作' , width : 216 , fixed : 'left' , align : 'center' , headerAlign : 'center' , slotName : 'actions' } ,
@@ -207,35 +214,13 @@ let channelRequestSequence = 0
let estimateRequestSequence = 0
let estimateTimer : ReturnType < typeof setTimeout > | undefined
interface CachedPrivateAddress {
id : string
label : string
consigneePartyId : string
consigneePartySnapshot : NonNullable < FirstLegOrderDraft [ ' consigneePartySnapshot ' ] >
consignee : string
destination : string
destinationCountryCode : string
destinationWarehouseName : string
}
const orderAddresses = ref < CustomerOrderAddress [ ] > ( [ ] )
const orderAddressesLoading = ref ( false )
const pickupAddresses = computed ( ( ) => orderAddresses . value . filter ( ( item ) => item . addressType === 'PICKUP' ) )
const privateAddresses = computed ( ( ) => orderAddresses . value . filter ( ( item ) => item . addressType === 'PRIVATE_DESTINATION' ) )
const privateAddresses = ref < CachedPrivateAddress [ ] > ( [ ] )
const selectedPrivateAddressId = ref ( '' )
function privateAddressStorageKey ( ) {
return ` cc:first-leg:private-addresses: ${ auth . user ? . id ? ? 'anonymous' } `
}
function loadPrivateAddresses ( ) {
try {
const value = JSON . parse ( localStorage . getItem ( privateAddressStorageKey ( ) ) ? ? '[]' ) as unknown
privateAddresses . value = Array . isArray ( value ) ? value . filter ( ( item ) : item is CachedPrivateAddress => Boolean ( item && typeof item === 'object' && 'id' in item && 'consigneePartySnapshot' in item ) ) : [ ]
} catch {
privateAddresses . value = [ ]
}
}
function persistPrivateAddresses ( ) {
localStorage . setItem ( privateAddressStorageKey ( ) , JSON . stringify ( privateAddresses . value ) )
function addressLabel ( address : CustomerOrderAddress ) {
return ` ${ address . addressName } · ${ address . contactName } · ${ address . countryCode } ${ address . city } ${ address . addressLine } `
}
const orderFormRules : FormRules = {
@@ -352,9 +337,11 @@ function emptyDraft(): FirstLegOrderDraft {
receivingWarehouseId : '' ,
expectedDepartureDate : '' ,
shipperPartyId : '' ,
senderAddressId : '' ,
shipperPartySnapshot : null ,
shipper : '' ,
consigneePartyId : '' ,
privateDestinationAddressId : '' ,
consigneePartySnapshot : null ,
consignee : '' ,
priority : 'NORMAL' ,
@@ -368,7 +355,7 @@ function emptyDraft(): FirstLegOrderDraft {
channelName : '' ,
pricingTaxMode : 'TAX_INCLUDED' ,
taxMode : 'BUY_DECLARATION' ,
customsInspection : false ,
customsInspection : null ,
destinationType : 'FBA' ,
amazonWarehouseCode : '' ,
shipmentId : '' ,
@@ -566,11 +553,13 @@ async function loadPageData() {
const orderPageRequest = isCreatePage . value
? store . loadWarehouseType ( 'DOMESTIC' , true )
: store . load ( { includeDrafts : true } )
const [ ordersResult , companyResult , destinationResult , policyResult ] = await Promise . allSettled ( [
const addressRequest = isCreatePage . value ? loadOrderAddresses ( ) : Promise . resolve ( )
const [ ordersResult , companyResult , destinationResult , policyResult , addressResult ] = await Promise . allSettled ( [
orderPageRequest ,
companyRequest ,
destinationRequest ,
getMerchantInboundCodePolicy ( ) ,
addressRequest ,
] )
if ( policyResult . status === 'fulfilled' ) inboundCodePolicy . value = policyResult . value
else {
@@ -586,6 +575,7 @@ async function loadPageData() {
if ( store . warehouseLoadError ) supportingDataErrors . value . push ( ` 公共仓库加载失败: ${ store . warehouseLoadError } ` )
if ( ! isCreatePage . value && store . draftLoadError ) supportingDataErrors . value . push ( ` 订单草稿加载失败: ${ store . draftLoadError } ` )
if ( destinationResult . status === 'rejected' ) supportingDataErrors . value . push ( ` 目的仓加载失败: ${ errorMessage ( destinationResult . reason ) } ` )
if ( addressResult . status === 'rejected' ) supportingDataErrors . value . push ( ` 企业地址簿加载失败: ${ errorMessage ( addressResult . reason ) } ` )
await autofillWarehouseSelections ( )
if ( isCreatePage . value && routeDraftId . value ) await loadRouteDraft ( routeDraftId . value )
supportingDataLoading . value = false
@@ -625,7 +615,6 @@ function getOrderCargoSummary(order: FirstLegOrder) {
}
onMounted ( ( ) => {
loadPrivateAddresses ( )
void loadPageData ( )
} )
@@ -745,9 +734,11 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
receivingWarehouseId : order . receivingWarehouseId ,
expectedDepartureDate : order . expectedDepartureDate ,
shipperPartyId : order . shipperPartyId ,
senderAddressId : '' ,
shipperPartySnapshot : order . shipperPartySnapshot ? { ... order . shipperPartySnapshot } : null ,
shipper : order . shipper ,
consigneePartyId : order . consigneePartyId ,
privateDestinationAddressId : '' ,
consigneePartySnapshot : order . consigneePartySnapshot ? { ... order . consigneePartySnapshot } : null ,
consignee : order . consignee ,
priority : order . priority ,
@@ -939,7 +930,7 @@ async function changeDestinationType() {
form . destinationWarehouseName = ''
form . destination = ''
form . amazonWarehouseCode = ''
selectedPrivateAddressId . value = ''
form . privateDestinationAddressId = ''
form . consigneePartyId = ''
form . consigneePartySnapshot = form . destinationType === 'PRIVATE' ? createEmptyPartySnapshot ( 'CONSIGNEE' ) : null
form . consignee = ''
@@ -958,6 +949,7 @@ function changePickupMethod() {
if ( form . pickupMethod === 'PICKUP' ) {
form . shipperPartySnapshot ? ? = createPickupPartySnapshot ( )
} else {
form . senderAddressId = ''
form . shipperPartyId = ''
form . shipperPartySnapshot = null
form . shipper = ''
@@ -974,54 +966,129 @@ function syncPrivateDestinationName(value: string) {
}
function selectPrivateAddress ( id : string ) {
const address = privateAddresses . value . find ( ( item ) => item . id === id )
const address = privateAddresses . value . find ( ( item ) => String ( item . id ) === id )
if ( ! address ) return
form . consigneePartyId = address . consigneePartyId
form . consigneePartySnapshot = { ... address . consigneePartySnapshot }
form . consignee = address . consignee
form . destination = address . destination
form . destinationCountryCode = address . destinationCountryCode
form . privateDestinationAddressId = String ( address . id )
form . consigneePartyId = ''
form . consigneePartySnapshot = addressSnapshot ( address , 'CONSIGNEE' )
form . consignee = address . companyName || address . addressName
form . destination = address . addressName
form . destinationCountryCode = address . countryCode
form . destinationWarehouseId = ''
form . destinationWarehouseName = address . destinationWarehouseName
form . destinationWarehouseName = address . addressName
}
function savePrivateAddress ( ) {
if ( ! form . consigneePartySnapshot || ! form . destinationWarehouseName . trim ( ) || ! form . destinationCountryCode . trim ( )
|| ! form . consigneePartySnapshot . contactName . trim ( ) || ! form . consigneePartySnapshot . phone . trim ( )
|| ! form . consigneePartySnapshot . city . trim ( ) || ! form . consigneePartySnapshot . address . trim ( ) ) {
ElMessage . warning ( '请先完整填写私人地址和收件资料' )
function addressSnapshot ( address : CustomerOrderAddress , role : FirstLegPartyRole ) : FirstLegPartySnapshot {
return {
id : '' ,
code : '' ,
name : address . companyName || address . addressName ,
role ,
contactName : address . contactName ,
phone : address . phone ,
email : address . email ? ? '' ,
countryRegion : address . countryCode ,
province : address . province ? ? '' ,
city : address . city ,
postalCode : address . postalCode ? ? '' ,
address : address . addressLine ,
}
}
async function loadOrderAddresses ( ) {
orderAddressesLoading . value = true
try {
orderAddresses . value = await getMerchantOrderAddresses ( )
} finally {
orderAddressesLoading . value = false
}
}
function selectPickupAddress ( id : string ) {
const address = pickupAddresses . value . find ( ( item ) => String ( item . id ) === id )
if ( ! address ) return
form . senderAddressId = String ( address . id )
form . shipperPartyId = ''
form . shipperPartySnapshot = addressSnapshot ( address , 'SHIPPER' )
form . shipper = address . companyName || address . addressName
form . origin = address . city
}
function addressPayload ( addressType : CustomerOrderAddress [ 'addressType' ] , snapshot : FirstLegPartySnapshot , addressName : string ) : CustomerOrderAddressSaveRequest {
const payload : CustomerOrderAddressSaveRequest = {
addressType ,
addressName : addressName . trim ( ) ,
companyName : snapshot . name . trim ( ) || addressName . trim ( ) ,
contactName : snapshot . contactName . trim ( ) ,
phone : snapshot . phone . trim ( ) ,
countryCode : snapshot . countryRegion . trim ( ) . toUpperCase ( ) ,
city : snapshot . city . trim ( ) ,
addressLine : snapshot . address . trim ( ) ,
defaultAddress : false ,
}
if ( snapshot . email . trim ( ) ) payload . email = snapshot . email . trim ( )
if ( snapshot . province . trim ( ) ) payload . province = snapshot . province . trim ( )
if ( snapshot . postalCode . trim ( ) ) payload . postalCode = snapshot . postalCode . trim ( )
return payload
}
function addressComplete ( snapshot : FirstLegPartySnapshot | null , addressName : string ) {
return Boolean ( snapshot && addressName . trim ( ) && snapshot . contactName . trim ( ) && snapshot . phone . trim ( )
&& /^[A-Za-z]{2}$/ . test ( snapshot . countryRegion . trim ( ) ) && snapshot . city . trim ( ) && snapshot . address . trim ( ) )
}
async function savePickupAddress ( ) {
const snapshot = form . shipperPartySnapshot
const addressName = snapshot ? . name . trim ( ) || ` ${ snapshot ? . city . trim ( ) || '国内' } 提货地址 `
if ( ! addressComplete ( snapshot , addressName ) ) {
ElMessage . warning ( '请先完整填写提货地址和联系人资料' )
return
}
const snapshot = { ... form . consigneePartySnapshot }
snapshot . name = form . destinationWarehouseName . trim ( )
snapshot . countryRegion = form . destinationCountryCode . trim ( ) . toUpperCase ( )
const existing = privateAddresses . value . find ( ( item ) => item . consigneePartySnapshot . address === snapshot . address )
const partyId = existing ? . consigneePartyId || snapshot . id || ` private-party- ${ Date . now ( ) } `
snapshot . id = partyId
const saved : CachedPrivateAddress = {
id : existing ? . id ? ? globalThis . crypto ? . randomUUID ? . ( ) ? ? ` private- ${ Date . now ( ) } ` ,
label : ` ${ form . destinationWarehouseName . trim ( ) } · ${ snapshot . address } ` ,
consigneePartyId : partyId ,
consigneePartySnapshot : snapshot ,
consignee : snapshot . name ,
destination : form . destination ,
destinationCountryCode : form . destinationCountryCode . trim ( ) . toUpperCase ( ) ,
destinationWarehouseName : form . destinationWarehouseName . trim ( ) ,
try {
const payload = addressPayload ( 'PICKUP' , snapshot ! , addressName )
if ( form . senderAddressId ) await updateMerchantOrderAddress ( form . senderAddressId , payload )
else form . senderAddressId = String ( await createMerchantOrderAddress ( payload ) )
await loadOrderAddresses ( )
selectPickupAddress ( form . senderAddressId )
ElMessage . success ( '企业提货地址已保存' )
} catch ( error ) {
ElMessage . error ( errorMessage ( error ) )
}
privateAddresses . value = existing
? privateAddresses . value . map ( ( item ) => item . id === existing . id ? saved : item )
: [ saved , ... privateAddresses . value ]
selectedPrivateAddressId . value = saved . id
persistPrivateAddresses ( )
ElMessage . success ( existing ? '私人地址已更新' : '私人地址已保存到当前账号' )
}
function removePrivateAddress ( ) {
if ( ! selectedPrivateAddressId . value ) return
privateAddresses . value = privateAddresses . value . filter ( ( item ) => item . id !== selectedPrivateAddressId . value )
selectedPrivateAddressId . value = ''
persistPrivateAddresses ( )
ElMessage . success ( '已从当前账号的 私人地址中移除 ' )
async function savePrivateAddress ( ) {
const snapshot = form . consigneePartySnapshot
const addressName = form . destinationWarehouseName . trim ( )
if ( snapshot ) snapshot . countryRegion = form . destinationCountryCode . trim ( ) . toUpperCase ( )
if ( ! addressComplete ( snapshot , addressName ) ) {
ElMessage . warning ( '请先完整填写 私人仓 地址和收件资料 ' )
return
}
snapshot ! . name = addressName
try {
const payload = addressPayload ( 'PRIVATE_DESTINATION' , snapshot ! , addressName )
if ( form . privateDestinationAddressId ) await updateMerchantOrderAddress ( form . privateDestinationAddressId , payload )
else form . privateDestinationAddressId = String ( await createMerchantOrderAddress ( payload ) )
await loadOrderAddresses ( )
selectPrivateAddress ( form . privateDestinationAddressId )
ElMessage . success ( '企业私人仓地址已保存' )
} catch ( error ) {
ElMessage . error ( errorMessage ( error ) )
}
}
async function removeOrderAddress ( type : CustomerOrderAddress [ 'addressType' ] ) {
const id = type === 'PICKUP' ? form . senderAddressId : form . privateDestinationAddressId
if ( ! id ) return
try {
await disableMerchantOrderAddress ( id )
if ( type === 'PICKUP' ) form . senderAddressId = ''
else form . privateDestinationAddressId = ''
await loadOrderAddresses ( )
ElMessage . success ( '地址已从企业地址簿停用' )
} catch ( error ) {
ElMessage . error ( errorMessage ( error ) )
}
}
async function loadSelectedWarehouse ( id : string | number | boolean | undefined ) {
@@ -1073,7 +1140,7 @@ async function openEdit(order: FirstLegOrder) {
if ( openingEditor . value || ! canEditOrder . value || ! canClientEditFirstLegOrder ( order . status ) ) return
openingEditor . value = true
try {
const detailedOrder = await store . loadDetail ( order . id )
const [ detailedOrder ] = await Promise . all ( [ store . loadDetail ( order . id ) , loadOrderAddresses ( ) ] )
const draft = draftFromOrder ( detailedOrder )
if ( draft . destinationType !== 'PRIVATE' ) await loadDestinationWarehouses ( draft . destinationType )
const warehouseAvailable = availableWarehouses . value . some ( ( item ) => item . id === detailedOrder . receivingWarehouseId )
@@ -1395,68 +1462,63 @@ function removeSelectedCargoItems() {
resetEditorValidation ( )
}
function downloadCargoTemplate ( ) {
const headers = cargoTemplateColumns . map ( ( [ label ] ) => ` <th> ${ label } </th> ` ) . join ( '' )
const example = cargoTemplateColumns . map ( ( [ label ] ) => {
if ( label === '包装类型' ) return '<td>CARTON</td> '
if ( label === '危险品' ) return '<td>否</td>'
if ( [ '每箱数量' , '箱数' , '总数量' ] . includes ( label ) ) return '<td>1</td>'
return '<td></td>'
} ) . join ( '' )
const html = ` <!doctype html><html><head><meta charset="UTF-8"></head><body><table><thead><tr> ${ headers } </tr></thead><tbody><tr> ${ example } </tr></tbody></table></body></html> `
const url = URL . createObjectURL ( new Blob ( [ '\ufeff' , html ] , { type : 'application/vnd.ms-excel;charset=utf-8' } ) )
const anchor = document . createElement ( 'a' )
anchor . href = url
anchor . download = '货物明细导入模板.xls'
anchor . click ( )
URL . revokeObjectURL ( url )
function openBatchImport ( ) {
batchImportMode . value = 'PARTIAL'
batchImportFile . value = null
batchImportIdempotencyKey . value = ''
batchImportResult . value = null
batchImportOpen . value = true
}
function parseCargoTemplateRows ( text : string ) {
const documentNode = new DOMParser ( ) . parseFromString ( text , 'text/html' )
const tableRows = [ ... documentNode . querySelectorAll ( 'table tr' ) ]
if ( tableRows . length < 2 ) return [ ]
const headers = [ ... tableRows [ 0 ] . querySelectorAll ( 'th,td' ) ] . map ( ( cell ) => cell . textContent ? . trim ( ) || '' )
return tableRows . slice ( 1 ) . map ( ( row ) => {
const values = [ ... row . querySelectorAll ( 'td' ) ] . map ( ( cell ) => cell . textContent ? . trim ( ) || '' )
return { headers , values }
} ) . filter ( ( { values } ) => values . some ( Boolean ) )
}
async function importCargoTemplate ( event : Event ) {
function selectBatchImportFile ( event : Event ) {
const input = event . target as HTMLInputElement
const file = input . files ? . [ 0 ]
input . value = ''
if ( ! file ) return
if ( ! file . name . toLowerCase ( ) . endsWith ( '.xls' ) ) {
ElMessage . error ( '请上传由“下载模板”生成的 .xls 文件' )
if ( ! file . name . toLowerCase ( ) . endsWith ( '.xlsx ' ) ) {
ElMessage . error ( '仅支持 .xlsx 格式的订单导入 文件' )
return
}
if ( file . size > 5 * 1024 * 1024 ) {
ElMessage . error ( '订单导入文件不能超过 5MB' )
return
}
batchImportFile . value = file
batchImportIdempotencyKey . value = createIdempotencyKey ( 'first-leg-batch-import' )
batchImportResult . value = null
}
async function downloadBatchTemplate ( ) {
try {
batchTemplateDownloading . value = true
saveBlob ( await downloadMerchantFirstLegBatchImportTemplate ( ) , '头程订单批量导入模板.xlsx' )
} catch ( error ) {
ElMessage . error ( errorMessage ( error ) )
} finally {
batchTemplateDownloading . value = false
}
}
async function submitBatchImport ( ) {
if ( ! batchImportFile . value ) {
ElMessage . warning ( '请先选择 .xlsx 订单导入文件' )
return
}
try {
const parsedRows = parseCargoTemplateRows ( await file . text ( ) )
if ( ! parsedRows . length ) throw new Error ( '模板中没有可导入的货物数据' )
const imported = parsedRows . map ( ( { headers , values } ) => {
const item = createEmptyCargoItem ( ` imported-cargo- ${ ++ cargoSequence } ` )
for ( const [ columnLabel , itemKey ] of cargoTemplateColumns ) {
const value = values [ headers . indexOf ( columnLabel ) ] ? ? ''
if ( itemKey === 'dangerousGoods' ) item . dangerousGoods = [ '是' , 'true' , '1' , 'yes' ] . includes ( value . toLowerCase ( ) )
else if ( cargoNumberKeys . has ( itemKey ) ) ( item as unknown as Record < string , number > ) [ itemKey ] = Number ( value ) || 0
else ( item as unknown as Record < string , string > ) [ itemKey ] = value
}
item . packageType = item . packageType || 'CARTON'
item . unitsPerPackage = item . unitsPerPackage || 1
item . packageCount = item . packageCount || 1
item . pieces = item . pieces || 1
return item
} )
const shouldReplaceDefaultRow = form . items . length === 1 && ! form . items [ 0 ] . cargoNameCn && ! form . items [ 0 ] . sku
if ( shouldReplaceDefaultRow ) form . items . splice ( 0 , 1 , ... imported )
else form . items . push ( ... imported )
selectedCargoIds . value = [ ]
resetEditorValidation ( )
ElMessage . success ( ` 已导入 ${ imported . length } 行货物明细 ` )
batchImportSubmitting . value = true
batchImportResult . value = await importMerchantFirstLegOrders (
batchImportFile . value ,
batchImportMode . value ,
batchImportIdempotencyKey . value ,
)
const result = batchImportResult . value . result
if ( result . succeeded ) await store . load ( { includeDrafts : true } )
if ( result . failed ) ElMessage . warning ( ` 导入完成:成功 ${ result . succeeded } 条,失败 ${ result . failed } 条 ` )
else ElMessage . success ( ` 已成功导入 ${ result . succeeded } 条订单草稿 ` )
} catch ( error ) {
ElMessage . error ( error instanceof Error ? error . message : '货物明细导入失败' )
ElMessage . error ( errorMessage ( error ) )
} finally {
batchImportSubmitting . value = false
}
}
@@ -1634,6 +1696,7 @@ async function searchOrders(model: QueryModel) {
< el-button link class = "column-settings-reset" @click ="resetOrderColumns" > < RotateCcw :size = "17" / > 恢复默认 < / el-button >
< / div >
< / el-popover >
< el-button v-if = "auth.hasPermission('order:first-leg:create')" @click="openBatchImport"><Upload :size="17" / > 批量导入订单 < / el-button >
< el-button v-if = "auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" / > 创建订单 < / el-button >
< / div >
< / div >
@@ -1751,6 +1814,9 @@ async function searchOrders(model: QueryModel) {
< div class = "prototype-form-flow shipping-form-flow" >
< 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" >
< el -form -item label = "企业提货地址簿" prop = "senderAddressId" required :rules = "requiredSelectRules('请选择企业提货地址')" class = "prototype-form-row prototype-form-row--wide address-book-row" >
< div class = "address-book-control" > < el-select v-model = "form.senderAddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业保存的提货地址" @change="selectPickupAddress"><el-option v-for="address in pickupAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" /></el-select><el-button @click="savePickupAddress">保存当前地址</el-button><el-button v-if="form.senderAddressId" link type="danger" @click="removeOrderAddress('PICKUP')" > 停用 < / el -button > < / div >
< / el-form-item >
< el-form-item label = "提货联系人" prop = "shipperPartySnapshot.contactName" required :rules = "requiredTextRules('请输入提货联系人')" class = "prototype-form-row prototype-form-row--compact" > < el-input v-model = "form.shipperPartySnapshot.contactName" maxlength="50" placeholder="请输入联系人姓名" / > < / el-form-item >
< el-form-item label = "联系电话" prop = "shipperPartySnapshot.phone" required :rules = "requiredTextRules('请输入提货联系电话')" class = "prototype-form-row prototype-form-row--compact" > < el-input v-model = "form.shipperPartySnapshot.phone" maxlength="30" placeholder="请输入联系电话" / > < / el-form-item >
< el-form-item label = "州 / 省" class = "prototype-form-row prototype-form-row--compact" > < el-input v-model = "form.shipperPartySnapshot.province" maxlength="100" placeholder="请输入州或省" / > < / el-form-item >
@@ -1775,7 +1841,7 @@ async function searchOrders(model: QueryModel) {
< / el -form -item >
< el-form-item label = "买单 / 退税" prop = "taxMode" required > < el-radio-group v-model = "form.taxMode"><el-radio value="BUY_DECLARATION">买单</el-radio><el-radio value="TAX_REFUND" > 退税 < / el -radio > < / el-radio-group > < / el-form-item >
< el-form-item label = "是否整柜" prop = "transportMode" required > < el-radio-group v-model = "form.transportMode"><el-radio value="FCL">整柜</el-radio><el-radio value="LCL" > 散货 < / el -radio > < / el-radio-group > < / el-form-item >
< el-form-item label = "是否商检" prop = "customsInspection" required > < el-radio-group v-model = "form.customsInspection"><el-radio :value="true">是</el-radio ><el-radio :value="false" > 否 < / el -radio > < / el-radio-group > < / el-form-item >
< el-form-item label = "是否商检" prop = "customsInspection" > < el-select v-model = "form.customsInspection" clearable placeholder="暂不选择"><el-option label="是" :value="true" /><el-option label="否" :value="false" / ></ el-select><small v-if="form.items.some((item) => item.woodenProduct) && form.customsInspection !== true" class="field-guidance field-guidance--error" > 货物包含木制品 , 提交前必须选择 “ 是 ” 。 < / small > < / el -form -item >
< el-form-item label = "期望出货日期" > < el-date-picker v-model = "form.expectedDepartureDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择期望出货日期" / > < / el-form-item >
< el-form-item label = "订单备注" > < el-input v-model = "form.customerRemark" type="textarea" :rows="3" resize="vertical" maxlength="300" show-word-limit placeholder="选填,例如:入仓前请电话联系" / > < / el-form-item >
< / div >
@@ -1787,7 +1853,7 @@ async function searchOrders(model: QueryModel) {
< 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 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 = "selectedPrivate AddressId" clearable filterable placeholder="选择当前账号 保存的私人地址" @change="selectPrivateAddress"><el-option v-for="address in privateAddresses" :key="address.id" :label="address.l abel" :value="address.id" /></el-select><el-button @click="savePrivateAddress">保存当前地址</el-button><el-button v-if="selectedPrivate AddressId" link type="danger" @click="removePrivateAddress " > 移除 < / el -button > < / div >
< div class = "address-book-control" > < el-select v-model = "form.privateDestination AddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业 保存的私人仓 地址" @change="selectPrivateAddress"><el-option v-for="address in privateAddresses" :key="String( address.id) " :label="addressL abel(address) " :value="String( address.id) " /></el-select><el-button @click="savePrivateAddress">保存当前地址</el-button><el-button v-if="form.privateDestination AddressId" link type="danger" @click="removeOrderAddress('PRIVATE_DESTINATION') " > 停用 < / el -button > < / div >
< / el-form-item >
< el-form-item v-if = "form.destinationType !== 'PRIVATE'" :label="form.destinationType === 'FBA' ? 'FBA 仓' : '海外仓'" prop="destinationWarehouseId" required :rules="requiredSelectRules('请选择平台维护的目的仓')" class="prototype-form-row"><el-select v-model="form.destinationWarehouseId" filterable :loading="destinationWarehousesLoading" placeholder="请选择当前可送达的目的仓" @change="selectDestinationWarehouse"><el-option v-for="warehouse in availableDestinationWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.code}`" :value="warehouse.id" /></el-select><small v-if="!destinationWarehousesLoading && availableDestinationWarehouses.length === 0" class="field-guidance" > 平台暂未维护可用的 {{ form.destinationType = = = ' FBA ' ? ' FBA 仓 ' : ' 海外仓 ' }} , 请联系平台管理员 。 < / small > < / el -form -item >
@@ -1808,7 +1874,7 @@ async function searchOrders(model: QueryModel) {
< section class = "order-form-section prototype-section cargo-section" >
< div class = "order-section-heading cargo-table-heading" > < div > < h3 > 货物明细 < / h3 > < / div > < / div >
< div class = "cargo-table-toolbar" >
< div class = "cargo-import-actions" > < input ref = "cargoImportInput" class = "cargo-import-input" type = "file" accept = ".xls,application/vnd.ms-excel" @change ="importCargoTemplate" / > < el-button class = "cargo-upload-button" @click ="cargoImportInput?.click()" > < Upload :size = "16" / > 上传 Excel < / el-button > < el-button class = "cargo-download-button" @click ="downloadCargoTemplate" > < Download :size = "16" / > 下载模板 < / el-button > < / div >
< span class = "cargo-table-hint" > 可逐行维护货物明细 ; 批量订单请返回订单列表使用正式 Excel 导入 。 < / 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" >
@@ -1817,7 +1883,7 @@ async function searchOrders(model: QueryModel) {
< 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 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 > 清关申报单价 USD < / th > < th > 清关申报金额 USD < / th > < th > 材质 < / th > < th > 用途 < / th > < th > 型号 < / th > < th > 品牌 < / th > < th class = "cargo-dimensions-column" > 产品规格 长 × 宽 × 高 < / th > < th > 危险品 < / th > < th > 货件编号 < small > 货件编号必填 < / small > < / th > < th > 跟踪编号 < small > 跟踪编号必填 < / small > < / th > < th > UPC NO . < / th > < th > 备注 < / th > < th class = "cargo-operation-column" > 操作 < / th >
< th > 清关申报单价 USD < / th > < th > 清关申报金额 USD < / th > < th > 材质 < / th > < th > 用途 < / th > < th > 型号 < / th > < th > 品牌 < / th > < th class = "cargo-dimensions-column" > 产品规格 长 × 宽 × 高 < / th > < th > 危险品 < / th > < th > 木制品 < / 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" >
< 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 >
@@ -1829,7 +1895,7 @@ async function searchOrders(model: QueryModel) {
< td > < el-input-number v-model = "item.lengthCm" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.widthCm" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.heightCm" :min="0" :precision="2" :controls="false" / > < / 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" /></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" /></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" /></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" /></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" /></el-form-item></td><td><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" / > < / td >
< td > < el-input-number v-model = "item.unitDeclaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input v-model="item.material" maxlength="128" 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" /><span>× </span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" /><span>× </span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" /></div></td><td class="cargo-danger-cell"><el-switch v-model="item.dangerousGoods" / > < / td >
< td class = "cargo-dimensions-column" > < div class = "cargo-table-dimensions" > < el-input-number v-model = "item.productLengthCm" :min="0" :precision="2" :controls="false" /><span>× </span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" /><span>× </span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" /></div></td><td class="cargo-danger-cell"><el-switch v-model="item.dangerousGoods" /></td><td class="cargo-danger-cell"><el-switch v-model="item.woodenProduct" / > < / 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 >
< / tr > < / tbody >
< / table >
@@ -1875,6 +1941,32 @@ async function searchOrders(model: QueryModel) {
< template # footer > < div class = "editor-actions" > < el-button :disabled = "saving" @click ="closeEditor" > 返回 < / el -button > < el-button v-if = "editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" / > { { companySubmissionBlocked ? '提交前需完成资料审核' : '提交订单' } } < / el-button > < / div > < / template >
< / FirstLegOrderEditorShell >
< el-dialog v-model = "batchImportOpen" title="批量导入头程订单" width="680px" destroy -on -close append -to -body >
< div class = "batch-import-dialog" >
< el-alert title = "使用服务端正式模板导入" description = "仅支持 .xlsx,文件不超过 5MB、最多 50 行。导入结果按本企业权限和数据范围返回。" type = "info" :closable = "false" show -icon / >
< div class = "batch-import-actions" >
< input ref = "batchImportInput" class = "cargo-import-input" type = "file" accept = ".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change ="selectBatchImportFile" / >
< el-button :loading = "batchTemplateDownloading" @click ="downloadBatchTemplate" > < Download :size = "16" / > 下载正式模板 < / el-button >
< el-button type = "primary" plain @click ="batchImportInput?.click()" > < Upload :size = "16" / > 选择 Excel < / el-button >
< span > { { batchImportFile ? . name || '尚未选择文件' } } < / span >
< / div >
< el-radio-group v-model = "batchImportMode" aria-label="导入模式" >
< el -radio value = "PARTIAL" > 部分成功 : 有效行创建草稿 , 失败行返回原因 < / el-radio >
< el-radio value = "ATOMIC" > 原子导入 : 任一行失败则整批不创建 < / el-radio >
< / el-radio-group >
< section v-if = "batchImportResult" class="batch-import-result" aria-live="polite" >
< strong > 批次 { { batchImportResult . batchNo } } < / strong >
< span > 共 { { batchImportResult . result . total } } 条 , 成功 { { batchImportResult . result . succeeded } } 条 , 失败 { { batchImportResult . result . failed } } 条 < / span >
< ul v-if = "batchImportResult.result.failed" >
< li v-for = "item in batchImportResult.result.items.filter((row) => !row.success)" :key="`${item.index}-${item.clientRowId}`" >
第 {{ item.index + 1 }} 行 ( {{ item.clientRowId | | ' 未填写行标识 ' }} ) : {{ item.errorMessage | | item.errorCode | | ' 导入失败 ' }}
< / li >
< / ul >
< / section >
< / div >
< template # footer > < div class = "editor-actions" > < el-button :disabled = "batchImportSubmitting" @click ="batchImportOpen = false" > 关闭 < / el -button > < el-button type = "primary" :loading = "batchImportSubmitting" :disabled = "!batchImportFile" @click ="submitBatchImport" > 开始导入 < / el -button > < / div > < / template >
< / el-dialog >
< el-dialog v-model = "detailOpen" class="first-leg-detail-dialog" title="订单详情" width="94vw" top="3vh" destroy -on -close append -to -body >
< div v-loading = "detailLoading" class="order-detail-content" >
< div v-if = "detailError" class="order-load-state is-compact" role="alert" >
@@ -1904,7 +1996,7 @@ async function searchOrders(model: QueryModel) {
< div > < dt > 目的国家 / 仓库 < / dt > < dd > { { detailOrder . destinationCountryCode || '-' } } · { { detailOrder . destinationWarehouseName || '-' } } < / dd > < / div >
< div v-if = "detailOrder.destinationType === 'FBA'" > < dt > Amazon 仓库代码 < / dt > < dd > { { displayValue ( detailOrder . amazonWarehouseCode ) } } < / dd > < / div >
< div > < dt > 期望出运 < / dt > < dd > { { displayValue ( detailOrder . expectedDepartureDate ) } } < / dd > < / div >
< div > < dt > 海关查验 < / dt > < dd > { { detailOrder . customsInspection ? '需要' : '不需要' } } < / dd > < / div >
< div > < dt > 海关查验 < / dt > < dd > { { detailOrder . customsInspection == null ? '未选择' : ( detailOrder . customsInspection ? '需要' : '不需要' ) } } < / dd > < / div >
< div > < dt > 提交时间 < / dt > < dd > { { detailOrder . submittedAt ? formatDateTime ( detailOrder . submittedAt ) : '-' } } < / dd > < / div >
< div > < dt > 完成时间 < / dt > < dd > { { detailOrder . completedAt ? formatDateTime ( detailOrder . completedAt ) : '-' } } < / dd > < / div >
< / dl >
@@ -1973,6 +2065,7 @@ async function searchOrders(model: QueryModel) {
< el-table-column label = "品牌" width = "110" show -overflow -tooltip > < template # default = "{ row }" > { { row . brand || '-' } } < / template > < / el-table-column >
< el-table-column label = "产品规格 长×宽×高(cm)" width = "180" align = "center" > < template # default = "{ row }" > { { row . productLengthCm || '-' } } × { { row . productWidthCm || '-' } } × { { row . productHeightCm || '-' } } < / template > < / el-table-column >
< el-table-column label = "危险品" width = "82" align = "center" > < template # default = "{ row }" > { { row . dangerousGoods ? '是' : '否' } } < / template > < / el-table-column >
< el-table-column label = "木制品" width = "82" align = "center" > < template # default = "{ row }" > { { row . woodenProduct ? '是' : '否' } } < / template > < / el-table-column >
< el-table-column label = "备注" min -width = " 160 " show -overflow -tooltip > < template # default = "{ row }" > { { row . remark || '-' } } < / template > < / el-table-column >
< / el-table >
< el-pagination
@@ -2043,6 +2136,13 @@ async function searchOrders(model: QueryModel) {
. order - table - toolbar span { color : var ( -- muted ) ; font - size : 11 px ; }
. order - table - toolbar > . order - table - toolbar - actions { align - items : center ; gap : 8 px ; }
. order - table - toolbar - actions : deep ( . el - button + . el - button ) { margin - left : 0 ; }
. batch - import - dialog { display : flex ; flex - direction : column ; gap : 18 px ; }
. batch - import - actions { display : flex ; min - width : 0 ; align - items : center ; flex - wrap : wrap ; gap : 10 px ; }
. batch - import - actions > span { min - width : 0 ; overflow : hidden ; color : var ( -- muted ) ; font - size : 12 px ; text - overflow : ellipsis ; white - space : nowrap ; }
. batch - import - dialog : deep ( . el - radio - group ) { display : flex ; align - items : flex - start ; flex - direction : column ; gap : 8 px ; }
. batch - import - result { display : flex ; flex - direction : column ; gap : 6 px ; padding : 14 px ; border : 1 px solid var ( -- border ) ; background : # f8fafc ; }
. batch - import - result > span , . batch - import - result li { color : var ( -- muted ) ; font - size : 12 px ; line - height : 1.55 ; }
. batch - import - result ul { max - height : 180 px ; margin : 4 px 0 0 ; padding - left : 20 px ; overflow - y : auto ; }
. column - settings - trigger { width : 40 px ; min - width : 40 px ; height : 36 px ; padding : 0 ; color : # 53627 b ; }
: global ( . order - column - popper . el - popover ) { padding : 0 ; border - color : # e3e8f0 ; border - radius : 4 px ; box - shadow : 0 8 px 24 px rgba ( 29 , 45 , 74 , .12 ) ; }
: global ( . order - column - popper . column - settings - panel ) { padding : 14 px 10 px 10 px ; }