fix(admin): integrate workbench contracts and dropship review states
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ContainerDetailView from './ContainerDetailView.vue'
|
||||
import { getContainer, getContainerOverview } from '../api/platformContainerLoading'
|
||||
import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages'
|
||||
|
||||
const canAccess = vi.hoisted(() => vi.fn<(code: string) => boolean>(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { containerId: '9001' }, query: {} }), useRouter: () => ({ push: vi.fn() }) }))
|
||||
vi.mock('@/modules/loading-lists', () => ({ ContainerFeesPanel: { template: '<div />' }, LoadingListDetailDrawer: { template: '<div />' }, getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }))
|
||||
vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() }))
|
||||
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div />' } }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), cancelContainerException: vi.fn(), completeContainerClearance: vi.fn(), downloadContainerClearance: vi.fn(), downloadContainerDocument: vi.fn(), getContainerClearance: vi.fn(), markContainerException: vi.fn(), uploadContainerClearance: vi.fn(), uploadContainerDocument: vi.fn(), updateContainerPickupCode: vi.fn() }))
|
||||
vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn() }))
|
||||
|
||||
const base = { id: '9001', cabinetNo: 'CB-1', containerNo: 'MSCU-1', planNo: 'LP-1', planStatus: 'COMPLETED', warehouseName: '义乌仓', destinationWarehouseCode: 'LAX01', destinationWarehouseName: '洛杉矶接柜仓', workbenchStage: 'UNLOADED', statusSummary: { terminalDrayageStatus: 'UNLOADED' }, allowedActions: ['RECORD_EMPTY_RETURN'], terminalDrayage: { containerBookingId: '9001', status: 'UNLOADED', version: 3, fleetSupplierId: '501', fleetSupplierName: '港运车队', pickupAppointmentAt: '2026-09-07T08:00:00', actualPickupAt: '2026-09-07T09:00:00', estimatedEmptyReturnAt: '2026-09-10T10:00:00', actualEmptyReturnAt: null, unloadingCompletedAt: '2026-09-07T10:00:00', emptyReturnWarning: { status: 'PENDING', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['RECORD_EMPTY_RETURN'], updatedAt: '2026-09-09T10:00:00' }, actualFacts: { unloadingCompletedAt: '2026-09-07T10:00:00' } }
|
||||
const overview = { container: { id: '9001', version: 1 }, loadingPlanId: null, documents: [], exception: { active: false }, timeline: [] }
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
async function mountDetail() {
|
||||
const wrapper = mount(ContainerDetailView, { props: { workbench: 'OVERSEAS' }, attachTo: document.body, global: { plugins: [createPinia(), ElementPlus], stubs: { ContainerOverviewPanel: true, ContainerCustomsPanel: true, ContainerLastLegFeesPanel: true } } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('ContainerDetailView empty return', () => {
|
||||
beforeEach(() => {
|
||||
canAccess.mockReset().mockReturnValue(true)
|
||||
vi.mocked(getContainer).mockReset().mockResolvedValue(base as never)
|
||||
vi.mocked(getContainerOverview).mockReset().mockResolvedValue(overview as never)
|
||||
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never)
|
||||
})
|
||||
|
||||
it('uses the overseas stage, submits one versioned command and refreshes to returned state', async () => {
|
||||
vi.mocked(getContainer).mockResolvedValueOnce(base as never).mockResolvedValue({ ...base, workbenchStage: 'EMPTY_RETURNED', statusSummary: { terminalDrayageStatus: 'EMPTY_RETURNED' }, allowedActions: [], terminalDrayage: { ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } } as never)
|
||||
const wrapper = await mountDetail()
|
||||
expect(wrapper.text()).toContain('待还柜')
|
||||
expect(wrapper.text()).not.toContain('装柜完成')
|
||||
await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click')
|
||||
await flushPromises()
|
||||
const confirm = [...document.body.querySelectorAll('button')].filter((item) => item.textContent?.trim() === '确认还柜').at(-1) as HTMLButtonElement
|
||||
confirm.click()
|
||||
await flushPromises()
|
||||
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledTimes(1)
|
||||
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', expect.objectContaining({ expectedVersion: 3, actualAt: expect.any(String), remark: undefined }))
|
||||
expect(getContainer).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('已还柜')
|
||||
expect(wrapper.find('.detail-section__heading').findAll('button').some((item) => item.text() === '确认还柜')).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['缺权限', false, ['RECORD_EMPTY_RETURN'], ['RECORD_EMPTY_RETURN'], 'UNLOADED'],
|
||||
['缺服务端动作', true, [], [], 'UNLOADED'],
|
||||
['已还柜', true, [], [], 'EMPTY_RETURNED'],
|
||||
])('%s时不提供确认还柜', async (_name, allowed, rowActions, drayageActions, status) => {
|
||||
canAccess.mockImplementation((code) => code !== 'platform:firstleg:terminal-drayage:empty-return:confirm' || allowed)
|
||||
vi.mocked(getContainer).mockResolvedValue({ ...base, workbenchStage: status, statusSummary: { terminalDrayageStatus: status }, allowedActions: rowActions, terminalDrayage: { ...base.terminalDrayage, status, allowedActions: drayageActions } } as never)
|
||||
const wrapper = await mountDetail()
|
||||
expect(wrapper.findAll('button').some((item) => item.text() === '确认还柜')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -4,15 +4,18 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
|
||||
const overviewSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerOverviewPanel.vue'), 'utf8')
|
||||
const routeSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/routes.ts'), 'utf8')
|
||||
const loadingRouteSource = readFileSync(resolve(process.cwd(), 'src/modules/loading-lists/routes.ts'), 'utf8')
|
||||
const navigationSource = readFileSync(resolve(process.cwd(), 'src/core/navigation/adminNavigation.ts'), 'utf8')
|
||||
const lastLegFeesSource = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/components/ContainerLastLegFeesPanel.vue'), 'utf8')
|
||||
|
||||
describe('ContainerDetailView customs integration', () => {
|
||||
it('renders the requested detail sections in the confirmed business order', () => {
|
||||
const headings = [
|
||||
'container-information-heading">货柜信息',
|
||||
'container-voyage-heading">船名航次信息',
|
||||
'container-customs-heading">报关清关信息',
|
||||
'container-receiving-heading">签收信息',
|
||||
'container-fees-heading">费用明细',
|
||||
'container-customs-heading">{{ isDomestic',
|
||||
'container-receiving-heading">装柜作业与国内出仓',
|
||||
]
|
||||
let previous = -1
|
||||
for (const heading of headings) {
|
||||
@@ -26,9 +29,79 @@ describe('ContainerDetailView customs integration', () => {
|
||||
expect(source).toContain('<CarrierScheduleQueryPanel')
|
||||
expect(source).toContain(':sections="[\'documents\']"')
|
||||
expect(source).toContain(':sections="[\'pda\']"')
|
||||
expect(source).toContain('<ContainerFeesPanel')
|
||||
expect(source).toContain('<LoadingListDetailDrawer')
|
||||
expect(source).toContain('getConfirmedLoadingList')
|
||||
expect(source).toContain('getPreloadPlan')
|
||||
expect(source).toContain('label="装柜清单"')
|
||||
expect(source).toContain('打印装柜清单')
|
||||
expect(source).toContain(':sections="[\'pda\']"')
|
||||
})
|
||||
|
||||
it('keeps the confirmed loading list inside the domestic container detail only', () => {
|
||||
expect(routeSource).toContain("name: 'domestic-container-detail'")
|
||||
expect(routeSource).toContain("name: 'overseas-container-detail'")
|
||||
expect(loadingRouteSource).not.toContain("name: 'loading-lists'")
|
||||
expect(navigationSource).not.toContain("title: '装柜清单'")
|
||||
expect(source).toContain('<el-tabs v-model="activeTab"')
|
||||
expect(source).toContain("v-if=\"isDomestic && activeTab === 'loading-list'\"")
|
||||
expect(source).toContain("loadingDetail.value = await getConfirmedLoadingList(aggregate.loadingPlanId, resource.warehouseId)")
|
||||
expect(source).toContain("getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)")
|
||||
expect(source).toContain(':can-edit="false"')
|
||||
})
|
||||
|
||||
it('keeps the loaded overview mounted while switching fee tabs', () => {
|
||||
expect(source).toContain("v-show=\"activeTab === 'overview'\" class=\"detail-section surface-panel\" aria-labelledby=\"container-voyage-heading\"")
|
||||
expect(source).toContain("v-if=\"isOverseas\" v-show=\"activeTab === 'overview'\"")
|
||||
expect(source).not.toContain("v-if=\"activeTab === 'overview'\" class=\"detail-section surface-panel\"")
|
||||
})
|
||||
|
||||
it('exposes empty return directly in overseas detail only when permission and server action both allow it', () => {
|
||||
expect(source).toContain("access.can('platform:firstleg:terminal-drayage:empty-return:confirm')")
|
||||
expect(source).toContain("hasAction('RECORD_EMPTY_RETURN')")
|
||||
expect(source).toContain("terminalDrayage?.allowedActions.includes('RECORD_EMPTY_RETURN')")
|
||||
expect(source).toContain('v-if="canRecordEmptyReturn" type="primary"')
|
||||
expect(source).toContain('>确认还柜</el-button>')
|
||||
expect(source).toContain('expectedVersion: current.version')
|
||||
expect(source).toContain('actualAt: emptyReturnAt.value')
|
||||
expect(source).toContain('remark: emptyReturnReason.value.trim() || undefined')
|
||||
expect(source).toContain('await load()')
|
||||
})
|
||||
|
||||
it('separates domestic first-leg fees and connects the overseas fee lifecycle lazily', () => {
|
||||
expect(source).toContain('label="国内段费用(头程)" name="first-leg-fees"')
|
||||
expect(source).toContain('label="目的港及海外段费用(尾程)" name="last-leg-fees"')
|
||||
expect(source).toContain('v-if="firstLegFeesOpened"')
|
||||
expect(source).toContain('v-show="activeTab === \'first-leg-fees\'"')
|
||||
expect(source).toContain('title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id"')
|
||||
expect(source).toContain(':readonly="isOverseas"')
|
||||
expect(source).toContain('v-if="lastLegFeesOpened"')
|
||||
expect(source).toContain('title="目的港及海外段费用(尾程)"')
|
||||
expect(source).toContain(':readonly="isDomestic"')
|
||||
expect(source).toContain("if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)")
|
||||
expect(source).toContain("const needsLoadingDetail = tabName === 'first-leg-fees' || (isDomestic.value && tabName === 'loading-list')")
|
||||
expect(lastLegFeesSource.indexOf('label="操作"')).toBeLessThan(lastLegFeesSource.indexOf('label="客户"'))
|
||||
expect(lastLegFeesSource).toContain('show-overflow-tooltip')
|
||||
expect(lastLegFeesSource).toContain('尾程计费加载失败')
|
||||
expect(lastLegFeesSource).toContain('empty-text="当前货柜暂无尾程费用"')
|
||||
expect(lastLegFeesSource).toContain('v-loading="loading"')
|
||||
expect(lastLegFeesSource).toContain('props.readonly ? Promise.resolve([]) : listContainerFeeSupplierOptions(null)')
|
||||
expect(lastLegFeesSource).toContain("sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER'")
|
||||
expect(lastLegFeesSource).toContain("form.settlementMode === 'DIRECT_PAID'")
|
||||
expect(lastLegFeesSource).toContain('uploadContainerFeePaymentVoucher(form.customerId, file.raw)')
|
||||
expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:edit')")
|
||||
expect(lastLegFeesSource).toContain("access.can('platform:finance:fee-source:review')")
|
||||
expect(source).toContain("if (!overview.value?.loadingPlanId) return '当前货柜尚未关联装柜计划,暂无头程计费归属。'")
|
||||
expect(source).toContain(':description="firstLegFeesEmptyReason"')
|
||||
expect(source).toContain("const needsLoadingDetail = tabName === 'first-leg-fees' || (isDomestic.value && tabName === 'loading-list')")
|
||||
})
|
||||
|
||||
it('uses domestic and overseas route-specific primary status and summary labels', () => {
|
||||
expect(source).toContain("const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)")
|
||||
expect(source).toContain("UNLOADED: '待还柜'")
|
||||
expect(source).toContain("EMPTY_RETURNED: '已还柜'")
|
||||
expect(source).toContain("{{ isDomestic ? '装柜清单号' : '当前海外阶段' }}")
|
||||
expect(source).toContain("{{ isDomestic ? '装柜仓库' : '目的仓 / 接柜仓' }}")
|
||||
expect(source).toContain('emptyReturnWarningText')
|
||||
})
|
||||
|
||||
it('mounts the new container customs panel only after loading completes', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { CarrierScheduleQueryPanel } from '@/modules/port-schedules'
|
||||
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, type LoadingDetail } from '@/modules/loading-lists'
|
||||
import { ContainerFeesPanel, getConfirmedLoadingList, getPreloadPlan, LoadingListDetailDrawer, type LoadingDetail } from '@/modules/loading-lists'
|
||||
import {
|
||||
confirmContainerOutboundDispatch,
|
||||
getContainerOutboundDispatch,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type ContainerOutboundGateItem,
|
||||
} from '@/modules/outbound-dispatch'
|
||||
import ContainerCustomsPanel from '../components/ContainerCustomsPanel.vue'
|
||||
import ContainerLastLegFeesPanel from '../components/ContainerLastLegFeesPanel.vue'
|
||||
import ContainerOverviewPanel from '../components/ContainerOverviewPanel.vue'
|
||||
import {
|
||||
cancelContainerException,
|
||||
@@ -31,21 +32,60 @@ import {
|
||||
markContainerException,
|
||||
uploadContainerClearance,
|
||||
uploadContainerDocument,
|
||||
updateContainerPickupCode,
|
||||
} from '../api/platformContainerLoading'
|
||||
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus } from '../types'
|
||||
import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages'
|
||||
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
||||
|
||||
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const containerId = computed(() => String(route.params.containerId || ''))
|
||||
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
||||
const canManageCustoms = computed(() => access.can('platform:firstleg:customs:manage'))
|
||||
const isDomestic = computed(() => props.workbench === 'DOMESTIC')
|
||||
const isOverseas = computed(() => props.workbench === 'OVERSEAS')
|
||||
const activeTab = ref(route.query.tab === 'loading-list' ? 'loading-list' : 'overview')
|
||||
const firstLegFeesOpened = ref(false)
|
||||
const lastLegFeesOpened = ref(false)
|
||||
const feeCustomers = computed(() => {
|
||||
const customers = new Map<string, string | null>()
|
||||
for (const item of loadingDetail.value?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
|
||||
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
|
||||
})
|
||||
const feeBusinessReferences = computed(() => (loadingDetail.value?.items || []).map((item) => ({
|
||||
accountId: item.accountId, customerName: item.customerName, orderId: item.orderId, orderNo: item.orderNo,
|
||||
inboundId: item.inboundId, inboundCode: item.inboundCode,
|
||||
})))
|
||||
const firstLegFeesEmptyReason = computed(() => {
|
||||
if (!overview.value?.loadingPlanId) return '当前货柜尚未关联装柜计划,暂无头程计费归属。'
|
||||
if (!detail.value?.warehouseId) return '当前货柜尚未关联装柜仓库,无法加载头程计费归属。'
|
||||
if (detail.value.planStatus === 'COMPLETED' && !canViewLoadingList.value) return '当前账号无权读取已确认装柜清单,无法查看头程计费。'
|
||||
if (detail.value.planStatus !== 'COMPLETED' && !canViewPreload.value) return '当前账号无权读取预装计划,无法查看头程计费。'
|
||||
return '当前柜次暂无可用费用归属明细。'
|
||||
})
|
||||
async function rememberWorkbenchTab(tabName: string | number) {
|
||||
if (tabName === 'first-leg-fees') firstLegFeesOpened.value = true
|
||||
if (tabName === 'last-leg-fees') lastLegFeesOpened.value = true
|
||||
const needsLoadingDetail = tabName === 'first-leg-fees' || (isDomestic.value && tabName === 'loading-list')
|
||||
if (needsLoadingDetail && detail.value && overview.value) {
|
||||
await loadLoadingDetail(detail.value, overview.value)
|
||||
}
|
||||
}
|
||||
const workbenchDetail = ref<ContainerWorkbenchRow | null>(null)
|
||||
const hasAction = (action: ContainerWorkbenchAction) => Boolean(workbenchDetail.value?.allowedActions?.includes(action))
|
||||
const canEdit = computed(() => isDomestic.value && access.can('platform:firstleg:container:edit') && hasAction('EDIT_CONTAINER'))
|
||||
const canManageCustoms = computed(() => isDomestic.value && access.can('platform:firstleg:customs:manage') && hasAction('MANAGE_CUSTOMS'))
|
||||
const canManageDestinationDocuments = computed(() => isOverseas.value && access.can('platform:firstleg:terminal-drayage:document:edit') && hasAction('MANAGE_DESTINATION_DOCUMENTS'))
|
||||
const canRecordEmptyReturn = computed(() => isOverseas.value
|
||||
&& access.can('platform:firstleg:terminal-drayage:empty-return:confirm')
|
||||
&& hasAction('RECORD_EMPTY_RETURN')
|
||||
&& Boolean(workbenchDetail.value?.terminalDrayage?.allowedActions.includes('RECORD_EMPTY_RETURN')))
|
||||
const canListCustoms = computed(() => access.can('platform:firstleg:customs:list'))
|
||||
const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:view'))
|
||||
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
|
||||
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const canViewContainerFees = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
||||
const canLinkOceanBooking = computed(() => Boolean(canEdit.value
|
||||
&& detail.value?.sourceType === 'SIMULATION'
|
||||
&& !detail.value.oceanBookingId
|
||||
@@ -56,7 +96,7 @@ const overview = ref<ContainerOverview | null>(null)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const overviewError = ref<ApiErrorDetails | null>(null)
|
||||
const documentOpen = ref(false)
|
||||
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER'
|
||||
type UploadDocumentType = 'DELIVERY_ORDER'
|
||||
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
|
||||
const documentFile = ref<File | null>(null)
|
||||
const documentUploadKey = ref(0)
|
||||
@@ -64,6 +104,7 @@ const documentSaving = ref(false)
|
||||
const documentDownloading = ref(false)
|
||||
const documentError = ref<ApiErrorDetails | null>(null)
|
||||
const documentRefreshWarning = ref<ApiErrorDetails | null>(null)
|
||||
const pickupCodeSaving = ref(false)
|
||||
const clearanceOpen = ref(false)
|
||||
const clearanceDocument = ref<BusinessDocument | null>(null)
|
||||
const clearanceFile = ref<File | null>(null)
|
||||
@@ -81,11 +122,37 @@ const outboundError = ref<ApiErrorDetails | null>(null)
|
||||
const showPassedGates = ref(false)
|
||||
const carrierRefreshKey = ref(0)
|
||||
const loadingDetail = ref<LoadingDetail | null>(null)
|
||||
const loadingDetailLoading = ref(false)
|
||||
const loadingDetailError = ref<ApiErrorDetails | null>(null)
|
||||
const emptyReturnOpen = ref(false)
|
||||
const emptyReturnAt = ref('')
|
||||
const emptyReturnReason = ref('')
|
||||
const emptyReturnSaving = ref(false)
|
||||
const emptyReturnError = ref<ApiErrorDetails | null>(null)
|
||||
|
||||
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
||||
const documentTitle = computed(() => ({ DELIVERY_ORDER: 'DO 资料', PICKUP_ORDER: 'PU 资料' })[documentType.value])
|
||||
const documentTitle = computed(() => 'DO 资料')
|
||||
const title = computed(() => detail.value?.containerNo || detail.value?.cabinetNo || '货柜详情')
|
||||
const overseasStageText = computed(() => {
|
||||
const status = workbenchDetail.value?.statusSummary?.terminalDrayageStatus
|
||||
|| workbenchDetail.value?.terminalDrayage?.status
|
||||
|| workbenchDetail.value?.workbenchStage
|
||||
return ({
|
||||
WAIT_LOADING: '待装柜', LOADING: '装柜中', WAIT_CUSTOMS: '待出口报关', WAIT_OUTBOUND: '待国内出仓',
|
||||
DEPARTED_WAREHOUSE: '已从国内仓出仓', IN_TRANSIT: '海运在途', ARRIVED_DESTINATION_PORT: '已到目的港',
|
||||
WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_READY: '可安排提柜', PICKUP_SCHEDULED: '已预约提柜',
|
||||
PICKED_UP: '已提柜', UNLOADED: '待还柜', EMPTY_RETURNED: '已还柜',
|
||||
} as Record<string, string>)[status || ''] || '海外履约中'
|
||||
})
|
||||
const primaryStatusText = computed(() => isDomestic.value ? statusText(detail.value?.planStatus) : overseasStageText.value)
|
||||
const overseasDestinationText = computed(() => [detail.value?.destinationWarehouseCode, detail.value?.destinationWarehouseName].filter(Boolean).join(' / ') || '—')
|
||||
const emptyReturnWarningText = computed(() => {
|
||||
const warning = workbenchDetail.value?.terminalDrayage?.emptyReturnWarning
|
||||
if (!warning || warning.status === 'NOT_APPLICABLE') return '当前暂无还空预警'
|
||||
if (warning.status === 'RESOLVED') return `已完成还柜${warning.deadlineAt ? ` · 截止 ${formatTime(warning.deadlineAt)}` : ''}`
|
||||
const overdue = warning.status === 'OVERDUE' ? (warning.overdueDays > 0 ? `已超时 ${warning.overdueDays} 天` : `已超时 ${warning.overdueHours} 小时`) : '待还柜'
|
||||
return `${overdue}${warning.deadlineAt ? ` · 截止 ${formatTime(warning.deadlineAt)}` : ''}`
|
||||
})
|
||||
const exceptionTitle = computed(() => overview.value?.exception.active ? '取消货柜异常' : '标记货柜异常')
|
||||
type AggregatedGateRow = ContainerOutboundGateItem & { key: string; inboundCodes: string[] }
|
||||
const referenceOnlyGateCodes = new Set(['TRUCK_DEPARTED', 'SAILING_SCHEDULE'])
|
||||
@@ -110,27 +177,14 @@ const outboundGateSummary = computed(() => ({
|
||||
blocked: outboundGateRows.value.filter((row) => !row.passed).length,
|
||||
}))
|
||||
const canConfirmDeparture = computed(() => canConfirmOutboundDispatch.value
|
||||
&& hasAction('CONFIRM_OUTBOUND')
|
||||
&& outboundDetail.value?.aggregate.dispatchStatus === 'READY_FOR_DISPATCH'
|
||||
&& outboundGateSummary.value.blocked === 0)
|
||||
const customsDepartureState = computed<'DEPARTED' | 'WAITING_OTHER_GATES'>(() =>
|
||||
outboundDetail.value?.aggregate.dispatchStatus === 'WAITING_FOR_LOADING' ? 'DEPARTED' : 'WAITING_OTHER_GATES')
|
||||
const feeCustomers = computed(() => {
|
||||
const customers = new Map<string, string | null>()
|
||||
for (const item of loadingDetail.value?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
|
||||
return [...customers].map(([accountId, customerName]) => ({ accountId, customerName }))
|
||||
})
|
||||
const feeBusinessReferences = computed(() => (loadingDetail.value?.items || []).map((item) => ({
|
||||
accountId: item.accountId,
|
||||
customerName: item.customerName,
|
||||
orderId: item.orderId,
|
||||
orderNo: item.orderNo,
|
||||
inboundId: item.inboundId,
|
||||
inboundCode: item.inboundCode,
|
||||
})))
|
||||
|
||||
function openOceanBookingLink() {
|
||||
if (!detail.value || !canLinkOceanBooking.value) return
|
||||
void router.push({ name: 'container-loading', query: { linkContainerId: detail.value.id } })
|
||||
void router.push({ name: 'domestic-container-workbench', query: { linkContainerId: detail.value.id } })
|
||||
}
|
||||
|
||||
function toOverviewDocument(document: BusinessDocument, documentType: string, fallback?: ContainerOverviewDocument | null): ContainerOverviewDocument {
|
||||
@@ -169,15 +223,47 @@ async function refreshOverviewDocument(documentType: 'DESTINATION_CLEARANCE') {
|
||||
|
||||
function statusText(status?: ContainerPlanStatus | null) { return status ? ({ WAIT_LOADING: '待装柜', EDITING: '预装柜中', LOADING: '拣货中', COMPLETED: '装柜完成' })[status] : '—' }
|
||||
function formatTime(value?: string | null) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
||||
function localDateTimeValue() {
|
||||
const now = new Date(Date.now() - new Date().getTimezoneOffset() * 60_000)
|
||||
return now.toISOString().slice(0, 19)
|
||||
}
|
||||
function openEmptyReturn() {
|
||||
if (!canRecordEmptyReturn.value || emptyReturnSaving.value) return
|
||||
emptyReturnAt.value = localDateTimeValue()
|
||||
emptyReturnReason.value = ''
|
||||
emptyReturnError.value = null
|
||||
emptyReturnOpen.value = true
|
||||
}
|
||||
async function confirmEmptyReturn() {
|
||||
const current = workbenchDetail.value?.terminalDrayage
|
||||
if (!current || !canRecordEmptyReturn.value || !emptyReturnAt.value || emptyReturnSaving.value) return
|
||||
emptyReturnSaving.value = true
|
||||
emptyReturnError.value = null
|
||||
try {
|
||||
await recordTerminalDrayageEmptyReturn(containerId.value, {
|
||||
expectedVersion: current.version,
|
||||
actualAt: emptyReturnAt.value,
|
||||
remark: emptyReturnReason.value.trim() || undefined,
|
||||
})
|
||||
emptyReturnOpen.value = false
|
||||
ElMessage.success('还柜已确认')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '确认还柜失败')
|
||||
if (details.code === 'A0409') await load()
|
||||
emptyReturnError.value = details
|
||||
} finally { emptyReturnSaving.value = false }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!containerId.value) return
|
||||
loading.value = true; error.value = null; overviewError.value = null
|
||||
try {
|
||||
const [resource, aggregate] = await Promise.all([getContainer(containerId.value), getContainerOverview(containerId.value)])
|
||||
detail.value = resource; overview.value = aggregate
|
||||
await loadLoadingDetail(resource, aggregate)
|
||||
if (resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
|
||||
const [resource, aggregate] = await Promise.all([getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)])
|
||||
workbenchDetail.value = resource; detail.value = resource; overview.value = aggregate
|
||||
if (activeTab.value === 'loading-list' && resource.planStatus !== 'COMPLETED') activeTab.value = 'overview'
|
||||
if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)
|
||||
if (isDomestic.value && resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -191,8 +277,10 @@ async function loadOutboundDispatch() {
|
||||
}
|
||||
|
||||
async function loadLoadingDetail(resource: ContainerBookingResource, aggregate: ContainerOverview) {
|
||||
if (loadingDetail.value || loadingDetailLoading.value) return
|
||||
loadingDetail.value = null; loadingDetailError.value = null
|
||||
if (!canViewContainerFees.value || !aggregate.loadingPlanId || !resource.warehouseId) return
|
||||
if (!aggregate.loadingPlanId || !resource.warehouseId) return
|
||||
loadingDetailLoading.value = true
|
||||
try {
|
||||
if (resource.planStatus === 'COMPLETED') {
|
||||
if (!canViewLoadingList.value) return
|
||||
@@ -202,7 +290,9 @@ async function loadLoadingDetail(resource: ContainerBookingResource, aggregate:
|
||||
if (!canViewPreload.value) return
|
||||
loadingDetail.value = await getPreloadPlan(aggregate.loadingPlanId, resource.warehouseId)
|
||||
} catch (cause) {
|
||||
loadingDetailError.value = getApiErrorDetails(cause, '费用归属信息加载失败')
|
||||
loadingDetailError.value = getApiErrorDetails(cause, '装柜清单加载失败')
|
||||
} finally {
|
||||
loadingDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
async function refreshAfterCustoms() {
|
||||
@@ -242,8 +332,8 @@ function truckReferenceText(status?: string | null) {
|
||||
}
|
||||
|
||||
function openDocument(type: string) {
|
||||
if (type === 'DESTINATION_CLEARANCE') { if (canManageCustoms.value) void openClearance(); return }
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER'].includes(type) || !canEdit.value) return
|
||||
if (type === 'DESTINATION_CLEARANCE') { if (canManageDestinationDocuments.value) void openClearance(); return }
|
||||
if (type !== 'DELIVERY_ORDER' || !canManageDestinationDocuments.value) return
|
||||
documentType.value = type as UploadDocumentType
|
||||
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
|
||||
}
|
||||
@@ -280,12 +370,12 @@ async function saveDocument() {
|
||||
}
|
||||
async function saveBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName || '货柜资料'; anchor.click(); URL.revokeObjectURL(url) }
|
||||
async function downloadDocument(item: ContainerOverviewDocument) {
|
||||
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
|
||||
if (!['DELIVERY_ORDER', 'DESTINATION_CLEARANCE'].includes(item.documentType)) return
|
||||
documentDownloading.value = true
|
||||
try {
|
||||
const blob = item.documentType === 'DESTINATION_CLEARANCE'
|
||||
? await downloadContainerClearance(containerId.value, item.fileId)
|
||||
: await downloadContainerDocument(containerId.value, item.documentType as 'DELIVERY_ORDER' | 'PICKUP_ORDER', item.fileId)
|
||||
: await downloadContainerDocument(containerId.value, 'DELIVERY_ORDER', item.fileId)
|
||||
await saveBlob(blob, item.fileName); ElMessage.success('资料下载已开始')
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '资料下载失败')
|
||||
@@ -294,6 +384,20 @@ async function downloadDocument(item: ContainerOverviewDocument) {
|
||||
}
|
||||
finally { documentDownloading.value = false }
|
||||
}
|
||||
async function savePickupCode(pickupCode: string) {
|
||||
if (!overview.value || !canManageDestinationDocuments.value || !/^[0-9]{1,64}$/.test(pickupCode)) return
|
||||
pickupCodeSaving.value = true; documentRefreshWarning.value = null
|
||||
try {
|
||||
const refreshed = await updateContainerPickupCode(containerId.value, pickupCode, overview.value.container.version)
|
||||
overview.value = refreshed
|
||||
if (detail.value) detail.value = { ...detail.value, pickupCode: refreshed.container.pickupCode, pickupCodeUpdatedBy: refreshed.container.pickupCodeUpdatedBy, pickupCodeUpdatedByName: refreshed.container.pickupCodeUpdatedByName, pickupCodeUpdatedAt: refreshed.container.pickupCodeUpdatedAt, version: refreshed.container.version }
|
||||
ElMessage.success('PU 码已保存')
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, 'PU 码保存失败')
|
||||
documentRefreshWarning.value = details
|
||||
if (details.code === 'A0409') try { overview.value = await getContainerOverview(containerId.value) } catch { /* 保留并发错误证据 */ }
|
||||
} finally { pickupCodeSaving.value = false }
|
||||
}
|
||||
function openException() { exceptionReason.value = ''; exceptionError.value = null; exceptionOpen.value = true }
|
||||
async function saveException() {
|
||||
if (!overview.value || !exceptionReason.value.trim()) { exceptionError.value = { code: 'REASON_REQUIRED', message: '请输入异常原因或取消备注' }; return }
|
||||
@@ -358,40 +462,44 @@ async function completeClearance() {
|
||||
finally { clearanceSaving.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => { await load(); if (route.query.action === 'documents') openDocument('DELIVERY_ORDER'); if (route.query.action === 'exception' && canEdit.value) openException() })
|
||||
function goBack() { void router.push({ name: isDomestic.value ? 'domestic-container-workbench' : 'overseas-container-workbench' }) }
|
||||
function printLoadingList() { window.print() }
|
||||
|
||||
onMounted(async () => { await load(); if (isOverseas.value && route.query.action === 'documents') openDocument('DELIVERY_ORDER') })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="container-detail-page">
|
||||
<section class="container-overview surface-panel">
|
||||
<header class="container-overview__header"><el-button link type="primary" @click="router.push({ name: 'container-loading' })"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>柜次资料、履约轨迹、PDA 进度与单证</p></div><StatusBadge v-if="detail" :status="statusText(detail.planStatus)" /></header>
|
||||
<div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待补录' }}</strong></div><div><span>装柜清单号</span><strong>{{ detail.planNo || '—' }}</strong></div><div><span>柜型</span><strong>{{ detail.containerType || '—' }}</strong></div><div><span>装柜仓库</span><strong>{{ detail.warehouseName || '—' }}</strong></div></div>
|
||||
<header class="container-overview__header"><el-button link type="primary" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1><p>{{ isDomestic ? '送柜、装柜、出口报关与国内出仓' : '海运节点、目的港清关、提柜与还空' }}</p></div><StatusBadge v-if="detail" :status="primaryStatusText" /></header>
|
||||
<div v-if="detail" class="container-overview__summary"><div><span>柜次号</span><strong>{{ detail.cabinetNo || '—' }}</strong></div><div><span>集装箱号</span><strong>{{ detail.containerNo || '待补录' }}</strong></div><div><span>{{ isDomestic ? '装柜清单号' : '当前海外阶段' }}</span><strong>{{ isDomestic ? (detail.planNo || '—') : primaryStatusText }}</strong></div><div><span>{{ isDomestic ? '柜型' : '起运仓' }}</span><strong>{{ isDomestic ? (detail.containerType || '—') : (detail.warehouseName || '—') }}</strong></div><div><span>{{ isDomestic ? '装柜仓库' : '目的仓 / 接柜仓' }}</span><strong :title="isDomestic ? detail.warehouseName || '—' : overseasDestinationText">{{ isDomestic ? (detail.warehouseName || '—') : overseasDestinationText }}</strong></div></div>
|
||||
<div v-if="isOverseas && detail" class="overseas-stage-strip" :class="{ 'is-overdue': workbenchDetail?.terminalDrayage?.emptyReturnWarning.status === 'OVERDUE' }"><div><span>当前海外履约状态</span><strong>{{ primaryStatusText }}</strong></div><p>{{ emptyReturnWarningText }}</p></div>
|
||||
</section>
|
||||
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<el-tabs v-model="activeTab" class="workbench-tabs" @tab-change="rememberWorkbenchTab"><el-tab-pane :label="isDomestic ? '送柜报关' : '清关提柜'" name="overview" /><el-tab-pane v-if="isDomestic && detail.planStatus === 'COMPLETED' && canViewLoadingList" label="装柜清单" name="loading-list" /><el-tab-pane label="国内段费用(头程)" name="first-leg-fees" /><el-tab-pane label="目的港及海外段费用(尾程)" name="last-leg-fees" /></el-tabs>
|
||||
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
||||
<section class="detail-section surface-panel" aria-labelledby="container-information-heading">
|
||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
|
||||
<h2 id="container-information-heading">货柜信息</h2>
|
||||
<div class="detail-fields"><dl><div><dt>真实订舱</dt><dd><span>{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</span><el-button v-if="canLinkOceanBooking" link type="primary" @click="openOceanBookingLink"><Link2 :size="14" />关联已放舱订舱</el-button></dd></div><div><dt>异常状态</dt><dd>{{ detail.exceptionActive ? '异常' : '正常' }}</dd></div><div><dt>目的仓</dt><dd>{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</dd></div><div><dt>拖车公司</dt><dd>{{ detail.terminalTruckingCompany || '—' }}</dd></div></dl></div>
|
||||
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
|
||||
<ContainerOverviewPanel v-if="isDomestic && overview" :overview="overview" :sections="['exception']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" @manage-exception="openException" />
|
||||
</section>
|
||||
|
||||
<section class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
|
||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-voyage-heading">
|
||||
<h2 id="container-voyage-heading">船名航次信息</h2>
|
||||
<div class="detail-fields"><dl><div><dt>船公司</dt><dd>{{ detail.shippingCompanyName || '—' }}</dd></div><div><dt>船名 / 航次</dt><dd>{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</dd></div><div><dt>预计开船</dt><dd>{{ formatTime(detail.estimatedSailingAt) }}</dd></div><div><dt>预计到港</dt><dd>{{ formatTime(detail.estimatedPortArrivalAt) }}</dd></div></dl></div>
|
||||
<div class="carrier-panel"><CarrierScheduleQueryPanel :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section surface-panel" aria-labelledby="container-customs-heading">
|
||||
<h2 id="container-customs-heading">报关清关信息</h2>
|
||||
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['documents']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" :document-busy="documentDownloading" @upload-document="openDocument" @download-document="downloadDocument" />
|
||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-customs-heading">
|
||||
<h2 id="container-customs-heading">{{ isDomestic ? '出口报关信息' : '目的港清关与提柜资料' }}</h2>
|
||||
<ContainerOverviewPanel v-if="isOverseas && overview" :overview="overview" :sections="['documents']" :can-manage-documents="canManageDestinationDocuments" :can-manage-customs="canManageDestinationDocuments" :can-manage-exception="false" :document-busy="documentDownloading" :pickup-code-busy="pickupCodeSaving" @upload-document="openDocument" @download-document="downloadDocument" @save-pickup-code="savePickupCode" />
|
||||
<el-alert v-if="documentRefreshWarning" type="warning" :closable="false" :title="documentRefreshWarning.message"><template #default><ApiErrorMeta :details="documentRefreshWarning" /></template></el-alert>
|
||||
<ContainerCustomsPanel v-if="detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
|
||||
<ContainerCustomsPanel v-if="isDomestic && detail.planStatus === 'COMPLETED' && overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
|
||||
</section>
|
||||
|
||||
<section class="detail-section surface-panel" aria-labelledby="container-receiving-heading">
|
||||
<h2 id="container-receiving-heading">签收信息</h2>
|
||||
<div class="detail-fields"><dl><div><dt>卸柜完成</dt><dd>{{ formatTime(detail.unloadingCompletedAt) }}</dd></div><div><dt>卸柜照片上传</dt><dd>{{ formatTime(detail.unloadingPhotoUploadedAt) }}</dd></div><div><dt>预计报空</dt><dd>{{ formatTime(detail.estimatedEmptyReportedAt) }}</dd></div><div><dt>实际报空</dt><dd>{{ formatTime(detail.actualEmptyReportedAt) }}</dd></div></dl></div>
|
||||
<section v-if="isDomestic" v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-receiving-heading">
|
||||
<h2 id="container-receiving-heading">装柜作业与国内出仓</h2>
|
||||
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="canEdit" :can-manage-customs="canManageCustoms" :can-manage-exception="canEdit" />
|
||||
<div v-if="detail.planStatus === 'COMPLETED' && canViewOutboundDispatch" class="outbound-panel" aria-label="国内出仓门禁">
|
||||
<header class="outbound-panel__header"><div><h2>国内出仓门禁</h2><p>按整柜核验全部入库绑定,仅确认一次离仓。</p></div><div v-if="outboundDetail" class="outbound-panel__actions"><el-tag :type="outboundGateSummary.blocked === 0 ? 'success' : 'danger'">{{ outboundGateSummary.blocked === 0 ? '门禁通过' : '门禁阻断' }}</el-tag><StatusBadge :status="outboundStatusText(outboundDetail.aggregate.dispatchStatus)" /><el-button v-if="canConfirmDeparture" type="primary" :loading="outboundConfirming" @click="confirmDeparture">确认离仓</el-button></div></header>
|
||||
@@ -411,15 +519,30 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-section surface-panel" aria-labelledby="container-fees-heading">
|
||||
<h2 id="container-fees-heading">费用明细</h2>
|
||||
<LoadErrorState v-if="loadingDetailError" compact title="费用明细加载失败" :message="loadingDetailError.message" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
|
||||
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
|
||||
<el-alert v-else-if="!canViewContainerFees" type="warning" :closable="false" show-icon title="当前账号没有查看柜次费用的权限" />
|
||||
<el-empty v-else description="当前货柜尚无可读取的装柜费用上下文" :image-size="64" />
|
||||
<section v-if="isOverseas" v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-overseas-receiving-heading">
|
||||
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></header>
|
||||
<div class="detail-fields"><dl><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预约提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.pickupAppointmentAt) }}</dd></div><div><dt>实际提柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualPickupAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime(workbenchDetail?.actualFacts?.unloadingCompletedAt as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
|
||||
</section>
|
||||
|
||||
<section v-if="isDomestic && activeTab === 'loading-list'" class="detail-section surface-panel" aria-labelledby="container-loading-list-heading">
|
||||
<header class="loading-list-heading"><div><h2 id="container-loading-list-heading">装柜清单</h2><p>清单明细、PDA 证据与报关关联均以当前柜次为边界。</p></div><el-button v-if="loadingDetail" @click="printLoadingList">打印装柜清单</el-button></header>
|
||||
<ContainerOverviewPanel v-if="overview" :overview="overview" :sections="['pda']" :can-manage-documents="false" :can-manage-customs="false" :can-manage-exception="false" />
|
||||
<ContainerCustomsPanel v-if="overview?.loadingPlanId && canListCustoms" :loading-plan-id="overview.loadingPlanId" :can-list="canListCustoms" :can-manage="canManageCustoms" :departure-state="customsDepartureState" @refreshed="refreshAfterCustoms" />
|
||||
<LoadingListDetailDrawer mode="confirmed" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
|
||||
</section>
|
||||
|
||||
<section v-if="firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
|
||||
<LoadErrorState v-if="loadingDetailError" compact title="头程计费归属明细加载失败" :message="loadingDetailError.message" :loading="loadingDetailLoading" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
|
||||
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" :readonly="isOverseas" />
|
||||
<el-empty v-else-if="!loadingDetailLoading" :description="firstLegFeesEmptyReason" :image-size="72" />
|
||||
</section>
|
||||
|
||||
<section v-if="lastLegFeesOpened" v-show="activeTab === 'last-leg-fees'" class="detail-section surface-panel" aria-label="尾程计费">
|
||||
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :container-no="detail.containerNo" :readonly="isDomestic" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<AppDialog v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" confirm-text="确认还柜" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form label-position="top"><el-form-item label="实际还柜时间" required><el-date-picker v-model="emptyReturnAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择实际还柜时间" /></el-form-item><el-form-item label="备注"><el-input v-model="emptyReturnReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form><LoadErrorState v-if="emptyReturnError" compact title="确认还柜失败" :message="emptyReturnError.message" :show-retry="false"><template #details><ApiErrorMeta :details="emptyReturnError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
|
||||
<AppDialog v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入异常原因或取消备注" /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
|
||||
<AppDialog v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
|
||||
@@ -428,4 +551,7 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
|
||||
|
||||
<style scoped>
|
||||
.container-detail-page { display:grid; min-width:0; gap:12px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; padding:10px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:3px; padding:8px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-section{display:grid;min-width:0;gap:12px;padding:12px 16px}.detail-section>h2{margin:0;font-size:15px}.detail-fields { padding:0; }.detail-fields dl { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:9px 16px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:3px; font-size:13px; }.outbound-panel{padding:12px 0 0;border-top:1px solid var(--cc-border)}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:12px}.outbound-panel__header h2{margin:0;font-size:15px}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:8px}.outbound-panel__loading{padding:8px 0}.gate-summary{display:flex;align-items:center;gap:14px;margin-top:8px;color:var(--cc-muted);font-size:12px}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:10px;border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 12px;margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:6px 8px;min-width:0;padding:6px 8px;border:1px solid var(--cc-border);border-radius:6px;background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{overflow:hidden;color:var(--cc-muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--cc-muted);font-size:11px}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding-top:4px}@media(max-width:1200px){.detail-fields dl{grid-template-columns:repeat(4,minmax(0,1fr))}.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
|
||||
.detail-section__heading { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.detail-section__heading h2 { margin:0; font-size:15px; }
|
||||
.overseas-stage-strip{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:9px 16px;border-top:1px solid var(--cc-border);background:#f5f8ff}.overseas-stage-strip>div{display:flex;align-items:baseline;gap:10px}.overseas-stage-strip span,.overseas-stage-strip p{color:var(--cc-muted);font-size:12px}.overseas-stage-strip strong{color:var(--cc-primary);font-size:14px}.overseas-stage-strip p{margin:0}.overseas-stage-strip.is-overdue{background:#fff7ed}.overseas-stage-strip.is-overdue strong,.overseas-stage-strip.is-overdue p{color:#c2410c}
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { listPlatformPartners } from '@/modules/master-data'
|
||||
import { listSuppliers } from '@/modules/suppliers'
|
||||
import { getOceanBooking, listOceanBookingLinkCandidates, listOceanBookings } from '@/modules/ocean-bookings'
|
||||
import { getContainer, getContainerOverview, linkContainerOceanBooking, listContainers, updateContainer } from '../api/platformContainerLoading'
|
||||
import { getTerminalDrayage, getTerminalDrayageHistory, listTerminalDrayages, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet } from '../api/terminalDrayages'
|
||||
import { getTerminalDrayage, getTerminalDrayageHistory, recordTerminalDrayageEmptyReturn, selectTerminalDrayageFleet } from '../api/terminalDrayages'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
|
||||
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
|
||||
@@ -35,9 +35,10 @@ const drayage = {
|
||||
function createTestRouter() {
|
||||
return createRouter({ history: createMemoryHistory(), routes: [
|
||||
{ path: '/', component: ContainersView },
|
||||
{ path: '/container-loading/:containerId/smart-loading', name: 'smart-loading-workbench', component: { template: '<div />' } },
|
||||
{ path: '/container-loading/domestic/:containerId/smart-loading', name: 'smart-loading-workbench', component: { template: '<div />' } },
|
||||
{ path: '/loading-plans', name: 'loading-plans', component: { template: '<div />' } },
|
||||
{ path: '/loading-lists', name: 'loading-lists', component: { template: '<div />' } },
|
||||
{ path: '/container-loading/domestic/:containerId', name: 'domestic-container-detail', component: { template: '<div />' } },
|
||||
{ path: '/container-loading/overseas/:containerId', name: 'overseas-container-detail', component: { template: '<div />' } },
|
||||
] })
|
||||
}
|
||||
|
||||
@@ -51,13 +52,12 @@ describe('ContainersView', () => {
|
||||
vi.mocked(listOceanBookings).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 })
|
||||
vi.mocked(listOceanBookingLinkCandidates).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
|
||||
vi.mocked(getOceanBooking).mockResolvedValue({} as never)
|
||||
vi.mocked(listTerminalDrayages).mockResolvedValue([{ ...drayage, allowedActions: [...drayage.allowedActions] }])
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...drayage, allowedActions: [...drayage.allowedActions] })
|
||||
vi.mocked(getTerminalDrayageHistory).mockResolvedValue([])
|
||||
vi.mocked(selectTerminalDrayageFleet).mockResolvedValue({ ...drayage, fleetSupplierId: '502', fleetSupplierName: '新车队', version: 4, allowedActions: [...drayage.allowedActions] })
|
||||
vi.mocked(recordTerminalDrayageEmptyReturn).mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
|
||||
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...drayage, status: 'EMPTY_RETURNED', actualEmptyReturnAt: '2026-09-11T13:00:00', version: 4, emptyReturnWarning: { status: 'RESOLVED', deadlineAt: '2026-09-10T10:00:00', overdueHours: 0, overdueDays: 0 }, allowedActions: ['SELECT_FLEET'] })
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [{ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER', 'SELECT_FLEET', 'SCHEDULE_PICKUP', 'RECORD_PICKUP', 'RECORD_EMPTY_RETURN'], terminalDrayage: drayage, oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null }], page: 1, size: 20, total: 1, pages: 1 })
|
||||
vi.mocked(getContainer).mockResolvedValue({ id: '9001', planId: null, bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '8', warehouseName: '义乌仓', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', containerType: '40HC', planStatus: 'WAIT_LOADING', version: 0, billOfLadingNo: null, vesselName: null, voyageNo: null, shippingCompanyId: null, shippingCompanyName: null, departurePortCode: null, departurePortName: '宁波', destinationPortCode: null, destinationPortName: '长滩', destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 76, maxWeightKg: 28000, terminalTruckingCompanyId: null, terminalTruckingCompany: null, loadingAt: null, estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null })
|
||||
vi.mocked(getContainerOverview).mockResolvedValue({ container: { id: '9001', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'WAIT_LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: null, vesselName: null, version: 0 }, loadingPlanId: null, pda: null, documents: [], exception: { active: false, reason: null, markedBy: null, markedByName: null, markedAt: null }, exceptionHistory: [], timeline: [] })
|
||||
})
|
||||
|
||||
@@ -66,27 +66,141 @@ describe('ContainersView', () => {
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20 }))
|
||||
expect(listContainers).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20, scope: 'ACTIVE' }), 'DOMESTIC')
|
||||
expect(wrapper.text()).toContain('待装柜')
|
||||
expect(wrapper.text()).toContain('CB-1')
|
||||
expect(wrapper.text()).toContain('MSCU-1')
|
||||
expect(wrapper.text()).toContain('宁波 → 长滩')
|
||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
|
||||
expect(listTerminalDrayages).toHaveBeenCalledWith(['9001'])
|
||||
expect(wrapper.text()).toContain('超时 1 天')
|
||||
expect(wrapper.text()).toContain('2026-09-10 10:00')
|
||||
expect(wrapper.text()).not.toContain('报空预警')
|
||||
})
|
||||
|
||||
it('keeps the cross-warehouse list usable when warehouse form options fail', async () => {
|
||||
it('shows one direct empty-return action only when permission and server actions allow it', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
const buttons = wrapper.findAll('button').filter((item) => item.text().includes('确认还柜'))
|
||||
expect(buttons).toHaveLength(1)
|
||||
await buttons[0].trigger('click')
|
||||
await flushPromises()
|
||||
const returnDialog = wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
|
||||
returnDialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-09-11T13:00:00')
|
||||
await returnDialog.find('textarea').setValue('还柜凭证已核对')
|
||||
const callsBefore = vi.mocked(listContainers).mock.calls.length
|
||||
const page: Awaited<ReturnType<typeof listContainers>> = await vi.mocked(listContainers).mock.results.at(-1)!.value
|
||||
vi.mocked(listContainers).mockResolvedValueOnce({ ...page, records: page.records.map((row) => ({ ...row, allowedActions: [], terminalDrayage: { ...drayage, status: 'EMPTY_RETURNED', version: 4, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED', deadlineAt: null, overdueHours: 0, overdueDays: 0 } } })) })
|
||||
const confirm = [...document.body.querySelectorAll('button')].filter((item) => item.textContent?.includes('确认还柜')).at(-1) as HTMLButtonElement
|
||||
confirm.click()
|
||||
await flushPromises()
|
||||
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledTimes(1)
|
||||
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 3, actualAt: '2026-09-11T13:00:00', remark: '还柜凭证已核对' })
|
||||
expect(wrapper.text()).toContain('已还柜')
|
||||
expect(listContainers).toHaveBeenCalledTimes(callsBefore + 1)
|
||||
expect(wrapper.findAll('.row-actions button').some((button) => button.text() === '确认还柜')).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('cancels empty return without writing and preserves input on failure while preventing duplicate requests', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
const open = async () => {
|
||||
await wrapper.findAll('.row-actions button').find((button) => button.text() === '确认还柜')!.trigger('click')
|
||||
await flushPromises()
|
||||
return wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '确认还柜')!
|
||||
}
|
||||
const first = await open()
|
||||
first.vm.$emit('update:modelValue', false)
|
||||
await flushPromises()
|
||||
expect(recordTerminalDrayageEmptyReturn).not.toHaveBeenCalled()
|
||||
const dialog = await open()
|
||||
const state = wrapper.vm as unknown as { drayageActionAt: string; drayageReason: string; drayageActionOpen: boolean; drayageActionError: { message: string } | null }
|
||||
state.drayageActionAt = '2026-09-11T13:00:00'
|
||||
state.drayageReason = '还柜凭证已核对'
|
||||
let reject!: (reason: Error) => void
|
||||
vi.mocked(recordTerminalDrayageEmptyReturn).mockReturnValueOnce(new Promise((_resolve, rejectRequest) => { reject = rejectRequest }))
|
||||
dialog.vm.$emit('confirm')
|
||||
dialog.vm.$emit('confirm')
|
||||
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledTimes(1)
|
||||
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 3, actualAt: '2026-09-11T13:00:00', remark: '还柜凭证已核对' })
|
||||
reject(new Error('还柜时间不符合业务要求'))
|
||||
await flushPromises()
|
||||
expect(state.drayageActionOpen).toBe(true)
|
||||
expect(state.drayageActionAt).toBe('2026-09-11T13:00:00')
|
||||
expect(state.drayageReason).toBe('还柜凭证已核对')
|
||||
expect(state.drayageActionError).not.toBeNull()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hides the direct empty-return action without permission or without the server action', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
accessCan.mockImplementation((code) => code !== 'platform:firstleg:terminal-drayage:empty-return:confirm')
|
||||
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('button').some((item) => item.text().includes('确认还柜'))).toBe(false)
|
||||
wrapper.unmount()
|
||||
accessCan.mockReturnValue(true)
|
||||
const allowedWrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
const vm = allowedWrapper.vm as unknown as { rows: Array<{ allowedActions?: string[] }> }
|
||||
vm.rows[0].allowedActions = vm.rows[0].allowedActions?.filter((action) => action !== 'RECORD_EMPTY_RETURN')
|
||||
await allowedWrapper.vm.$nextTick()
|
||||
expect(allowedWrapper.findAll('button').some((item) => item.text().includes('确认还柜'))).toBe(false)
|
||||
})
|
||||
|
||||
it('defers form options until their dialogs open and caches successful loads', async () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
|
||||
expect(listPlatformPartners).not.toHaveBeenCalled()
|
||||
expect(listSuppliers).not.toHaveBeenCalled()
|
||||
expect(listOceanBookings).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
|
||||
expect(listPlatformPartners).not.toHaveBeenCalled()
|
||||
expect(listSuppliers).not.toHaveBeenCalled()
|
||||
expect(listOceanBookings).not.toHaveBeenCalled()
|
||||
wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '新增模拟柜')!.vm.$emit('update:modelValue', false)
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
|
||||
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listAllPlatformWarehouses).toHaveBeenCalledTimes(1)
|
||||
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
|
||||
expect(listSuppliers).toHaveBeenCalledTimes(1)
|
||||
expect(listOceanBookings).toHaveBeenCalledTimes(1)
|
||||
wrapper.findAllComponents(AppDialog).find((dialog) => dialog.props('title') === '编辑')!.vm.$emit('update:modelValue', false)
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('编辑运输信息'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
|
||||
expect(listSuppliers).toHaveBeenCalledTimes(1)
|
||||
expect(listOceanBookings).toHaveBeenCalledTimes(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps the cross-warehouse list usable when lazy warehouse form options fail', async () => {
|
||||
vi.mocked(listAllPlatformWarehouses).mockRejectedValueOnce(new Error('warehouse unavailable'))
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(listAllPlatformWarehouses).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('CB-1')
|
||||
expect(wrapper.text()).not.toContain('装柜仓库加载失败')
|
||||
expect(wrapper.find('button[aria-label="查看 CB-1"]').exists()).toBe(true)
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('新增模拟柜'))!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('装柜仓库加载失败')
|
||||
})
|
||||
|
||||
it('uses stable view labels when cabinetNo is missing', async () => {
|
||||
@@ -150,7 +264,7 @@ describe('ContainersView', () => {
|
||||
await wrapper.findAll('button').find((button) => button.text().includes('装柜清单'))!.trigger('click')
|
||||
expect(push).toHaveBeenNthCalledWith(1, { name: 'smart-loading-workbench', params: { containerId: '9001' } })
|
||||
expect(push).toHaveBeenNthCalledWith(2, { name: 'loading-plans', query: { planId: '9102', warehouseId: '8' } })
|
||||
expect(push).toHaveBeenNthCalledWith(3, { name: 'loading-lists', query: { planId: '9103', warehouseId: '8' } })
|
||||
expect(push).toHaveBeenNthCalledWith(3, { name: 'domestic-container-detail', params: { containerId: '9003' }, query: { tab: 'loading-list' } })
|
||||
})
|
||||
|
||||
it('uses active customer names and sends the stable customerId filter', () => {
|
||||
@@ -209,8 +323,7 @@ describe('ContainersView', () => {
|
||||
|
||||
it('opens container detail as an independent route while keeping create and edit dialogs', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain("router.push({ name: 'container-detail', params: { containerId: row.id } })")
|
||||
expect(source).toContain("query: { action: command }")
|
||||
expect(source).toContain("'domestic-container-detail' : 'overseas-container-detail'")
|
||||
expect(source).not.toContain('<AppDialog v-model="detailOpen"')
|
||||
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog"')
|
||||
expect(source).toContain('max-height: 90vh')
|
||||
@@ -223,10 +336,11 @@ describe('ContainersView', () => {
|
||||
it('registers a list-authorized independent container detail page', () => {
|
||||
const routes = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/routes.ts'), 'utf8')
|
||||
const detail = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainerDetailView.vue'), 'utf8')
|
||||
expect(routes).toContain("path: 'container-loading/:containerId'")
|
||||
expect(routes).toContain("name: 'container-detail'")
|
||||
expect(routes).toContain("path: 'container-loading/domestic/:containerId'")
|
||||
expect(routes).toContain("name: 'domestic-container-detail'")
|
||||
expect(routes).toContain("name: 'overseas-container-detail'")
|
||||
expect(routes).toContain("permission: 'platform:firstleg:container:list'")
|
||||
expect(detail).toContain('getContainer(containerId.value)')
|
||||
expect(detail).toContain('getContainer(containerId.value, props.workbench)')
|
||||
expect(detail).toContain('getContainerOverview(containerId.value)')
|
||||
expect(detail).toContain('<ContainerOverviewPanel')
|
||||
expect(detail).toContain("@upload-document=\"openDocument\"")
|
||||
@@ -249,7 +363,7 @@ describe('ContainersView', () => {
|
||||
it('renders container business actions as buttons without changing the transport gate', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain('size="small" type="primary" plain :aria-label="containerViewLabel')
|
||||
expect(source).toContain("['WAIT_LOADING', 'EDITING'].includes(row.planStatus)")
|
||||
expect(source).toContain("hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')")
|
||||
expect(source).not.toContain("row.containerNo ? '修改集装箱号' : '录入集装箱号'")
|
||||
expect(source).toContain('货柜基础资料不提供柜号写入')
|
||||
expect(source).toContain('containerNo: optional(detail.value?.containerNo || null)')
|
||||
@@ -265,7 +379,7 @@ describe('ContainersView', () => {
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '502', name: '新车队', category: 'TRUCKING', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
const wrapper = mount(ContainersView, { global: { plugins: [ElementPlus, router] } })
|
||||
const wrapper = mount(ContainersView, { props: { workbench: 'OVERSEAS' }, global: { plugins: [ElementPlus, router] } })
|
||||
await flushPromises()
|
||||
|
||||
const vm = wrapper.vm as unknown as {
|
||||
@@ -280,8 +394,10 @@ describe('ContainersView', () => {
|
||||
|
||||
vm.openDrayageAction(vm.rows[0], 'RECORD_EMPTY_RETURN')
|
||||
vm.drayageActionAt = '2026-09-11T13:00:00'
|
||||
const page: Awaited<ReturnType<typeof listContainers>> = await vi.mocked(listContainers).mock.results.at(-1)!.value
|
||||
vi.mocked(listContainers).mockResolvedValueOnce({ ...page, records: page.records.map((row) => ({ ...row, allowedActions: [], terminalDrayage: { ...drayage, status: 'EMPTY_RETURNED', version: 5, allowedActions: [], emptyReturnWarning: { status: 'RESOLVED', deadlineAt: null, overdueHours: 0, overdueDays: 0 } } })) })
|
||||
await vm.saveDrayageAction()
|
||||
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 4, actualAt: '2026-09-11T13:00:00', reason: undefined })
|
||||
expect(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', { expectedVersion: 4, actualAt: '2026-09-11T13:00:00', remark: undefined })
|
||||
expect(wrapper.text()).toContain('已还柜')
|
||||
wrapper.unmount()
|
||||
})
|
||||
@@ -304,9 +420,9 @@ describe('ContainersView', () => {
|
||||
it('opens the existing released-booking workflow when entered from container detail', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
expect(source).toContain('route.query.linkContainerId')
|
||||
expect(source).toContain('const target = await getContainer(linkContainerId)')
|
||||
expect(source).toContain("const target = await getContainer(linkContainerId, 'DOMESTIC')")
|
||||
expect(source).toContain('await openLink(target)')
|
||||
expect(source).toContain("void router.replace({ name: 'container-loading' })")
|
||||
expect(source).toContain("void router.replace({ name: 'domestic-container-workbench' })")
|
||||
})
|
||||
|
||||
it('normalizes an optional booking-link container number', async () => {
|
||||
@@ -399,7 +515,7 @@ describe('ContainersView', () => {
|
||||
it('filters OB-260825-0183 for CAB-260825-0215 because the real warehouses differ', async () => {
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [{
|
||||
id: '215', planId: null, bookingNo: 'CB-260825-0215', cabinetNo: 'CAB-260825-0215', containerNo: null,
|
||||
sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
|
||||
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
|
||||
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', planStatus: 'EDITING',
|
||||
capacityCbm: 86, maxWeightKg: 29000, version: 7,
|
||||
}], page: 1, size: 20, total: 1, pages: 1 } as never)
|
||||
@@ -426,7 +542,7 @@ describe('ContainersView', () => {
|
||||
let resolveCandidates!: (value: Awaited<ReturnType<typeof listOceanBookingLinkCandidates>>) => void
|
||||
vi.mocked(listContainers).mockResolvedValue({ records: [{
|
||||
id: '315', planId: null, bookingNo: 'CB-CAPACITY-SNAPSHOT', cabinetNo: 'CAB-CAPACITY-SNAPSHOT', containerNo: null,
|
||||
sourceType: 'SIMULATION', oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
|
||||
sourceType: 'SIMULATION', allowedActions: ['EDIT_CONTAINER'], oceanBookingId: null, warehouseId: '193000000000200002', warehouseName: '东莞仓',
|
||||
channelCode: 'EXX_RAIL_NY', channelName: '纽约铁路', containerType: '45HC', planStatus: 'WAIT_LOADING',
|
||||
capacityCbm: 86, maxWeightKg: 29000, version: 7,
|
||||
}], page: 1, size: 20, total: 1, pages: 1 } as never)
|
||||
@@ -491,7 +607,7 @@ describe('ContainersView', () => {
|
||||
|
||||
it('backfills a legacy trucking supplier name and submits the frozen id-name pair', async () => {
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
|
||||
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '测试拖车供应商' } as never)
|
||||
vi.mocked(updateContainer).mockResolvedValue({} as never)
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
@@ -511,7 +627,7 @@ describe('ContainersView', () => {
|
||||
|
||||
it('clears the trucking supplier id and name together', async () => {
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740998', tenantId: '1', name: '测试拖车供应商', category: 'TRUCKING', carrierCodes: [], region: 'CN', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '2026-08-25T10:00:00', updatedBy: null, updatedAt: '2026-08-25T10:00:00', deleted: false, version: 0 }], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
|
||||
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: '9007199254740998', terminalTruckingCompany: '测试拖车供应商' } as never)
|
||||
vi.mocked(updateContainer).mockResolvedValue({} as never)
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
@@ -533,7 +649,7 @@ describe('ContainersView', () => {
|
||||
})
|
||||
|
||||
it('blocks saving a legacy trucking supplier name that no longer matches an active option', async () => {
|
||||
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
|
||||
vi.mocked(getContainer).mockResolvedValueOnce({ id: '9001', allowedActions: ['EDIT_CONTAINER'], planStatus: 'WAIT_LOADING', containerType: '40HC', warehouseId: '8', terminalTruckingCompanyId: null, terminalTruckingCompany: '已停用拖车供应商' } as never)
|
||||
vi.mocked(updateContainer).mockClear()
|
||||
const router = createTestRouter()
|
||||
await router.push('/'); await router.isReady()
|
||||
@@ -555,7 +671,7 @@ describe('ContainersView', () => {
|
||||
describe('ContainersView trucking supplier contract', () => {
|
||||
it('shows the frozen plan, exception and document aggregates without deriving them client-side', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
|
||||
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupOrderDocumentStatus']
|
||||
const fields = ['containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode']
|
||||
let previous = source.indexOf("key: 'actions'")
|
||||
for (const field of fields) {
|
||||
const current = source.indexOf(`key: '${field}'`, previous + 1)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles, TriangleAlert } from '@lucide/vue'
|
||||
import { Boxes, Check, Eye, FileText, FileUp, Link2, ListChecks, MoreHorizontal, Pencil, Plus, Sparkles } from '@lucide/vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
@@ -22,7 +22,6 @@ import { createContainerSimulation, getContainer, linkContainerOceanBooking, lis
|
||||
import {
|
||||
getTerminalDrayage,
|
||||
getTerminalDrayageHistory,
|
||||
listTerminalDrayages,
|
||||
recordTerminalDrayageEmptyReturn,
|
||||
recordTerminalDrayagePickup,
|
||||
scheduleTerminalDrayagePickup,
|
||||
@@ -31,15 +30,17 @@ import {
|
||||
type TerminalDrayageHistory,
|
||||
type TerminalDrayageView,
|
||||
} from '../api/terminalDrayages'
|
||||
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest } from '../types'
|
||||
import type { ContainerBookingResource, ContainerDocumentStatus, ContainerPlanStatus, ContainerSaveRequest, ContainerSimulationCreateRequest, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
||||
|
||||
type Filters = QueryModel & { customerId: string; keyword: string; containerType: string; status: ContainerPlanStatus | ''; sailingRange: string[] }
|
||||
type ContainerForm = ContainerSaveRequest & Pick<ContainerBookingResource, 'cabinetNo'> & { id: string; version: number }
|
||||
|
||||
const props = withDefaults(defineProps<{ workbench?: 'DOMESTIC' | 'OVERSEAS' }>(), { workbench: 'DOMESTIC' })
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const access = useAccessPreviewStore()
|
||||
const rows = ref<ContainerBookingResource[]>([])
|
||||
const rows = ref<ContainerWorkbenchRow[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
@@ -56,6 +57,7 @@ const businessOptionsError = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const scope = ref<'ACTIVE' | 'HISTORY'>('ACTIVE')
|
||||
const filters = ref<Filters>({ customerId: '', keyword: '', containerType: '', status: '', sailingRange: [] })
|
||||
const applied = ref<Filters>({ ...filters.value })
|
||||
const detail = ref<ContainerBookingResource | null>(null)
|
||||
@@ -81,8 +83,6 @@ const linkCandidates = ref<Array<{ key: string; oceanBookingId: string; requirem
|
||||
const selectedTruckingSupplierId = ref('')
|
||||
const unresolvedTruckingSupplierName = ref<string | null>(null)
|
||||
const drayages = ref<Record<string, TerminalDrayageView>>({})
|
||||
const drayageLoading = ref(false)
|
||||
const drayageLoadError = ref<ApiErrorDetails | null>(null)
|
||||
const drayageSavingIds = ref<string[]>([])
|
||||
const drayageActionOpen = ref(false)
|
||||
const drayageAction = ref<Exclude<TerminalDrayageAction, 'SELECT_FLEET'>>('SCHEDULE_PICKUP')
|
||||
@@ -98,7 +98,13 @@ const drayageHistoryError = ref<ApiErrorDetails | null>(null)
|
||||
let listSequence = 0
|
||||
let detailSequence = 0
|
||||
let linkSequence = 0
|
||||
let drayageSequence = 0
|
||||
let warehousesLoaded = false
|
||||
let warehouseOptionsPromise: Promise<void> | null = null
|
||||
let partnersLoaded = false
|
||||
let truckingSuppliersLoaded = false
|
||||
let truckingSuppliersPromise: Promise<Supplier[]> | null = null
|
||||
let oceanBookingsLoaded = false
|
||||
let businessOptionsPromise: Promise<void> | null = null
|
||||
|
||||
function emptyForm(): ContainerForm {
|
||||
return {
|
||||
@@ -118,11 +124,17 @@ const rules: FormRules<ContainerForm> = {
|
||||
terminalTruckingCompany: [{ validator: (_rule, value, callback) => callback(unresolvedTruckingSupplierName.value || (value && !selectedTruckingSupplierId.value) ? new Error('请选择有效的合作中拖车供应商') : undefined), trigger: 'change' }],
|
||||
}
|
||||
|
||||
const canView = computed(() => access.can('platform:firstleg:container:list'))
|
||||
const isDomestic = computed(() => props.workbench === 'DOMESTIC')
|
||||
const isOverseas = computed(() => props.workbench === 'OVERSEAS')
|
||||
const canView = computed(() => isDomestic.value ? access.can('platform:firstleg:container:list') : access.can('platform:firstleg:terminal-drayage:list'))
|
||||
const canEdit = computed(() => access.can('platform:firstleg:container:edit'))
|
||||
const canCreate = computed(() => access.can('platform:firstleg:container:create'))
|
||||
const canOverseasDocumentEdit = computed(() => access.can('platform:firstleg:terminal-drayage:document:edit'))
|
||||
const canFleetEdit = computed(() => access.can('platform:firstleg:terminal-drayage:fleet:edit'))
|
||||
const canPickupAppointmentEdit = computed(() => access.can('platform:firstleg:terminal-drayage:pickup-appointment:edit'))
|
||||
const canPickupConfirm = computed(() => access.can('platform:firstleg:terminal-drayage:pickup:confirm'))
|
||||
const canEmptyReturnConfirm = computed(() => access.can('platform:firstleg:terminal-drayage:empty-return:confirm'))
|
||||
const canSmartLoading = computed(() => access.can('platform:firstleg:smart-loading:list'))
|
||||
const canCustomsManage = computed(() => access.can('platform:firstleg:customs:manage'))
|
||||
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const editorTitle = computed(() => form.id ? '编辑' : '新增')
|
||||
@@ -166,7 +178,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '装柜状态', width: 150, placeholder: '请选择货柜状态', options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '装柜状态' } },
|
||||
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
const allColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'emptyReturnWarning', label: '报空预警', width: 120, slotName: 'emptyReturnWarning' },
|
||||
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
|
||||
@@ -176,7 +188,7 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
|
||||
{ key: 'destinationClearanceDocumentStatus', label: '清关资料', width: 110, formatter: (row: ContainerBookingResource) => documentStatusText(row.destinationClearanceDocumentStatus) },
|
||||
{ key: 'deliveryOrderDocumentStatus', label: 'DO资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.deliveryOrderDocumentStatus) },
|
||||
{ key: 'pickupOrderDocumentStatus', label: 'PU资料', width: 100, formatter: (row: ContainerBookingResource) => documentStatusText(row.pickupOrderDocumentStatus) },
|
||||
{ key: 'pickupCode', label: 'PU码', minWidth: 140, formatter: (row: ContainerBookingResource) => row.pickupCode || '—' },
|
||||
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
|
||||
{ key: 'warehouseName', prop: 'warehouseName', label: '装柜仓库', minWidth: 160 },
|
||||
{ key: 'terminalTruckingCompany', label: '码头车队', width: 190, slotName: 'terminalTruckingCompany' },
|
||||
@@ -196,6 +208,9 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'estimatedPortArrivalAt', label: '预计到港', minWidth: 160, slotName: 'estimatedPortArrivalAt' },
|
||||
{ key: 'actualPortArrivalAt', label: '实际到港', minWidth: 160, slotName: 'actualPortArrivalAt' },
|
||||
]
|
||||
const domesticColumnKeys = new Set(['actions', 'containerNo', 'planNo', 'planStatus', 'exceptionActive', 'cabinetNo', 'oceanBooking', 'warehouseName', 'billOfLadingNo', 'route', 'containerType', 'capacity', 'vessel', 'shippingCompanyName', 'loadingAt', 'estimatedSailingAt'])
|
||||
const overseasColumnKeys = new Set(['actions', 'emptyReturnWarning', 'containerNo', 'cabinetNo', 'destinationClearanceDocumentStatus', 'deliveryOrderDocumentStatus', 'pickupCode', 'terminalTruckingCompany', 'pickupAppointmentAt', 'actualPickupAt', 'estimatedEmptyReturnAt', 'actualEmptyReturnAt', 'billOfLadingNo', 'route', 'vessel', 'actualSailingAt', 'estimatedPortArrivalAt', 'actualPortArrivalAt'])
|
||||
const columns = computed(() => allColumns.filter((column) => (isDomestic.value ? domesticColumnKeys : overseasColumnKeys).has(String(column.key))))
|
||||
const drayageHistoryColumns: DataTableColumn[] = [
|
||||
{ key: 'action', label: '动作', width: 130, formatter: (row: TerminalDrayageHistory) => drayageActionText(row.action) },
|
||||
{ key: 'status', label: '状态变化', minWidth: 180, formatter: (row: TerminalDrayageHistory) => `${drayageStatusText(row.fromStatus)} → ${drayageStatusText(row.toStatus)}` },
|
||||
@@ -212,8 +227,12 @@ function documentStatusText(status: ContainerDocumentStatus | null | undefined)
|
||||
function formatTime(value: string | null | undefined) { return value?.replace('T', ' ').slice(0, 16) || '—' }
|
||||
function drayageFor(containerBookingId: string) { return drayages.value[containerBookingId] }
|
||||
function drayageStatusText(status?: string | null) { return ({ WAITING_FOR_ARRIVAL: '待到港', ARRIVED: '已到港', PICKUP_SCHEDULED: '已约提柜', PICKED_UP: '已提柜', UNLOADED: '已卸柜', EMPTY_RETURNED: '已还柜' } as Record<string, string>)[status || ''] || status || '—' }
|
||||
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_PICKUP: '确认实际提柜', RECORD_EMPTY_RETURN: '确认实际还柜' } as Record<TerminalDrayageAction, string>)[action] }
|
||||
function canDrayage(row: ContainerBookingResource, action: TerminalDrayageAction) { return Boolean(canEdit.value && drayageFor(row.id)?.allowedActions.includes(action)) }
|
||||
function drayageActionText(action: TerminalDrayageAction) { return ({ SELECT_FLEET: '选择码头车队', SCHEDULE_PICKUP: '预约提柜', RECORD_PICKUP: '确认实际提柜', RECORD_EMPTY_RETURN: '确认还柜' } as Record<TerminalDrayageAction, string>)[action] }
|
||||
function hasAction(row: ContainerWorkbenchRow, action: ContainerWorkbenchAction) { return Boolean(row.allowedActions?.includes(action)) }
|
||||
function canDrayage(row: ContainerWorkbenchRow, action: TerminalDrayageAction) {
|
||||
const permission = action === 'SELECT_FLEET' ? canFleetEdit.value : action === 'SCHEDULE_PICKUP' ? canPickupAppointmentEdit.value : action === 'RECORD_PICKUP' ? canPickupConfirm.value : canEmptyReturnConfirm.value
|
||||
return Boolean(isOverseas.value && permission && hasAction(row, action) && drayageFor(row.id)?.allowedActions.includes(action))
|
||||
}
|
||||
function drayageWarningText(row: ContainerBookingResource) {
|
||||
const warning = drayageFor(row.id)?.emptyReturnWarning
|
||||
if (!warning || warning.status === 'NOT_APPLICABLE') return '—'
|
||||
@@ -333,14 +352,21 @@ function selectDestinationWarehouse(warehouseCode: string) {
|
||||
}
|
||||
|
||||
async function loadWarehouses() {
|
||||
warehouseLoading.value = true; warehouseError.value = null
|
||||
try {
|
||||
const records = await listAllPlatformWarehouses({ status: 'ACTIVE' })
|
||||
warehouses.value = records.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
|
||||
destinationWarehouses.value = records.filter((warehouse) => warehouse.warehouseType !== 'DOMESTIC')
|
||||
}
|
||||
catch (error) { warehouseError.value = getApiErrorDetails(error, '装柜仓库加载失败') }
|
||||
finally { warehouseLoading.value = false }
|
||||
if (warehousesLoaded) return
|
||||
if (warehouseOptionsPromise) return warehouseOptionsPromise
|
||||
warehouseOptionsPromise = (async () => {
|
||||
warehouseLoading.value = true; warehouseError.value = null
|
||||
try {
|
||||
const records = await listAllPlatformWarehouses({ status: 'ACTIVE' })
|
||||
warehouses.value = records.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC')
|
||||
destinationWarehouses.value = records.filter((warehouse) => warehouse.warehouseType !== 'DOMESTIC')
|
||||
warehousesLoaded = true
|
||||
}
|
||||
catch (error) { warehouseError.value = getApiErrorDetails(error, '装柜仓库加载失败') }
|
||||
finally { warehouseLoading.value = false }
|
||||
})()
|
||||
try { await warehouseOptionsPromise }
|
||||
finally { warehouseOptionsPromise = null }
|
||||
}
|
||||
async function loadCustomers() {
|
||||
customersLoading.value = true
|
||||
@@ -354,25 +380,48 @@ async function loadCustomers() {
|
||||
}
|
||||
}
|
||||
async function loadBusinessOptions() {
|
||||
businessOptionsLoading.value = true
|
||||
businessOptionsError.value = ''
|
||||
const errors: string[] = []
|
||||
const [partnerResult, supplierResult, bookingResult] = await Promise.allSettled([
|
||||
loadAllPartners(),
|
||||
listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' }),
|
||||
loadAllOceanBookings(),
|
||||
])
|
||||
if (partnerResult.status === 'fulfilled') partners.value = partnerResult.value
|
||||
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
|
||||
if (supplierResult.status === 'fulfilled') {
|
||||
truckingSuppliers.value = supplierResult.value.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
|
||||
if (form.id) reconcileTruckingSupplier()
|
||||
}
|
||||
else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message)
|
||||
if (bookingResult.status === 'fulfilled') oceanBookings.value = bookingResult.value
|
||||
else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message)
|
||||
businessOptionsError.value = errors.join(';')
|
||||
businessOptionsLoading.value = false
|
||||
if (partnersLoaded && truckingSuppliersLoaded && oceanBookingsLoaded) return
|
||||
if (businessOptionsPromise) return businessOptionsPromise
|
||||
businessOptionsPromise = (async () => {
|
||||
businessOptionsLoading.value = true
|
||||
businessOptionsError.value = ''
|
||||
const errors: string[] = []
|
||||
const [partnerResult, supplierResult, bookingResult] = await Promise.allSettled([
|
||||
partnersLoaded ? Promise.resolve(partners.value) : loadAllPartners(),
|
||||
loadTruckingSuppliers(),
|
||||
oceanBookingsLoaded ? Promise.resolve(oceanBookings.value) : loadAllOceanBookings(),
|
||||
])
|
||||
if (partnerResult.status === 'fulfilled') { partners.value = partnerResult.value; partnersLoaded = true }
|
||||
else errors.push(getApiErrorDetails(partnerResult.reason, '船公司与拖车公司加载失败').message)
|
||||
if (supplierResult.status === 'fulfilled') {
|
||||
if (form.id) reconcileTruckingSupplier()
|
||||
}
|
||||
else errors.push(getApiErrorDetails(supplierResult.reason, '拖车供应商加载失败').message)
|
||||
if (bookingResult.status === 'fulfilled') { oceanBookings.value = bookingResult.value; oceanBookingsLoaded = true }
|
||||
else errors.push(getApiErrorDetails(bookingResult.reason, '港口候选加载失败').message)
|
||||
businessOptionsError.value = errors.join(';')
|
||||
businessOptionsLoading.value = false
|
||||
})()
|
||||
try { await businessOptionsPromise }
|
||||
finally { businessOptionsPromise = null }
|
||||
}
|
||||
async function loadTruckingSuppliers(): Promise<Supplier[]> {
|
||||
if (truckingSuppliersLoaded) return truckingSuppliers.value
|
||||
if (truckingSuppliersPromise) return truckingSuppliersPromise
|
||||
truckingSuppliersPromise = (async () => {
|
||||
const result = await listSuppliers({ page: 1, size: 200, category: 'TRUCKING', status: 'ACTIVE' })
|
||||
const records = result.records.filter((supplier) => supplier.category === 'TRUCKING' && supplier.status === 'ACTIVE')
|
||||
truckingSuppliers.value = records
|
||||
truckingSuppliersLoaded = true
|
||||
return records
|
||||
})()
|
||||
try { return await truckingSuppliersPromise }
|
||||
finally { truckingSuppliersPromise = null }
|
||||
}
|
||||
async function ensureTruckingSuppliers(open: unknown = true) {
|
||||
if (!open) return false
|
||||
try { await loadTruckingSuppliers(); return true }
|
||||
catch (error) { ElMessage.error(getApiErrorDetails(error, '拖车供应商加载失败').message); return false }
|
||||
}
|
||||
async function loadAllPartners() {
|
||||
const firstPage = await listPlatformPartners({ page: 1, size: 200, status: 'ACTIVE' })
|
||||
@@ -386,11 +435,13 @@ async function loadAllOceanBookings() {
|
||||
const remaining = await Promise.all(Array.from({ length: firstPage.pages - 1 }, (_, index) => listOceanBookings({ page: index + 2, size: 200 })))
|
||||
return [firstPage, ...remaining].flatMap((pageResult) => pageResult.records)
|
||||
}
|
||||
function openSimulation() {
|
||||
async function openSimulation() {
|
||||
Object.assign(simulationForm, { channelCode: '', warehouseId: '', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000 })
|
||||
simulationChannels.value = []
|
||||
simulationError.value = null
|
||||
simulationOpen.value = true
|
||||
await loadWarehouses()
|
||||
if (warehouseError.value) simulationError.value = warehouseError.value
|
||||
}
|
||||
function selectSimulationContainerType(containerType: string) {
|
||||
applyContainerSpecification(simulationForm, containerType)
|
||||
@@ -477,20 +528,6 @@ async function saveLink() {
|
||||
function replaceDrayage(value: TerminalDrayageView) {
|
||||
drayages.value = { ...drayages.value, [value.containerBookingId]: value }
|
||||
}
|
||||
async function loadDrayages(targetRows = rows.value) {
|
||||
const sequence = ++drayageSequence
|
||||
if (!targetRows.length) { drayages.value = {}; drayageLoadError.value = null; return }
|
||||
drayageLoading.value = true; drayageLoadError.value = null
|
||||
try {
|
||||
const values = await listTerminalDrayages(targetRows.map((row) => row.id))
|
||||
if (sequence !== drayageSequence) return
|
||||
drayages.value = Object.fromEntries(values.map((value) => [value.containerBookingId, value]))
|
||||
} catch (error) {
|
||||
if (sequence !== drayageSequence) return
|
||||
drayages.value = {}
|
||||
drayageLoadError.value = getApiErrorDetails(error, '码头拖车信息加载失败,请重试')
|
||||
} finally { if (sequence === drayageSequence) drayageLoading.value = false }
|
||||
}
|
||||
async function refreshDrayage(containerBookingId: string) {
|
||||
const value = await getTerminalDrayage(containerBookingId)
|
||||
replaceDrayage(value)
|
||||
@@ -499,6 +536,7 @@ async function refreshDrayage(containerBookingId: string) {
|
||||
async function selectRowFleet(source: object, supplierIdValue: unknown) {
|
||||
const row = source as ContainerBookingResource
|
||||
const current = drayageFor(row.id)
|
||||
if (!(await ensureTruckingSuppliers())) return
|
||||
const supplier = truckingCompanies.value.find((item) => item.id === String(supplierIdValue || ''))
|
||||
if (!current || !supplier || !canDrayage(row, 'SELECT_FLEET') || drayageSavingIds.value.includes(row.id)) return
|
||||
drayageSavingIds.value = [...drayageSavingIds.value, row.id]
|
||||
@@ -528,10 +566,11 @@ async function saveDrayageAction() {
|
||||
const next = drayageAction.value === 'SCHEDULE_PICKUP'
|
||||
? await scheduleTerminalDrayagePickup(row.id, { expectedVersion: current.version, pickupAppointmentAt: drayageActionAt.value, reason })
|
||||
: drayageAction.value === 'RECORD_PICKUP'
|
||||
? await recordTerminalDrayagePickup(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, reason })
|
||||
: await recordTerminalDrayageEmptyReturn(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, reason })
|
||||
? await recordTerminalDrayagePickup(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, remark: reason })
|
||||
: await recordTerminalDrayageEmptyReturn(row.id, { expectedVersion: current.version, actualAt: drayageActionAt.value, remark: reason })
|
||||
replaceDrayage(next); drayageActionOpen.value = false
|
||||
ElMessage.success(`${drayageActionText(drayageAction.value)}成功`)
|
||||
await loadRows()
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, `${drayageActionText(drayageAction.value)}失败`)
|
||||
if (details.code === 'A0409') {
|
||||
@@ -558,11 +597,11 @@ async function loadRows() {
|
||||
page: page.value, size: pageSize.value, customerId: applied.value.customerId || undefined, keyword: applied.value.keyword.trim() || undefined,
|
||||
containerType: applied.value.containerType || undefined,
|
||||
status: applied.value.status, estimatedSailingFrom: from ? `${from}T00:00:00` : undefined,
|
||||
estimatedSailingTo: to ? `${to}T23:59:59` : undefined,
|
||||
})
|
||||
estimatedSailingTo: to ? `${to}T23:59:59` : undefined, scope: scope.value,
|
||||
}, props.workbench)
|
||||
if (sequence !== listSequence) return
|
||||
rows.value = result.records; total.value = result.total
|
||||
await loadDrayages(result.records)
|
||||
rows.value = result.records as ContainerWorkbenchRow[]; total.value = result.total
|
||||
drayages.value = Object.fromEntries(rows.value.flatMap((row) => row.terminalDrayage ? [[row.id, row.terminalDrayage as TerminalDrayageView]] : []))
|
||||
} catch (error) {
|
||||
if (sequence !== listSequence) return
|
||||
rows.value = []; total.value = 0; loadError.value = getApiErrorDetails(error, '货柜列表加载失败,请稍后重试')
|
||||
@@ -571,7 +610,7 @@ async function loadRows() {
|
||||
function search() { applied.value = { ...filters.value, sailingRange: [...filters.value.sailingRange] }; if (page.value === 1) void loadRows(); else page.value = 1 }
|
||||
function openDetail(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
void router.push({ name: 'container-detail', params: { containerId: row.id } })
|
||||
void router.push({ name: props.workbench === 'DOMESTIC' ? 'domestic-container-detail' : 'overseas-container-detail', params: { containerId: row.id } })
|
||||
}
|
||||
function handleMoreAction(command: string, source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
@@ -579,17 +618,14 @@ function handleMoreAction(command: string, source: object) {
|
||||
if (command === 'overview') { openDetail(row); return }
|
||||
if (command === 'drayage-history') { void openDrayageHistory(row); return }
|
||||
if (command.startsWith('drayage:')) { openDrayageAction(row, command.slice('drayage:'.length) as Exclude<TerminalDrayageAction, 'SELECT_FLEET'>); return }
|
||||
if (['exception', 'documents'].includes(command)) void router.push({ name: 'container-detail', params: { containerId: row.id }, query: { action: command } })
|
||||
if (command === 'documents' && isOverseas.value && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')) void router.push({ name: 'overseas-container-detail', params: { containerId: row.id }, query: { action: command } })
|
||||
}
|
||||
async function openEdit(source: object) {
|
||||
const row = source as ContainerBookingResource
|
||||
editorError.value = null
|
||||
try {
|
||||
const latest = await getContainer(row.id)
|
||||
if (!['WAIT_LOADING', 'EDITING'].includes(latest.planStatus)) {
|
||||
ElMessage.warning('当前阶段运输信息只读;真实集装箱号如需纠错,请进入预装清单')
|
||||
return
|
||||
}
|
||||
const [latest] = await Promise.all([getContainer(row.id, props.workbench), loadWarehouses(), loadBusinessOptions()])
|
||||
if (!isDomestic.value || !hasAction(latest, 'EDIT_CONTAINER')) return
|
||||
detail.value = latest
|
||||
replaceForm(latest)
|
||||
editorOpen.value = true
|
||||
@@ -612,7 +648,7 @@ async function save() {
|
||||
if (details.code === 'A0409' && form.id) {
|
||||
const submitted = { ...form }
|
||||
try {
|
||||
const latest = await getContainer(form.id)
|
||||
const latest = await getContainer(form.id, props.workbench)
|
||||
detail.value = latest
|
||||
replaceForm(latest)
|
||||
Object.assign(form, submitted, { id: latest.id, version: latest.version })
|
||||
@@ -635,26 +671,28 @@ function goContainerProcess(source: object) {
|
||||
return
|
||||
}
|
||||
void router.push({
|
||||
name: row.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
|
||||
query: { planId: row.planId || undefined, warehouseId: row.warehouseId },
|
||||
name: row.planStatus === 'LOADING' ? 'loading-plans' : 'domestic-container-detail',
|
||||
params: row.planStatus === 'COMPLETED' ? { containerId: row.id } : undefined,
|
||||
query: row.planStatus === 'COMPLETED' ? { tab: 'loading-list' } : { planId: row.planId || undefined, warehouseId: row.warehouseId },
|
||||
})
|
||||
}
|
||||
|
||||
watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) => {
|
||||
if (nextPage !== previousPage || nextSize !== previousSize) void loadRows()
|
||||
})
|
||||
watch(scope, () => { if (page.value === 1) void loadRows(); else page.value = 1 })
|
||||
watch(linkOpen, (open) => {
|
||||
if (open) return
|
||||
linkSequence += 1
|
||||
linkLoading.value = false
|
||||
})
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadCustomers(), loadWarehouses(), loadBusinessOptions(), loadRows()])
|
||||
await Promise.all([loadCustomers(), loadRows()])
|
||||
const linkContainerId = typeof route.query.linkContainerId === 'string' ? route.query.linkContainerId : ''
|
||||
if (!linkContainerId || !canEdit.value) return
|
||||
if (!isDomestic.value || !linkContainerId || !canEdit.value) return
|
||||
try {
|
||||
const target = await getContainer(linkContainerId)
|
||||
if (target.sourceType !== 'SIMULATION' || target.oceanBookingId || !['WAIT_LOADING', 'EDITING'].includes(target.planStatus)) {
|
||||
const target = await getContainer(linkContainerId, 'DOMESTIC')
|
||||
if (target.sourceType !== 'SIMULATION' || target.oceanBookingId || !hasAction(target, 'EDIT_CONTAINER')) {
|
||||
ElMessage.warning('当前货柜已关联订舱或所处阶段不允许关联')
|
||||
return
|
||||
}
|
||||
@@ -662,7 +700,7 @@ onMounted(async () => {
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorDetails(error, '关联货柜加载失败').message)
|
||||
} finally {
|
||||
void router.replace({ name: 'container-loading' })
|
||||
void router.replace({ name: 'domestic-container-workbench' })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -670,23 +708,28 @@ onMounted(async () => {
|
||||
<template>
|
||||
<AdminPage as="div">
|
||||
<section class="container-page">
|
||||
<ListPageLayout ariaLabel="货柜列表">
|
||||
<ListPageLayout :ariaLabel="isDomestic ? '送柜报关工作台' : '清关提柜工作台'">
|
||||
<template #toolbar>
|
||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
|
||||
<div class="container-query-panel">
|
||||
<div class="container-scope-tabs" role="tablist" aria-label="货柜完结状态">
|
||||
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'ACTIVE'" :tabindex="scope === 'ACTIVE' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'ACTIVE' }]" @click="scope = 'ACTIVE'">未完结</button>
|
||||
<button type="button" role="tab" aria-controls="container-workbench-results" :aria-selected="scope === 'HISTORY'" :tabindex="scope === 'HISTORY' ? 0 : -1" :class="['container-scope-tab', { 'is-active': scope === 'HISTORY' }]" @click="scope = 'HISTORY'">已完结</button>
|
||||
</div>
|
||||
<QueryToolbar v-model="filters" class="container-query-toolbar" ariaLabel="货柜筛选" :fields="queryFields" preference-key="container-loading-query" @search="search" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="loadError" #state>
|
||||
<LoadErrorState title="货柜列表加载失败" :message="loadError.message" :loading="loading" @retry="loadRows"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
</template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<el-alert v-if="drayageLoadError" class="drayage-load-alert" type="warning" :closable="false" show-icon :title="drayageLoadError.message"><el-button link type="warning" :loading="drayageLoading" @click="loadDrayages()">重新加载码头拖车信息</el-button></el-alert>
|
||||
<section v-if="!loadError" id="container-workbench-results" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
|
||||
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && ['WAIT_LOADING', 'EDITING'].includes(row.planStatus)" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />智能装柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="drayage-history"><FileText :size="15" />码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerBookingResource, 'SCHEDULE_PICKUP')" command="drayage:SCHEDULE_PICKUP">预约提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerBookingResource, 'RECORD_PICKUP')" command="drayage:RECORD_PICKUP">确认实际提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')" command="drayage:RECORD_EMPTY_RETURN">确认实际还柜</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && row.sourceType === 'SIMULATION' && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #toolbar-actions><el-button v-if="isDomestic && canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><template v-if="isDomestic"><el-button v-if="canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER')" size="small" @click="openEdit(row)"><Pencil :size="15" />编辑运输信息</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />智能装柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button></template><el-button v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_EMPTY_RETURN')" size="small" type="primary" @click="openDrayageAction(row as ContainerBookingResource, 'RECORD_EMPTY_RETURN')">确认还柜</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><template v-if="isOverseas"><el-dropdown-item command="drayage-history"><FileText :size="15" />码头拖车记录</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'SCHEDULE_PICKUP')" command="drayage:SCHEDULE_PICKUP">预约提柜</el-dropdown-item><el-dropdown-item v-if="canDrayage(row as ContainerWorkbenchRow, 'RECORD_PICKUP')" command="drayage:RECORD_PICKUP">确认实际提柜</el-dropdown-item><el-dropdown-item v-if="canOverseasDocumentEdit && hasAction(row as ContainerWorkbenchRow, 'MANAGE_DESTINATION_DOCUMENTS')" command="documents"><FileUp :size="15" />清关 / DO / PU</el-dropdown-item></template><el-dropdown-item v-if="isDomestic && canEdit && hasAction(row as ContainerWorkbenchRow, 'EDIT_CONTAINER') && row.sourceType === 'SIMULATION' && !row.oceanBookingId" command="link"><Link2 :size="15" />关联已放舱订舱</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #emptyReturnWarning="{ row }"><el-tag :type="drayageWarningType(row as ContainerBookingResource)" effect="light" :title="drayageWarningTitle(row as ContainerBookingResource)">{{ drayageWarningText(row as ContainerBookingResource) }}</el-tag></template>
|
||||
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
|
||||
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号:待补录' }}</small></span><span v-else>未关联</span></template>
|
||||
<template #containerNo="{ row }">{{ row.containerNo || '待补录' }}</template>
|
||||
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable placeholder="请选择车队" :loading="drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !truckingCompanies.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
|
||||
<template #terminalTruckingCompany="{ row }"><el-select class="fleet-select" :model-value="drayageFor(row.id)?.fleetSupplierId || ''" filterable placeholder="请选择车队" :loading="drayageSavingIds.includes(row.id)" :disabled="!canDrayage(row as ContainerBookingResource, 'SELECT_FLEET') || drayageSavingIds.includes(row.id)" @visible-change="ensureTruckingSuppliers" @update:model-value="selectRowFleet(row, $event)"><el-option v-if="drayageFor(row.id)?.fleetSupplierName && !truckingCompanies.some((item) => item.id === drayageFor(row.id)?.fleetSupplierId)" :label="drayageFor(row.id)?.fleetSupplierName || '原码头车队'" :value="drayageFor(row.id)?.fleetSupplierId || ''" disabled /><el-option v-for="supplier in truckingCompanies" :key="supplier.id" :label="supplier.name" :value="supplier.id" /></el-select></template>
|
||||
<template #pickupAppointmentAt="{ row }">{{ formatTime(drayageFor(row.id)?.pickupAppointmentAt) }}</template>
|
||||
<template #actualPickupAt="{ row }">{{ formatTime(drayageFor(row.id)?.actualPickupAt) }}</template>
|
||||
<template #estimatedEmptyReturnAt="{ row }">{{ formatTime(drayageFor(row.id)?.estimatedEmptyReturnAt) }}</template>
|
||||
@@ -697,9 +740,9 @@ onMounted(async () => {
|
||||
<template #vessel="{ row }">{{ [row.vesselName, row.voyageNo].filter(Boolean).join(' / ') || '—' }}</template>
|
||||
<template #loadingAt="{ row }">{{ formatTime(row.loadingAt) }}</template>
|
||||
<template #estimatedSailingAt="{ row }">{{ formatTime(row.estimatedSailingAt) }}</template>
|
||||
<template #actualSailingAt="{ row }">{{ formatTime(row.actualSailingAt) }}</template>
|
||||
<template #actualSailingAt="{ row }">{{ formatTime(row.actualFacts?.actualSailingAt) }}</template>
|
||||
<template #estimatedPortArrivalAt="{ row }">{{ formatTime(row.estimatedPortArrivalAt) }}</template>
|
||||
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualPortArrivalAt) }}</template>
|
||||
<template #actualPortArrivalAt="{ row }">{{ formatTime(row.actualFacts?.actualPortArrivalAt) }}</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
@@ -761,11 +804,11 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }.drayage-load-alert { margin-bottom: 12px; }.drayage-summary { margin-bottom: 14px; }
|
||||
.container-page { display: grid; gap: 16px; min-width: 0; }.container-page small { display: block; margin-top: 4px; color: var(--cc-muted); }.container-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.container-scope-tabs { display: flex; max-width: 100%; overflow-x: auto; margin-bottom: 0; border-bottom: 1px solid #e5e7eb; background: #f9fafb; scrollbar-width: thin; }.container-scope-tab { position: relative; flex: 0 0 auto; min-height: 44px; padding: 0 18px; border: 0; background: transparent; color: var(--cc-muted); font: inherit; font-size: 14px; cursor: pointer; }.container-scope-tab::after { position: absolute; right: 12px; bottom: 0; left: 12px; height: 3px; background: transparent; content: ''; }.container-scope-tab:hover { color: var(--cc-primary); background: #f6f9fd; }.container-scope-tab:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }.container-scope-tab.is-active { color: var(--cc-primary); font-weight: 600; }.container-scope-tab.is-active::after { background: var(--cc-primary); }.container-query-toolbar { border: 0; }.row-actions { display: flex; align-items: center; flex-wrap: nowrap; gap: 4px; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 0; }.form-grid { display: grid; grid-template-columns: minmax(0,1fr); gap: 0; }.form-grid :deep(.el-select),.form-grid :deep(.el-date-editor),.form-grid :deep(.el-input-number),.simulation-form :deep(.el-select),.simulation-form :deep(.el-input-number),.fleet-select { width: 100%; }.details :deep(.el-descriptions__label) { width: 120px; }.dialog-hint { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }.drayage-load-alert { margin-bottom: 12px; }.drayage-summary { margin-bottom: 14px; }
|
||||
:global(.container-editor-dialog.el-dialog) { display: flex; width: min(820px, calc(100vw - 28px)) !important; max-height: 90vh; margin-block: 5vh !important; overflow: hidden; flex-direction: column; border-radius: 12px; }
|
||||
:global(.container-editor-dialog .el-dialog__header) { flex: 0 0 auto; margin: 0; padding: 20px 28px; border-bottom: 1px solid #e5eaf1; }
|
||||
:global(.container-editor-dialog .el-dialog__title) { color: #1e293b; font-size: 18px; font-weight: 700; }
|
||||
:global(.container-editor-dialog .el-dialog__body) { min-height: 0; padding: 28px; overflow-y: auto; }
|
||||
:global(.container-editor-dialog .el-dialog__footer) { flex: 0 0 auto; padding: 16px 28px; border-top: 1px solid #e5eaf1; background: #f8fafc; }
|
||||
@media(max-width:720px){:global(.container-editor-dialog.el-dialog){max-height:calc(100vh - 20px);margin-block:10px!important}:global(.container-editor-dialog .el-dialog__header),:global(.container-editor-dialog .el-dialog__body),:global(.container-editor-dialog .el-dialog__footer){padding-inline:16px}.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
|
||||
@media(max-width:720px){.container-scope-tab{min-height:46px;padding:0 14px}:global(.container-editor-dialog.el-dialog){max-height:calc(100vh - 20px);margin-block:10px!important}:global(.container-editor-dialog .el-dialog__header),:global(.container-editor-dialog .el-dialog__body),:global(.container-editor-dialog .el-dialog__footer){padding-inline:16px}.details :deep(.el-descriptions__body table),.details :deep(tbody),.details :deep(tr),.details :deep(td){display:block;width:100%!important}.row-actions { min-width: 180px; }}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ContainerDetailView from './ContainerDetailView.vue'
|
||||
</script>
|
||||
|
||||
<template><ContainerDetailView workbench="DOMESTIC" /></template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ContainersView from './ContainersView.vue'
|
||||
</script>
|
||||
|
||||
<template><ContainersView workbench="DOMESTIC" /></template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ContainerDetailView from './ContainerDetailView.vue'
|
||||
</script>
|
||||
|
||||
<template><ContainerDetailView workbench="OVERSEAS" /></template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ContainersView from './ContainersView.vue'
|
||||
</script>
|
||||
|
||||
<template><ContainersView workbench="OVERSEAS" /></template>
|
||||
@@ -220,8 +220,9 @@ async function load() {
|
||||
function openOperationalList() {
|
||||
if (!container.value || container.value.planStatus === 'EDITING') return
|
||||
void router.push({
|
||||
name: container.value.planStatus === 'LOADING' ? 'loading-plans' : 'loading-lists',
|
||||
query: { planId: container.value.planId || undefined, warehouseId: container.value.warehouseId },
|
||||
name: container.value.planStatus === 'LOADING' ? 'loading-plans' : 'domestic-container-detail',
|
||||
params: container.value.planStatus === 'COMPLETED' ? { containerId: container.value.id } : undefined,
|
||||
query: container.value.planStatus === 'COMPLETED' ? { tab: 'loading-list' } : { planId: container.value.planId || undefined, warehouseId: container.value.warehouseId },
|
||||
})
|
||||
}
|
||||
function openAssign(source: object) {
|
||||
|
||||
Reference in New Issue
Block a user