499 lines
39 KiB
Vue
499 lines
39 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminDensity.css'
|
||
import '@/styles/adminWorkbench.css'
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ArrowLeft, Printer, Save, Trash2 } from '@lucide/vue'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||
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'
|
||
import { SMART_LOADING_CHANNEL_GROUPS } from '@/modules/container-loading'
|
||
import {
|
||
createOceanBooking,
|
||
deleteOceanBookingDraft,
|
||
getOceanBooking,
|
||
listOceanBookingAgents,
|
||
releaseOceanBooking,
|
||
listWarehouseLogisticsChannels,
|
||
updateOceanBookingDraft,
|
||
type OceanLogisticsChannel,
|
||
} from '../api/platformOceanBookings'
|
||
import type {
|
||
OceanBookingDetail,
|
||
OceanBookingAgentOption,
|
||
OceanBookingCarrierCode,
|
||
OceanBookingDraftData,
|
||
OceanBookingDocument,
|
||
OceanBookingParty,
|
||
OceanBookingSaveRequest,
|
||
OceanBookingStatus,
|
||
} from '../types'
|
||
import { oceanBookingStatusLabel } from '../status'
|
||
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
|
||
import { shouldClearBookingAgent } from '../bookingAgents'
|
||
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
|
||
import OceanBookingDocumentsPanel from '../components/OceanBookingDocumentsPanel.vue'
|
||
|
||
const partyTypes = [
|
||
{ value: 'SHIPPER', label: '发货人' },
|
||
{ value: 'CONSIGNEE', label: '收货人' },
|
||
{ 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()
|
||
const workspace = useWorkspaceTabsStore()
|
||
const id = computed(() => String(route.params.bookingId || ''))
|
||
const isNew = computed(() => route.name === 'ocean-booking-create' || id.value === 'new')
|
||
const detail = ref<OceanBookingDetail | null>(null)
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const actionSaving = ref(false)
|
||
const error = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
|
||
const actionError = ref<ReturnType<typeof getApiErrorDetails> | null>(null)
|
||
const formRef = ref<FormInstance>()
|
||
const partners = ref<PlatformPartnerResource[]>([])
|
||
const bookingAgents = ref<OceanBookingAgentOption[]>([])
|
||
const bookingAgentsLoading = ref(false)
|
||
const bookingAgentsError = ref<ApiErrorDetails | null>(null)
|
||
const warehouses = ref<PlatformWarehouseResource[]>([])
|
||
const optionsLoading = ref(false)
|
||
const optionsError = ref('')
|
||
const channels = ref<OceanLogisticsChannel[]>([])
|
||
const channelsLoading = ref(false)
|
||
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))
|
||
return SMART_LOADING_CHANNEL_GROUPS.filter((group) => group.channelCodes.some((code) => activeCodes.has(code)))
|
||
})
|
||
const channelUnavailable = computed(() => Boolean(form.data.warehouseId) && !channelsLoading.value && !channelsError.value && channelGroups.value.length === 0)
|
||
|
||
function blankParties(): OceanBookingParty[] {
|
||
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', address: '' }))
|
||
}
|
||
function blankData(): OceanBookingDraftData {
|
||
return {
|
||
customerId: null, customerName: null, channelCode: '', warehouseId: '', parties: blankParties(),
|
||
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
|
||
shippingCompanyId: null, shippingCompanyName: null, carrierCode: null,
|
||
containerRequirements: [createBlankContainerRequirement()],
|
||
}
|
||
}
|
||
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')
|
||
const canSave = computed(() => editable.value && access.can(isNew.value ? 'platform:firstleg:ocean-booking:create' : 'platform:firstleg:ocean-booking:edit'))
|
||
const formWritable = computed(() => canSave.value)
|
||
const canDelete = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:delete'))
|
||
const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:release'))
|
||
const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:ocean-booking:list'))
|
||
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:edit'))
|
||
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : '')
|
||
const workflowBusy = computed(() => saving.value || actionSaving.value)
|
||
const releaseDialogOpen = ref(false)
|
||
const printOpen = ref(false)
|
||
|
||
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
|
||
return oceanBookingStatusLabel(status)
|
||
}
|
||
async function loadOptions() {
|
||
optionsLoading.value = true
|
||
optionsError.value = ''
|
||
try {
|
||
const [, partnerResult, warehouseResult] = await Promise.all([
|
||
loadCustomers(),
|
||
listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' }),
|
||
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'DOMESTIC', status: 'ACTIVE' }),
|
||
])
|
||
partners.value = partnerResult.records
|
||
if (form.data.shippingCompanyId && !form.data.carrierCode) {
|
||
const selected = partners.value.find((partner) => String(partner.id) === String(form.data.shippingCompanyId))
|
||
form.data.carrierCode = selected?.carrierCode ?? null
|
||
}
|
||
warehouses.value = warehouseResult.records.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC')
|
||
if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode)
|
||
} catch (cause) {
|
||
optionsError.value = getApiErrorDetails(cause, '船公司或仓库选项加载失败').message
|
||
} finally {
|
||
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 {
|
||
...form.data,
|
||
customerId,
|
||
customerName: customerId ? form.data.customerName : null,
|
||
channelCode: form.data.channelCode?.trim() || null,
|
||
warehouseId: form.data.warehouseId?.trim() || null,
|
||
billOfLadingNo: form.data.billOfLadingNo.trim(),
|
||
vesselName: form.data.vesselName.trim(),
|
||
voyageNo: form.data.voyageNo.trim(),
|
||
parties: form.data.parties.filter((party) => party.partyName.trim()).map((party) => ({ ...party, partyName: party.partyName.trim() })),
|
||
containerRequirements: form.data.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => ({ containerType, containerKind, quantity: Number(quantity), capacityCbm, maxWeightKg })),
|
||
}
|
||
}
|
||
function draftSignature() { return JSON.stringify(draftPayload()) }
|
||
const hasUnsavedDraftChanges = computed(() => Boolean(detail.value?.status === 'DRAFT' && savedDraftSignature.value && draftSignature() !== savedDraftSignature.value))
|
||
function selectWarehouse(value: unknown) {
|
||
const warehouseId = value == null || value === '' ? null : String(value)
|
||
form.data.warehouseId = warehouseId
|
||
form.data.channelCode = ''
|
||
selectedChannelName.value = ''
|
||
channels.value = []
|
||
channelsError.value = ''
|
||
if (warehouseId) void loadChannels(warehouseId)
|
||
}
|
||
async function loadChannels(warehouseId: string) {
|
||
const serial = ++channelRequestSerial
|
||
channelsLoading.value = true
|
||
channelsError.value = ''
|
||
try {
|
||
const result = await listWarehouseLogisticsChannels(warehouseId)
|
||
if (serial === channelRequestSerial && warehouseId === form.data.warehouseId) channels.value = result
|
||
} catch (cause) {
|
||
if (serial === channelRequestSerial) channelsError.value = getApiErrorDetails(cause, '物流渠道加载失败').message
|
||
} finally {
|
||
if (serial === channelRequestSerial) channelsLoading.value = false
|
||
}
|
||
}
|
||
function selectChannel(channelCode: string) {
|
||
const channel = SMART_LOADING_CHANNEL_GROUPS.find((item) => item.code === channelCode)
|
||
form.data.channelCode = channelCode
|
||
selectedChannelName.value = channel?.name || ''
|
||
}
|
||
function clearBookingAgent() {
|
||
form.data.bookingAgentId = null
|
||
form.data.bookingAgentName = null
|
||
}
|
||
async function loadBookingAgents(carrierCode: OceanBookingCarrierCode) {
|
||
const serial = ++bookingAgentRequestSerial
|
||
bookingAgentsLoading.value = true
|
||
bookingAgentsError.value = null
|
||
try {
|
||
const options = await listOceanBookingAgents(carrierCode)
|
||
if (serial !== bookingAgentRequestSerial || carrierCode !== form.data.carrierCode) return
|
||
bookingAgents.value = options
|
||
if (formWritable.value && shouldClearBookingAgent(form.data.bookingAgentId, options)) clearBookingAgent()
|
||
} catch (cause) {
|
||
if (serial !== bookingAgentRequestSerial) return
|
||
bookingAgents.value = []
|
||
bookingAgentsError.value = getApiErrorDetails(cause, '订舱代理候选加载失败')
|
||
} finally {
|
||
if (serial === bookingAgentRequestSerial) bookingAgentsLoading.value = false
|
||
}
|
||
}
|
||
function selectShippingCompany(partnerId: string) {
|
||
const partner = partners.value.find((item) => String(item.id) === partnerId)
|
||
form.data.shippingCompanyId = partnerId || null
|
||
form.data.shippingCompanyName = partner?.partnerName || null
|
||
form.data.carrierCode = partner?.carrierCode ?? null
|
||
bookingAgents.value = []
|
||
bookingAgentsError.value = null
|
||
bookingAgentRequestSerial += 1
|
||
if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode)
|
||
else clearBookingAgent()
|
||
}
|
||
function selectBookingAgent(supplierId: string) {
|
||
const supplier = bookingAgents.value.find((item) => item.supplierId === supplierId)
|
||
form.data.bookingAgentId = supplier?.supplierId ?? null
|
||
form.data.bookingAgentName = supplier?.name ?? null
|
||
}
|
||
function applyDetail(value: OceanBookingDetail) {
|
||
detail.value = value
|
||
form.expectedVersion = value.version
|
||
const selectedCarrier = partners.value.find((partner) => String(partner.id) === String(value.shippingCompanyId || ''))
|
||
form.data = {
|
||
customerId: value.customerId ?? null, customerName: value.customerName ?? null, channelCode: value.channelCode || '', warehouseId: value.warehouseId || '',
|
||
billOfLadingNo: value.billOfLadingNo || '', vesselName: value.vesselName || '', voyageNo: value.voyageNo || '',
|
||
estimatedSailingAt: value.estimatedSailingAt || null, estimatedArrivalAt: value.estimatedArrivalAt || null,
|
||
shippingCompanyId: value.shippingCompanyId, shippingCompanyName: value.shippingCompanyName,
|
||
carrierCode: value.carrierCode ?? selectedCarrier?.carrierCode ?? null,
|
||
bookingAgentId: value.bookingAgentId, bookingAgentName: value.bookingAgentName,
|
||
departurePortCode: value.departurePortCode, departurePortName: value.departurePortName,
|
||
destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName,
|
||
placeOfDelivery: value.placeOfDelivery,
|
||
remark: value.remark,
|
||
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
|
||
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg }) => ({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg })) : [createBlankContainerRequirement()],
|
||
}
|
||
selectedChannelName.value = value.channelName || ''
|
||
savedDraftSignature.value = draftSignature()
|
||
channels.value = []
|
||
if (form.data.warehouseId) void loadChannels(form.data.warehouseId)
|
||
if (form.data.carrierCode) void loadBookingAgents(form.data.carrierCode)
|
||
}
|
||
async function load() {
|
||
if (!id.value || isNew.value) return
|
||
loading.value = true
|
||
error.value = null
|
||
try { applyDetail(await getOceanBooking(id.value)) }
|
||
catch (cause) { error.value = getApiErrorDetails(cause) }
|
||
finally { loading.value = false }
|
||
}
|
||
async function validateDraftForm() {
|
||
const valid = await formRef.value?.validate().catch(() => false)
|
||
if (valid === false) { actionError.value = { message: '请检查表单中的必填项和格式' }; return false }
|
||
const requirementMessage = containerRequirementError(form.data.containerRequirements)
|
||
if (requirementMessage) { actionError.value = { message: requirementMessage }; return false }
|
||
return true
|
||
}
|
||
async function persistDraft(showSuccess: boolean) {
|
||
if (!canSave.value || !(await validateDraftForm())) return null
|
||
actionError.value = null
|
||
try {
|
||
const payload = draftPayload()
|
||
const saved = isNew.value
|
||
? await createOceanBooking({ expectedVersion: 0, data: payload })
|
||
: await updateOceanBookingDraft(id.value, { expectedVersion: detail.value?.version || 0, data: payload })
|
||
if (showSuccess) ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || '未生成订舱号'}` : '订舱草稿已保存')
|
||
if (isNew.value) {
|
||
workspace.removeByIds(['ocean-booking-create'])
|
||
await router.replace('/ocean-bookings')
|
||
return saved
|
||
}
|
||
applyDetail(saved)
|
||
return saved
|
||
} catch (cause) { actionError.value = getApiErrorDetails(cause); return null }
|
||
}
|
||
async function save() {
|
||
if (!canSave.value || workflowBusy.value) return
|
||
saving.value = true
|
||
try { await persistDraft(true) }
|
||
finally { saving.value = false }
|
||
}
|
||
async function openReleaseDialog() {
|
||
if (!canRelease.value || workflowBusy.value || !(await validateDraftForm())) return
|
||
const validationError = releaseValidationError()
|
||
if (validationError) { actionError.value = { message: validationError }; return }
|
||
releaseDialogOpen.value = true
|
||
}
|
||
function releaseValidationError() {
|
||
if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'
|
||
if (!form.data.departurePortCode?.trim() && !form.data.departurePortName?.trim()) return '放舱前请填写起运港代码或名称'
|
||
if (!form.data.destinationPortCode?.trim() && !form.data.destinationPortName?.trim()) return '放舱前请填写目的港代码或名称'
|
||
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.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 (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)
|
||
}
|
||
function updateDocuments(documents: OceanBookingDocument[], bookingVersion: number) {
|
||
if (!detail.value) return
|
||
detail.value.documents = documents
|
||
detail.value.version = bookingVersion
|
||
detail.value.oceanBooking.version = bookingVersion
|
||
form.expectedVersion = bookingVersion
|
||
}
|
||
async function submitRelease() {
|
||
if (!detail.value || workflowBusy.value) return
|
||
actionSaving.value = true
|
||
actionError.value = null
|
||
try {
|
||
if (!(await validateDraftForm())) { releaseDialogOpen.value = false; return }
|
||
const validationError = releaseValidationError()
|
||
if (validationError) { actionError.value = { message: validationError }; releaseDialogOpen.value = false; return }
|
||
let releaseVersion = detail.value.version
|
||
if (hasUnsavedDraftChanges.value) {
|
||
const saved = await persistDraft(false)
|
||
if (!saved) return
|
||
releaseVersion = saved.version
|
||
}
|
||
applyDetail(await releaseOceanBooking(id.value, { expectedVersion: releaseVersion }))
|
||
releaseDialogOpen.value = false
|
||
ElMessage.success('确认放舱成功')
|
||
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
|
||
finally { actionSaving.value = false }
|
||
}
|
||
function bookingDialogTitle(action: string) { const code = detail.value?.bookingNo?.trim(); return code ? `${action} · ${code}` : action }
|
||
async function removeDraft() {
|
||
if (!detail.value || !canDelete.value) return
|
||
actionError.value = null
|
||
try {
|
||
await ElMessageBox.confirm('确认删除该订舱草稿?删除后无法恢复。', bookingDialogTitle('删除草稿'), { customClass: 'admin-density-confirm', confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' })
|
||
actionSaving.value = true
|
||
await deleteOceanBookingDraft(id.value, detail.value.version)
|
||
ElMessage.success('订舱草稿已删除')
|
||
workspace.bumpCacheEpoch('ocean-bookings')
|
||
await router.replace('/ocean-bookings')
|
||
} catch (cause) {
|
||
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
|
||
} finally { actionSaving.value = false }
|
||
}
|
||
function addRequirement() {
|
||
if (form.data.containerRequirements.length < 20) form.data.containerRequirements.push(createBlankContainerRequirement())
|
||
}
|
||
function removeRequirement(index: number) { if (form.data.containerRequirements.length > 1) form.data.containerRequirements.splice(index, 1) }
|
||
|
||
onMounted(() => { void load(); void loadOptions() })
|
||
</script>
|
||
|
||
<template>
|
||
<AdminPage as="div" class="booking-detail admin-density admin-workbench">
|
||
<OperationErrorToast :details="actionError" />
|
||
<DetailPageLayout aria-label="订舱详情">
|
||
<template #header>
|
||
<div class="detail-heading">
|
||
<el-button link class="density-back" @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
|
||
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱草稿'" :description="detail ? `${detail.customerName || '未关联客户'} · ${detail.pol || '—'} → ${detail.pod || '—'}` : ''">
|
||
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
|
||
</PageToolbar>
|
||
</div>
|
||
</template>
|
||
|
||
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
<template v-else>
|
||
<section class="workbench-panel is-compact">
|
||
<div class="section-title">
|
||
<h2>订舱信息</h2>
|
||
<div class="actions">
|
||
<el-tooltip :content="canPrint ? '按当前已保存资料打印出口货物托运单或提单确认件' : '暂无订舱单据打印权限'" placement="top"><span><el-button v-if="detail" plain :disabled="!canPrint || workflowBusy" @click="printOpen = true"><Printer :size="16" />打印单据</el-button></span></el-tooltip>
|
||
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
|
||
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" :disabled="workflowBusy" @click="openReleaseDialog">放舱</el-button>
|
||
<el-button v-if="canSave" type="primary" :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
|
||
</div>
|
||
</div>
|
||
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
|
||
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!formWritable" class="booking-groups density-form">
|
||
<section class="booking-group" aria-label="基本信息">
|
||
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
|
||
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></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="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '保存后为草稿'" disabled /></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>
|
||
<el-form-item label="提单号" prop="billOfLadingNo"><el-input v-model="form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></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>
|
||
</div>
|
||
</section>
|
||
<section class="booking-group" aria-label="航线船期">
|
||
<h3>航线船期</h3><div class="booking-group__body booking-group__body--fields">
|
||
<el-form-item label="船名" prop="vesselName"><el-input v-model="form.data.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
|
||
<el-form-item label="航次" prop="voyageNo"><el-input v-model="form.data.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
|
||
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" placeholder="例如:CNNGB" /></el-form-item>
|
||
<el-form-item label="起运港"><el-input v-model="form.data.departurePortName" placeholder="例如:宁波港" /></el-form-item>
|
||
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" placeholder="例如:USLAX" /></el-form-item>
|
||
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" placeholder="例如:洛杉矶港" /></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="预计开船" 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>
|
||
</div>
|
||
</section>
|
||
<section class="booking-group" aria-label="柜型需求">
|
||
<h3>柜型需求</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>
|
||
<tbody>
|
||
<tr v-for="(requirement, index) in form.data.containerRequirements" :key="requirement.requirementId || index">
|
||
<td class="container-table__action">
|
||
<el-tooltip v-if="formWritable" :content="form.data.containerRequirements.length === 1 ? '至少保留一项柜型需求' : '删除该柜型需求'" placement="top">
|
||
<span><el-button data-tone="danger" class="density-action" plain size="small" type="danger" :aria-label="`删除柜型需求 ${index + 1}`" :disabled="form.data.containerRequirements.length === 1" @click="removeRequirement(index)">删除</el-button></span>
|
||
</el-tooltip>
|
||
<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>
|
||
</section>
|
||
|
||
<section class="workbench-panel is-compact">
|
||
<h2>订舱关系方</h2>
|
||
<div class="party-grid">
|
||
<div v-for="(party, index) in form.data.parties" :key="party.partyType" class="subsection">
|
||
<h3><span class="required-mark" aria-hidden="true">*</span>{{ partyTypes[index]?.label || party.partyType }}</h3>
|
||
<el-input v-model="party.partyName" :disabled="!formWritable" :aria-label="`${partyTypes[index]?.label || party.partyType}单位名称(必填)`" placeholder="单位名称(必填)" />
|
||
<el-input v-model="party.contactName" :disabled="!formWritable" placeholder="联系人" />
|
||
<el-input v-model="party.contactPhone" :disabled="!formWritable" placeholder="联系电话" />
|
||
<el-input v-model="party.address" :disabled="!formWritable" placeholder="地址" />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-no="detail.bookingNo" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
|
||
|
||
<div v-if="detail" class="workbench-panel is-compact"><CarrierScheduleQueryPanel dense :ocean-booking-id="detail.id" /></div>
|
||
|
||
<section v-if="detail" class="workbench-panel is-compact history-panel">
|
||
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
|
||
<div class="workbench-evidence"><table class="density-table booking-history-table"><thead><tr><th>时间</th><th>状态变化</th><th>操作人</th><th>备注</th></tr></thead><tbody><tr v-for="event in detail.histories" :key="event.id"><td>{{ event.occurredAt }}</td><td>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</td><td>{{ event.operatorName || '系统' }}</td><td>{{ event.reason || '—' }}</td></tr><tr v-if="!detail.histories.length"><td colspan="4">暂无状态历史</td></tr></tbody></table></div>
|
||
</section>
|
||
</template>
|
||
</DetailPageLayout>
|
||
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" />
|
||
|
||
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="releaseDialogOpen" :title="bookingDialogTitle('确认放舱')" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
|
||
<div class="density-dialog-sections">
|
||
<el-alert title="放舱后资料将同步到关联货柜,不能修改、不能删除,请确认资料无误。" type="warning" :closable="false" show-icon />
|
||
<section class="density-dialog-section" data-tone="summary"><el-descriptions class="release-summary density-descriptions" :column="2" border>
|
||
<el-descriptions-item label="提单号">{{ form.data.billOfLadingNo || '—' }}</el-descriptions-item>
|
||
<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>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.channel-code{display:block;margin-left:var(--admin-space-3);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.field-error,.field-hint{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-3);margin-top:5px;color:var(--workbench-muted);font-size:var(--admin-font-caption);line-height:20px}.field-error{color:var(--el-color-danger)}
|
||
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;gap:var(--admin-space-5);padding:var(--admin-space-3) var(--admin-space-5);background:var(--admin-surface);border:1px solid var(--workbench-border);align-items:center}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-body)}.heading-status{display:flex;align-items:center;gap:var(--admin-space-3)}.surface-panel h2{margin:0;font-size:var(--admin-font-section)}.section-title{display:flex;justify-content:space-between;gap:var(--admin-space-5);align-items:flex-start;margin-bottom:var(--admin-space-5)}.actions{display:flex;flex-wrap:wrap;gap:var(--admin-space-3)}.booking-groups{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:var(--admin-space-5)}.booking-group{min-width:0;overflow:hidden;background:var(--admin-surface);border:0}.booking-group:last-child{grid-column:1/-1}.booking-group>h3{margin:0;color:#1f2937;font-size:var(--admin-font-section);padding:var(--admin-space-3) 0;border-bottom:1px solid var(--workbench-border)}.booking-group__body{padding:var(--admin-space-3) 0 0}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:var(--admin-space-4);row-gap:var(--admin-space-3)}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4);margin-top:var(--admin-space-5)}.subsection{display:grid;gap:var(--admin-space-4);border:0;padding:0}.subsection h3{margin:0;font-size:var(--admin-font-section)}.required-mark{margin-right:var(--admin-space-1);color:var(--el-color-danger)}.container-table-wrap{max-width:100%;overflow-x:auto;margin-bottom:var(--admin-space-4)}.container-table{width:100%;min-width:780px;table-layout:fixed}.container-table th:first-child,.container-table td:first-child{width:80px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:180px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:90px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:110px}.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:100px}.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}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column;padding:var(--admin-space-3) var(--admin-space-5);background:var(--admin-surface);border:1px solid var(--workbench-border);align-items:center}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.booking-group__body{padding:var(--admin-space-3) 0 0}.actions :deep(.el-button){min-height:44px}}.booking-history-table th:nth-child(1){width:180px}.booking-history-table th:nth-child(2){width:220px}.booking-history-table th:nth-child(3){width:140px}.booking-page-toolbar :deep(h1){font-size:var(--admin-font-title)}
|
||
</style>
|