@@ -17,7 +17,6 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { CONTAINER _SPECIFICATIONS } from '@/shared/domain/containerSpecifications'
import { listPlatformTenants , useActiveCustomerOptions } from '@/modules/iam'
import { listPlatformPartners , type PlatformPartnerResource } from '@/modules/master-data'
import { listPlatformWarehouses , type PlatformWarehouseResource } from '@/modules/warehouses'
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
@@ -57,10 +56,6 @@ const partyTypes = [
{ value : 'NOTIFY_PARTY' , label : '通知人' } ,
] as const
const containerTypeOptions = CONTAINER _SPECIFICATIONS
const containerKindOptions = [
{ value : 'FCL' , label : '整箱 FCL' } ,
{ value : 'LCL' , label : '拼箱 LCL' } ,
]
const route = useRoute ( )
const router = useRouter ( )
const access = useAccessPreviewStore ( )
@@ -87,9 +82,6 @@ const channelsError = ref('')
const selectedChannelName = ref ( '' )
let channelRequestSerial = 0
let bookingAgentRequestSerial = 0
const selectableCustomers = computed ( ( ) => customers . value . filter ( ( customer ) => (
customer . status === 'ACTIVE' || String ( customer . id ) === String ( form . data . customerId )
) ) )
const shippingCompanies = computed ( ( ) => partners . value . filter ( ( partner ) => partner . types . includes ( 'CARRIER' ) ) )
const channelGroups = computed ( ( ) => {
const activeCodes = new Set ( channels . value . map ( ( channel ) => channel . channelCode ) )
@@ -103,7 +95,7 @@ function blankParties(): OceanBookingParty[] {
function blankData ( ) : OceanBookingDraftData {
return {
freightTerm : 'FREIGHT PREPAID' , serviceTerm : 'CY-CY' , packageUnit : 'CARTONS' ,
cargoDescription : '' , marksAndNos : '' , declaredPackageCount : null , declaredGrossWeightKg : null , declaredVolumeCbm : null ,
cargoDescription : '' , marksAndNos : '' , declaredPackageCount : 500 , declaredGrossWeightKg : 15000 , declaredVolumeCbm : 68 ,
actualPackageCount : null , actualGrossWeightKg : null , actualVolumeCbm : null ,
customerId : null , customerName : null , channelCode : '' , warehouseId : '' , parties : blankParties ( ) ,
billOfLadingNo : '' , vesselName : '' , voyageNo : '' , estimatedSailingAt : null , estimatedArrivalAt : null ,
@@ -112,7 +104,6 @@ function blankData(): OceanBookingDraftData {
}
}
const form = reactive < OceanBookingSaveRequest > ( { expectedVersion : 0 , data : blankData ( ) } )
const { customers , loading : customersLoading , error : customersError , search : loadCustomers , endReached : customerEndReached , retry : retryCustomers } = useActiveCustomerOptions ( ( ) => form . data . customerId , listPlatformTenants )
const savedDraftSignature = ref ( '' )
const rules : FormRules = { }
const editable = computed ( ( ) => isNew . value || detail . value ? . status === 'DRAFT' )
@@ -128,6 +119,7 @@ const actualReason = ref('')
const cargoWritable = computed ( ( ) => formWritable . value || ( detail . value ? . status === 'RELEASED' && access . can ( 'platform:firstleg:ocean-booking:edit' ) ) )
const workflowBusy = computed ( ( ) => saving . value || actionSaving . value || actualSaving . value )
const releaseDialogOpen = ref ( false )
const operationSections = ref < string [ ] > ( [ ] )
const printOpen = ref ( false )
const printType = ref < OceanBookingPrintType > ( 'SHIPPING_INSTRUCTION' )
const previewSignatures = ref < OceanBookingPreviewSignatures > ( { } )
@@ -143,8 +135,7 @@ async function loadOptions() {
optionsLoading . value = true
optionsError . value = ''
try {
const [ , partnerResult , warehouseResult ] = await Promise . all ( [
loadCustomers ( ) ,
const [ partnerResult , warehouseResult ] = await Promise . all ( [
listPlatformPartners ( { page : 1 , size : 200 , status : 'ACTIVE' } ) ,
listPlatformWarehouses ( { page : 1 , size : 200 , warehouseType : 'DOMESTIC' , status : 'ACTIVE' } ) ,
] )
@@ -161,12 +152,6 @@ async function loadOptions() {
optionsLoading . value = false
}
}
function selectCustomer ( value : unknown ) {
const customerId = value == null || value === '' ? null : String ( value )
form . data . customerId = customerId
const customer = customerId == null ? null : customers . value . find ( ( item ) => String ( item . id ) === customerId )
form . data . customerName = customer ? . tenantName || null
}
function draftPayload ( ) : OceanBookingDraftData {
const customerId = form . data . customerId || null
return {
@@ -388,13 +373,16 @@ function releaseValidationError() {
const requiredFields : Array < [ unknown , string ] > = [
[ form . data . warehouseId , '装柜仓库' ] , [ form . data . channelCode , '物流渠道' ] ,
[ form . data . billOfLadingNo , '提单号' ] , [ form . data . vesselName , '船名' ] , [ form . data . voyageNo , '航次' ] ,
[ form . data . estimatedSailingAt , '预计开船时间' ] , [ form . data . estimatedArrivalAt , '预计到港时间' ] ,
[ form . data . estimatedSailingAt , '预计开船时间' ] ,
[ form . data . placeOfDelivery , '交货地点' ] ,
[ form . data . shippingCompanyId , '船公司' ] , [ form . data . bookingAgentId , '订舱代理' ] ,
]
const missing = requiredFields . find ( ( [ value ] ) => ! String ( value || '' ) . trim ( ) )
if ( missing ) return ` 放舱前请填写 ${ missing [ 1 ] } `
if ( form . data . estimatedArrivalAt ! < form . data . estimatedSailingAt ! ) return '预计到港时间不能早于预计开船时间'
if ( missing ) {
if ( missing [ 1 ] === '装柜仓库' || missing [ 1 ] === '物流渠道' ) operationSections . value = [ 'operation' ]
return ` 放舱前请填写 ${ missing [ 1 ] } `
}
if ( form . data . estimatedArrivalAt && form . data . estimatedSailingAt && form . data . estimatedArrivalAt < form . data . estimatedSailingAt ) return '预计到港时间不能早于预计开船时间'
if ( form . data . parties . some ( ( party ) => ! party . partyName . trim ( ) ) ) return '放舱前请填写发货人、收货人和通知人名称'
if ( ! detail . value ? . documents . some ( ( document ) => document . valid && [ 'BOOKING_CONFIRMATION' , 'BILL_OF_LADING' ] . includes ( document . documentType ) ) ) return '放舱前请上传有效的订舱确认文件或提单文件'
return containerRequirementError ( form . data . containerRequirements )
@@ -492,18 +480,14 @@ onMounted(() => { void load(); void loadOptions() })
< el-form-item label = "订舱号" > < el-input : model -value = " detail ? .bookingNo | | ' 保存后生成 ' " disabled / > < / el-form-item >
< el-form-item label = "船公司" > < el-select :model-value = "form.data.shippingCompanyId" filterable clearable placeholder = "请选择船公司" :loading = "optionsLoading" style = "width:100%" @ update : model -value = " selectShippingCompany ( String ( $ event | | ' ' ) ) " > < el-option v-if = "form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" / > < / el-select > < / el-form-item >
< el-form-item label = "订舱代理" > < el-select :model-value = "form.data.bookingAgentId" filterable clearable : disabled = "!editable || !form.data.carrierCode || bookingAgentsLoading || !!bookingAgentsError" : placeholder = "form.data.carrierCode ? '请选择订舱代理' : '请先选择船公司'" :loading = "bookingAgentsLoading" no -data -text = " 当前船公司暂无可用订舱代理 " style = "width:100%" @ update : model -value = " selectBookingAgent ( String ( $ event | | ' ' ) ) " > < el-option v-if = "!editable && form.data.bookingAgentId && !bookingAgents.some((supplier) => supplier.supplierId === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in bookingAgents" :key="supplier.supplierId" :label="supplier.name" :value="supplier.supplierId" /></el-select><div v-if="bookingAgentsError" class="field-error"><ApiErrorMeta :details="bookingAgentsError" /><el-button link type="primary" :loading="bookingAgentsLoading" @click="form.data.carrierCode && loadBookingAgents(form.data.carrierCode)" > 重新加载 < / el -button > < / div > < / el-form-item >
< el-form-item label = "客户(选填)" > < el-select :model-value = "form.data.customerId" filterable clearable placeholder = "单客户可选择,多客户可留空" no -data -text = " 暂无可选客户 " :loading = "customersLoading" remote :remote-method = "loadCustomers" @ end -reached = " customerEndReached " style = "width:100%" @ update :model-value = "selectCustomer" > < el-option v-if = "form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select><LoadErrorState v-if="customersError" title="客户选项加载失败" :message="customersError.message" :loading="customersLoading" compact @retry="retryCustomers"><template #details><ApiErrorMeta :details="customersError" / > < / template > < / LoadErrorState > < / el-form-item >
< el-form-item label = "装柜仓库" prop = "warehouseId" > < el-select :model-value = "form.data.warehouseId" filterable placeholder = "请选择装柜仓库" :loading = "optionsLoading" style = "width:100%" @ update :model-value = "selectWarehouse" > < el-option v-for = "warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" / > < / el-select > < / el-form-item >
< el-form-item label = "物流渠道" prop = "channelCode" > < el-select :model-value = "form.data.channelCode" filterable : disabled = "!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading = "channelsLoading" : placeholder = "form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no -data -text = " 该仓库暂无有效物流渠道 " style = "width:100%" @ update : model -value = " selectChannel ( String ( $ event | | ' ' ) ) " > < el-option v-if = "form.data.channelCode && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint" > 该仓库暂无有效物流渠道 , 请更换仓库或联系管理员 。 < / div > < / el -form -item >
< / div >
< / section >
< section class = "booking-group" aria -label = " 航线船期 " >
< h3 class = "booking-group__title" > < span > 港口航线 < / span > < / h3 >
< div class = "booking-group__body booking-group__body--fields" >
< el-form-item label = "起运港" > < div class = "booking-port-fields" > < el-input v-model = "form.data.departurePortCode" placeholder="港代码" aria-label="起运港代码" /><el-input v-model="form.data.departurePortName" placeholder="港口名称" aria-label="起运港名称" / > < / div > < / el-form-item >
< el-form-item label = "目的港" > < div class = "booking-port-fields" > < el-input v-model = "form.data.destinationPortCode" placeholder="港代码" aria-label="目的港代码" /><el-input v-model="form.data.destinationPortName" placeholder="港口名称" aria-label="目的港名称" / > < / div > < / el-form-item >
< el-form-item label = "起运港" > < el-input v-model = "form.data.departurePortCode" placeholder="港代码" aria-label="起运港代码" / > < / el-form-item >
< el-form-item label = "目的港" > < el-input v-model = "form.data.destinationPortCode" placeholder="港代码" aria-label="目的港代码" / > < / el-form-item >
< el-form-item label = "预计开船" prop = "estimatedSailingAt" > < el-date-picker v-model = "form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" / > < / el-form-item >
< el-form-item label = "预计到港" prop = "estimatedArrivalAt" > < el-date-picker v-model = "form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" / > < / el-form-item >
< el-form-item class = "booking-field--wide" label = "交货地点" > < el-input v-model = "form.data.placeOfDelivery" placeholder="请输入最终交货地点" / > < / el-form-item >
< el-form-item label = "运费条款" > < el-input v-model = "form.data.freightTerm" maxlength="64" / > < / el-form-item >
< el-form-item label = "运输条款" > < el-input v-model = "form.data.serviceTerm" maxlength="64" / > < / el-form-item >
@@ -544,7 +528,6 @@ onMounted(() => { void load(); void loadOptions() })
< / div >
< / div >
< div class = "booking-group__body--fields" >
< el-form-item label = "包装单位" > < el-select v-model = "form.data.packageUnit" :disabled="!formWritable" placeholder="请选择包装单位"><el-option label="箱 CARTONS" value="CARTONS" /><el-option label="件 PCS" value="PCS" /><el-option label="托 PALLETS" value="PALLETS" / > < / el-select > < / el-form-item >
< el-form-item label = "唛头和号码" > < el-input v-model = "form.data.marksAndNos" :disabled="!formWritable" maxlength="1000" / > < / el-form-item >
< el-form-item class = "booking-field--wide" label = "品名及托运要求" > < el-input v-model = "form.data.cargoDescription" :disabled="!formWritable" type="textarea" :rows="2" maxlength="1000" placeholder="填写实际品名及免用箱等托运要求" / > < / el-form-item >
< / div >
@@ -572,10 +555,10 @@ onMounted(() => { void load(); void loadOptions() })
< el-form :model = "form.data" label -position = " top " :disabled = "!formWritable" class = "density-form booking-requirements" >
< section class = "booking-group" aria -label = " 柜型需求 " >
< h3 class = "booking-group__title" > < span > 柜型需求与备注 < / span > < / h3 > < div class = "booking-group__body" >
< h3 class = "booking-group__title" > < span > 柜型需求 < / span > < / h3 > < div class = "booking-group__body" >
< div class = "container-table-wrap" aria -label = " 柜型需求 " >
< div class = "workbench-evidence" > < table class = "container-table density-table" >
< thead > < tr > < th scope = "col" > 操作 < / th > < th scope = "col" > 柜型 < / th > < th scope = "col" > 箱种 < / th > < th scope = "col" > 数量 < / th > < th scope = "col" > 容量 < / th > < th scope = "col" > 限重 < / th > < th v-if = "detail?.status === 'RELEASED'" scope="col" > 剩余可关联 < / th > < / tr > < / thead >
< thead > < tr > < th scope = "col" > 操作 < / th > < th scope = "col" > 柜型 < / th > < th scope = "col" > 数量 < / th > < th v-if = "detail?.status === 'RELEASED'" scope="col" > 剩余可关联 < / th > < / tr > < / thead >
< tbody >
< tr v-for = "(requirement, index) in form.data.containerRequirements" :key="requirement.requirementId || index" >
< td class = "container-table__action" >
@@ -585,20 +568,24 @@ onMounted(() => { void load(); void loadOptions() })
< span v-else aria -hidden = " true " > — < / span >
< / td >
< td > < el-select :model-value = "requirement.containerType" :disabled = "!formWritable" placeholder = "请选择柜型" @ update : model -value = " selectContainerRequirementType ( requirement , String ( $ event | | ' ' ) ) " > < el-option v-for = "item in containerTypeOptions" :key="item.type" :label="item.type" :value="item.type" / > < / el-select > < / td >
< td > < el-select v-model = "requirement.containerKind" :disabled="!formWritable" placeholder="请选择箱种"><el-option v-for="item in containerKindOptions" :key="item.value" :label="item.label" :value="item.value" / > < / el-select > < / td >
< td > < el-input-number v-model = "requirement.quantity" :disabled="!formWritable" :min="1" :max="99" controls-position="right" placeholder="请输入柜量" / > < / td >
< td class = "container-table__specification" > < span > { { requirement . capacityCbm } } m³ < / span > < / td >
< td class = "container-table__specification" > < span > { { requirement . maxWeightKg } } kg < / span > < / td >
< td v-if = "detail?.status === 'RELEASED'" > < span > { { requirement . remainingQuantity ? ? requirement . quantity } } < / span > < / td >
< / tr >
< / tbody >
< / table > < / div >
< / div >
< div v-if = "formWritable" class="container-actions"><el-button class="add-container density-action" size="small" :disabled="form.data.containerRequirements.length >= 20" @click="addRequirement" > 新增柜型需求 < / el -button > < span > { { form . data . containerRequirements . length } } / 20 项 < / span > < / div >
< el-form-item label = "备注" > < el-input v-model = "form.data.remark" type="textarea" :rows="3" / > < / el-form-item >
< / div >
< / section >
< / el-form >
< el-collapse v-model = "operationSections" class="booking-operation-config" >
< el -collapse -item title = "作业配置" name = "operation" >
< el-form :model = "form.data" label -position = " top " :disabled = "!formWritable" class = "density-form booking-group__body--fields" >
< el-form-item label = "装柜仓库" prop = "warehouseId" > < el-select :model-value = "form.data.warehouseId" filterable placeholder = "请选择装柜仓库" :loading = "optionsLoading" style = "width:100%" @ update :model-value = "selectWarehouse" > < el-option v-for = "warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" / > < / el-select > < / el-form-item >
< el-form-item label = "物流渠道" prop = "channelCode" > < el-select :model-value = "form.data.channelCode" filterable : disabled = "!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading = "channelsLoading" : placeholder = "form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no -data -text = " 该仓库暂无有效物流渠道 " style = "width:100%" @ update : model -value = " selectChannel ( String ( $ event | | ' ' ) ) " > < el-option v-if = "form.data.channelCode && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint" > 该仓库暂无有效物流渠道 , 请更换仓库或联系管理员 。 < / div > < / el -form -item >
< / el-form >
< / el-collapse-item >
< / el-collapse >
< footer class = "booking-section__footer" >
< el-tooltip : content = "printDisabledReason || '按已保存预报货量打印托运单'" placement = "top" > < span > < el-button type = "primary" : disabled = "!!printDisabledReason || workflowBusy" @click ="openPrint('SHIPPING_INSTRUCTION')" > < Printer :size = "16" / > 打印托运单 < / el-button > < / span > < / el-tooltip >
< / footer >
@@ -635,7 +622,6 @@ onMounted(() => { void load(); void loadOptions() })
< el-descriptions-item label = "船名" > { { form . data . vesselName || '—' } } < / el-descriptions-item >
< el-descriptions-item label = "航次" > { { form . data . voyageNo || '—' } } < / el-descriptions-item >
< el-descriptions-item label = "预计开船" > { { form . data . estimatedSailingAt || '—' } } < / el-descriptions-item >
< el-descriptions-item label = "预计到港" > { { form . data . estimatedArrivalAt || '—' } } < / el-descriptions-item >
< / el-descriptions > < / section >
< / div >
< / AppDialog >
@@ -759,7 +745,6 @@ onMounted(() => { void load(); void loadOptions() })
. booking - group _ _title : : after { flex : 1 ; height : 1 px ; background : var ( -- workbench - border ) ; content : '' ; }
. booking - group _ _body -- fields { display : grid ; grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr ) ) ; column - gap : var ( -- admin - space - 5 ) ; row - gap : var ( -- admin - space - 3 ) ; }
. booking - field -- wide { grid - column : 1 / - 1 ; }
. booking - port - fields { display : grid ; grid - template - columns : minmax ( 76 px , .65 fr ) minmax ( 0 , 1 fr ) ; gap : var ( -- admin - space - 2 ) ; width : 100 % ; }
. booking - core _ _main > . booking - group { margin - top : var ( -- admin - space - 6 ) ; }
. booking - parties { padding - left : var ( -- admin - space - 7 ) ; border - left : 1 px solid var ( -- workbench - border ) ; }
. party - grid { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ; gap : var ( -- admin - space - 4 ) ; align - items : start ; }
@@ -799,17 +784,16 @@ onMounted(() => { void load(); void loadOptions() })
. cargo - metric _ _separator { color : var ( -- workbench - muted ) ; text - align : center ; }
. cargo - metric _ _unit { color : var ( -- workbench - muted ) ; font - size : var ( -- admin - font - caption ) ; }
. cargo - metric _ _inputs : deep ( . el - input _ _inner ) { text - align : left ; }
. booking - operation - config { margin - top : var ( -- admin - space - 4 ) ; }
. booking - requirements { margin - top : var ( -- admin - space - 6 ) ; }
. container - table - wrap { max - width : 100 % ; overflow - x : auto ; margin - bottom : var ( -- admin - space - 4 ) ; }
. container - table { width : 100 % ; min - width : 780 px ; table - layout : fixed ; }
. container - table { width : 100 % ; min - width : 420 px ; table - layout : fixed ; }
. container - table th : first - child , . container - table td : first - child { width : 80 px ; }
. container - table th : nth - child ( 2 ) , . container - table td : nth - child ( 2 ) { width : 180 px ; }
. container - table th : nth - child ( 3 ) , . container - table td : nth - child ( 3 ) { width : 90 px ; }
. container - table th : nth - child ( 4 ) , . container - table td : nth - child ( 4 ) { width : 110 px ; }
. container - table th : nth - child ( 5 ) , . container - table td : nth - child ( 5 ) , . container - table th : nth - child ( 6 ) , . container - table td : nth - child ( 6 ) { width : 100 px ; }
. container - table td : deep ( . el - select ) , . container - table td : deep ( . el - input ) { width : 100 % ; min - width : 0 ; }
. container - table _ _action { text - align : center ! important ; }
. container - table _ _specification { color : var ( -- workbench - muted ) ; white - space : nowrap ; }
. container - actions { display : flex ; align - items : center ; gap : var ( -- admin - space - 4 ) ; margin - bottom : var ( -- admin - space - 5 ) ; color : var ( -- workbench - muted ) ; font - size : var ( -- admin - font - caption ) ; }
. add - container { margin : 0 ; }
. booking - section _ _footer { display : flex ; align - items : center ; justify - content : flex - end ; flex - wrap : wrap ; gap : var ( -- admin - space - 3 ) ; margin - top : var ( -- admin - space - 6 ) ; padding - top : var ( -- admin - space - 5 ) ; border - top : 1 px dashed var ( -- workbench - border ) ; }