feat(appointment): integrate work calendar and original business actions
This commit is contained in:
@@ -9,7 +9,24 @@ import {
|
||||
recordTerminalDrayageEmptyReturn,
|
||||
scheduleTerminalDrayagePickup,
|
||||
selectTerminalDrayageFleet,
|
||||
getDirectDeliveries, recordDirectDelivery, recordTerminalPickup,
|
||||
} from './terminalDrayages'
|
||||
it('records only the original actual PICKED_UP node with an immutable observed time and idempotency header', async () => {
|
||||
const command = { standardNode: 'PICKED_UP' as const, observedAt: '2026-10-15T09:00:00', estimatedAt: null, actualAt: '2026-10-14T23:00:00', locationCode: 'TEST-PORT', locationName: '测试码头', reason: '司机确认已提柜' }
|
||||
vi.mocked(http.post).mockResolvedValue({ data: {} })
|
||||
await recordTerminalPickup('9007199254740993', command, 'same-pickup-key')
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/container-shipping-nodes/9007199254740993', command, { headers: { 'Idempotency-Key': 'same-pickup-key' } })
|
||||
})
|
||||
|
||||
it('uses the actual direct-delivery source and stable idempotency key without coercing long IDs', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: [] }); await getDirectDeliveries('9007199254740993')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/terminal-drayages/9007199254740993/direct-deliveries')
|
||||
const request = { expectedVersion: 0, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '测试签收人', evidenceNo: 'POD-001', remark: null }
|
||||
vi.mocked(http.post).mockResolvedValue({ data: { requirementKey: 'INBOUND:9007199254740999', version: 1 } })
|
||||
await recordDirectDelivery('9007199254740993', 'INBOUND:9007199254740999', request, 'retry-same-key')
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/terminal-drayages/9007199254740993/direct-deliveries/9007199254740999', request, { headers: { 'Idempotency-Key': 'retry-same-key' } })
|
||||
await expect(recordDirectDelivery('1', 'ORDER:1', request, 'bad')).rejects.toThrow('标识无效')
|
||||
})
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn() },
|
||||
|
||||
@@ -108,21 +108,40 @@ export async function getTerminalDrayageHistory(containerBookingId: string) {
|
||||
return (unwrapApiResponse(response) || []).map((item) => ({ ...item, operatorId: id(item.operatorId) }))
|
||||
}
|
||||
|
||||
async function command(containerBookingId: string, action: string, body: object) {
|
||||
async function command(containerBookingId: string, action: string, body: object, idempotencyKey = operationId(action)) {
|
||||
const response = await http.post<ApiEnvelope<TerminalDrayageView>, ApiEnvelope<TerminalDrayageView>>(
|
||||
`${path}/${encodeURIComponent(containerBookingId)}/${action}`,
|
||||
body,
|
||||
{ headers: { 'Idempotency-Key': operationId(action) } },
|
||||
{ headers: { 'Idempotency-Key': idempotencyKey } },
|
||||
)
|
||||
return normalize(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export function selectTerminalDrayageFleet(containerBookingId: string, body: { expectedVersion: number; fleetSupplierId: string; fleetSupplierName?: string; reason?: string }) {
|
||||
return command(containerBookingId, 'fleet', body)
|
||||
export function selectTerminalDrayageFleet(containerBookingId: string, body: { expectedVersion: number; fleetSupplierId: string; fleetSupplierName?: string; reason?: string }, idempotencyKey?: string) {
|
||||
return command(containerBookingId, 'fleet', body, idempotencyKey)
|
||||
}
|
||||
export function scheduleTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; pickupAppointmentAt: string; reason?: string }) {
|
||||
return command(containerBookingId, 'pickup-appointment', body)
|
||||
export function scheduleTerminalDrayagePickup(containerBookingId: string, body: { expectedVersion: number; pickupAppointmentAt: string; reason?: string }, idempotencyKey?: string) {
|
||||
return command(containerBookingId, 'pickup-appointment', body, idempotencyKey)
|
||||
}
|
||||
export function recordTerminalDrayageEmptyReturn(containerBookingId: string, body: { expectedVersion: number; actualAt: string; remark?: string }) {
|
||||
return command(containerBookingId, 'empty-return', body)
|
||||
export function recordTerminalDrayageEmptyReturn(containerBookingId: string, body: { expectedVersion: number; actualAt: string; remark?: string }, idempotencyKey?: string) {
|
||||
return command(containerBookingId, 'empty-return', body, idempotencyKey)
|
||||
}
|
||||
|
||||
export interface DirectDelivery {
|
||||
requirementKey: string; inboundCode: string | null; orderNo: string | null; destinationName: string | null
|
||||
packageCount: number | null; version: number; actualAt: string | null; timezone: string | null
|
||||
receivedBy: string | null; evidenceNo: string | null; remark: string | null
|
||||
}
|
||||
export interface DirectDeliveryCommand { expectedVersion: number; actualAt: string; timezone: string; receivedBy: string; evidenceNo: string; remark: string | null }
|
||||
export async function getDirectDeliveries(containerBookingId: string) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<DirectDelivery[]>, ApiEnvelope<DirectDelivery[]>>(`${path}/${encodeURIComponent(containerBookingId)}/direct-deliveries`))
|
||||
}
|
||||
export async function recordDirectDelivery(containerBookingId: string, requirementKey: string, body: DirectDeliveryCommand, idempotencyKey: string) {
|
||||
if (!/^INBOUND:\d+$/.test(requirementKey)) throw new Error('服务端入仓来源标识无效,请重新读取清单')
|
||||
const inboundId = requirementKey.slice('INBOUND:'.length)
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<DirectDelivery>, ApiEnvelope<DirectDelivery>>(`${path}/${encodeURIComponent(containerBookingId)}/direct-deliveries/${encodeURIComponent(inboundId)}`, body, { headers: { 'Idempotency-Key': idempotencyKey } }))
|
||||
}
|
||||
export interface TerminalPickupCommand { standardNode: 'PICKED_UP'; observedAt: string; estimatedAt: null; actualAt: string; locationCode: string | null; locationName: string | null; reason: string }
|
||||
export async function recordTerminalPickup(containerBookingId: string, body: TerminalPickupCommand, idempotencyKey: string) {
|
||||
unwrapApiResponse(await http.post<ApiEnvelope<unknown>, ApiEnvelope<unknown>>(`/platform/v1/container-shipping-nodes/${encodeURIComponent(containerBookingId)}`, body, { headers: { 'Idempotency-Key': idempotencyKey } }))
|
||||
}
|
||||
|
||||
+97
-5
@@ -1,11 +1,13 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import ElementPlus, { ElDatePicker, ElInput, ElFormItem, ElSelect } from 'element-plus'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
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, linkContainerOceanBooking } from '../api/platformContainerLoading'
|
||||
import { listOceanBookingLinkCandidates } from '@/modules/ocean-bookings'
|
||||
import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages'
|
||||
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, scheduleTerminalDrayagePickup, selectTerminalDrayageFleet, recordTerminalPickup } from '../api/terminalDrayages'
|
||||
import { listSuppliers } from '@/modules/suppliers'
|
||||
import { listContainerArrivalTimes } from '../api/containerArrivalTimes'
|
||||
import { getContainerOutboundDispatch } from '@/modules/outbound-dispatch'
|
||||
import { ref } from 'vue'
|
||||
@@ -16,7 +18,7 @@ const canAccess = vi.hoisted(() => vi.fn<(code: string) => boolean>(() => true))
|
||||
const routeState = vi.hoisted(() => ({ params: { containerId: '9001' }, query: {} as Record<string, string> }))
|
||||
const routerPush = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canAccess }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: routerPush }) }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => routeState, useRouter: () => ({ push: routerPush }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||
vi.mock('@/modules/loading-lists', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/modules/loading-lists')>()
|
||||
const api = { getConfirmedLoadingList: vi.fn(), getPreloadPlan: vi.fn() }
|
||||
@@ -32,7 +34,8 @@ vi.mock('@/modules/ocean-bookings', () => ({ listOceanBookingLinkCandidates: vi.
|
||||
vi.mock('@/modules/outbound-dispatch', () => ({ confirmContainerOutboundDispatch: vi.fn(), getContainerOutboundDispatch: vi.fn() }))
|
||||
vi.mock('@/modules/port-schedules', () => ({ CarrierScheduleQueryPanel: { template: '<div />' }, ContainerPortSchedulePanel: { name: 'ContainerPortSchedulePanel', props: ['container'], emits: ['saved'], template: '<div />' } }))
|
||||
vi.mock('../api/platformContainerLoading', () => ({ getContainer: vi.fn(), getContainerOverview: vi.fn(), linkContainerOceanBooking: 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() }))
|
||||
vi.mock('../api/terminalDrayages', () => ({ recordTerminalDrayageEmptyReturn: vi.fn(), getDirectDeliveries: vi.fn(), recordDirectDelivery: vi.fn(), getTerminalDrayage: vi.fn(), scheduleTerminalDrayagePickup: vi.fn(), selectTerminalDrayageFleet: vi.fn(), recordTerminalPickup: vi.fn() }))
|
||||
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
|
||||
vi.mock('../api/containerArrivalTimes', () => ({ listContainerArrivalTimes: 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' } }
|
||||
@@ -66,6 +69,13 @@ describe('ContainerDetailView empty return', () => {
|
||||
})
|
||||
beforeEach(() => {
|
||||
routeState.query = {}
|
||||
vi.mocked(getDirectDeliveries).mockReset().mockResolvedValue([])
|
||||
vi.mocked(recordDirectDelivery).mockReset()
|
||||
vi.mocked(getTerminalDrayage).mockReset().mockResolvedValue(base.terminalDrayage as never)
|
||||
vi.mocked(scheduleTerminalDrayagePickup).mockReset().mockResolvedValue(base.terminalDrayage as never)
|
||||
vi.mocked(selectTerminalDrayageFleet).mockReset().mockResolvedValue(base.terminalDrayage as never)
|
||||
vi.mocked(recordTerminalPickup).mockReset().mockResolvedValue(undefined)
|
||||
vi.mocked(listSuppliers).mockReset().mockResolvedValue({ records: [], total: 0 } as never)
|
||||
routerPush.mockReset()
|
||||
canAccess.mockReset().mockReturnValue(true)
|
||||
vi.mocked(getContainerOutboundDispatch).mockReset().mockResolvedValue({ aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', gate: { inbounds: [] } }, timeline: [] } as never)
|
||||
@@ -76,6 +86,86 @@ describe('ContainerDetailView empty return', () => {
|
||||
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([{ sourceContainerBookingId: '9001', estimatedWarehouseArrivalAt: '2026-09-25T15:31:00', actualWarehouseArrivalAt: '2026-09-26T09:00:00', version: 0 }])
|
||||
})
|
||||
|
||||
it('records a real direct-delivery source with explicit local time and proof, retaining the request after an unknown outcome', async () => {
|
||||
const row = { requirementKey: 'INBOUND:9007199254740999', inboundCode: 'IN-TEST', orderNo: 'ORD-TEST', destinationName: '测试客户仓', packageCount: 10, version: 0, actualAt: null, timezone: null, receivedBy: null, evidenceNo: null, remark: null }
|
||||
vi.mocked(getDirectDeliveries).mockResolvedValue([row])
|
||||
vi.mocked(recordDirectDelivery).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce({ ...row, version: 1, actualAt: '2026-10-16T01:00:00' })
|
||||
const wrapper = await mountDetail()
|
||||
expect(getDirectDeliveries).not.toHaveBeenCalled()
|
||||
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
|
||||
await dialog.findAll('button').find(button => button.text() === '登记本票')!.trigger('click'); await flushPromises()
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
|
||||
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00')
|
||||
for (const [label, value] of [['地点时区(IANA)', 'America/Los_Angeles'], ['签收人', '测试签收人'], ['真实签收凭据号', 'POD-001']]) {
|
||||
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === label)!.findComponent(ElInput).vm.$emit('update:modelValue', value)
|
||||
}
|
||||
await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
|
||||
const first = vi.mocked(recordDirectDelivery).mock.calls[0]!
|
||||
expect(first.slice(0, 3)).toEqual(['9001', row.requirementKey, { expectedVersion: 0, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '测试签收人', evidenceNo: 'POD-001', remark: null }])
|
||||
expect(dialog.text()).not.toContain('9007199254740999')
|
||||
const close = vi.fn(); await dialog.props('beforeClose')!(close); expect(close).not.toHaveBeenCalled()
|
||||
dialog.vm.$emit('confirm'); await flushPromises()
|
||||
expect(recordDirectDelivery).toHaveBeenNthCalledWith(2, ...first)
|
||||
})
|
||||
|
||||
it('requires the original direct-unload write permission and keeps completed source evidence read-only', async () => {
|
||||
canAccess.mockImplementation(code => code !== 'platform:firstleg:terminal-drayage:direct-unload:confirm')
|
||||
vi.mocked(getDirectDeliveries).mockResolvedValue([{ requirementKey: 'INBOUND:9', inboundCode: 'IN-TEST', orderNo: null, destinationName: '测试客户仓', packageCount: null, version: 1, actualAt: '2026-10-16T01:00:00', timezone: 'America/Los_Angeles', receivedBy: '签收人', evidenceNo: 'POD-001', remark: null }])
|
||||
const wrapper = await mountDetail()
|
||||
await wrapper.findAll('button').find(button => button.text() === '直送卸货清单')!.trigger('click'); await flushPromises()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '直送卸货登记')!
|
||||
await dialog.findAll('button').find(button => button.text() === '查看签收')!.trigger('click'); await flushPromises()
|
||||
expect(dialog.props('showConfirm')).toBe(false); expect(dialog.text()).toContain('签收事实只读')
|
||||
dialog.vm.$emit('confirm'); await flushPromises(); expect(recordDirectDelivery).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the pickup action and saves an actual overseas supplier before the explicit pickup appointment', async () => {
|
||||
routeState.query = { action: 'pickup' }
|
||||
const current = { ...base.terminalDrayage, actualPickupAt: null, allowedActions: ['SELECT_FLEET', 'SCHEDULE_PICKUP'] }
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValueOnce(current as never).mockResolvedValue({ ...current, version: 4 } as never)
|
||||
vi.mocked(listSuppliers).mockResolvedValue({ records: [{ id: '9007199254740999', name: '测试海外车队' }], total: 1 } as never)
|
||||
const wrapper = await mountDetail()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
|
||||
expect(dialog.props('modelValue')).toBe(true); expect(selectTerminalDrayageFleet).not.toHaveBeenCalled()
|
||||
dialog.findComponent(ElSelect).vm.$emit('visible-change', true); await flushPromises()
|
||||
expect(listSuppliers).toHaveBeenCalledWith({ page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword: '' })
|
||||
dialog.findComponent(ElSelect).vm.$emit('update:modelValue', '9007199254740999'); await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '保存码头车队')!.trigger('click'); await flushPromises()
|
||||
expect(selectTerminalDrayageFleet).toHaveBeenCalledWith('9001', { expectedVersion: 3, fleetSupplierId: '9007199254740999', fleetSupplierName: '测试海外车队', reason: undefined }, expect.any(String))
|
||||
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
|
||||
expect(scheduleTerminalDrayagePickup).toHaveBeenCalledWith('9001', { expectedVersion: 4, pickupAppointmentAt: '2026-10-16T01:00:00', reason: undefined }, expect.any(String))
|
||||
})
|
||||
|
||||
it('keeps the same pickup request and idempotency key after an unknown response', async () => {
|
||||
routeState.query = { action: 'pickup' }
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, allowedActions: ['SCHEDULE_PICKUP'] } as never)
|
||||
vi.mocked(scheduleTerminalDrayagePickup).mockRejectedValueOnce(new Error('connection lost'))
|
||||
const wrapper = await mountDetail()
|
||||
const dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
|
||||
dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-16T01:00:00'); await flushPromises()
|
||||
await dialog.findAll('button').find(button => button.text() === '保存提柜预约')!.trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(scheduleTerminalDrayagePickup).mock.calls[0]!
|
||||
const done = vi.fn(); await dialog.props('beforeClose')!(done); expect(done).not.toHaveBeenCalled()
|
||||
await dialog.findAll('button').find(button => button.text() === '重试本次预约保存')!.trigger('click'); await flushPromises()
|
||||
expect(scheduleTerminalDrayagePickup).toHaveBeenNthCalledWith(2, ...first)
|
||||
})
|
||||
it('records actual pickup with an explicit actual time and preserves the same node on retry', async () => {
|
||||
routeState.query = { action: 'pickup' }
|
||||
vi.mocked(getTerminalDrayage).mockResolvedValue({ ...base.terminalDrayage, actualPickupAt: null } as never)
|
||||
vi.mocked(recordTerminalPickup).mockRejectedValueOnce(new Error('lost response'))
|
||||
const wrapper = await mountDetail(), dialog = wrapper.findAllComponents(AppDialog).find(component => component.props('title') === '提柜安排与执行')!
|
||||
await dialog.findAll('button').find(button => button.text() === '登记实际提柜')!.trigger('click'); await flushPromises(); expect(recordTerminalPickup).not.toHaveBeenCalled()
|
||||
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '实际提柜当地时间(登记实际时必填)')!.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-14T23:00:00')
|
||||
dialog.findAllComponents(ElFormItem).find(field => field.props('label') === '处理说明')!.findComponent(ElInput).vm.$emit('update:modelValue', '司机提供已提柜记录')
|
||||
await flushPromises(); await dialog.findAll('button').find(button => button.text() === '登记实际提柜')!.trigger('click'); await flushPromises()
|
||||
const first = vi.mocked(recordTerminalPickup).mock.calls[0]!
|
||||
expect(first[1]).toMatchObject({ standardNode: 'PICKED_UP', actualAt: '2026-10-14T23:00:00', estimatedAt: null, reason: '司机提供已提柜记录' })
|
||||
await dialog.findAll('button').find(button => button.text() === '重试本次实际提柜登记')!.trigger('click'); await flushPromises()
|
||||
expect(recordTerminalPickup).toHaveBeenNthCalledWith(2, ...first)
|
||||
})
|
||||
|
||||
it('loads departed gates exactly once when read permission arrives after the container detail', async () => {
|
||||
const granted = ref(false)
|
||||
canAccess.mockImplementation((code) => code === 'platform:firstleg:outbound-dispatch:view' ? granted.value : true)
|
||||
@@ -121,11 +211,13 @@ describe('ContainerDetailView empty return', () => {
|
||||
expect(wrapper.text()).not.toContain('装柜完成')
|
||||
await wrapper.findAll('button').find((item) => item.text() === '确认还柜')!.trigger('click')
|
||||
await flushPromises()
|
||||
const emptyDialog = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title') === '确认还柜')!
|
||||
emptyDialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-09-09T12:00:00'); 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(recordTerminalDrayageEmptyReturn).toHaveBeenCalledWith('9001', expect.objectContaining({ expectedVersion: 3, actualAt: '2026-09-09T12:00:00', remark: undefined }), expect.any(String))
|
||||
expect(getContainer).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('已还柜')
|
||||
expect(wrapper.find('.detail-section__heading').findAll('button').some((item) => item.text() === '确认还柜')).toBe(false)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { appointmentReturnTarget } from '@/modules/appointment-calendar'
|
||||
import { listSuppliers, type Supplier } from '@/modules/suppliers'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, FileUp } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import axios from 'axios'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
@@ -38,7 +41,7 @@ import {
|
||||
uploadContainerDocument,
|
||||
updateContainerPickupCode,
|
||||
} from '../api/platformContainerLoading'
|
||||
import { recordTerminalDrayageEmptyReturn } from '../api/terminalDrayages'
|
||||
import { recordTerminalDrayageEmptyReturn, getDirectDeliveries, recordDirectDelivery, getTerminalDrayage, selectTerminalDrayageFleet, scheduleTerminalDrayagePickup, recordTerminalPickup, type TerminalDrayageView, type DirectDelivery, type DirectDeliveryCommand } from '../api/terminalDrayages'
|
||||
import { listContainerArrivalTimes, type ContainerArrivalTime } from '../api/containerArrivalTimes'
|
||||
import type { BusinessDocument, ContainerBookingResource, ContainerOverview, ContainerOverviewDocument, ContainerPlanStatus, ContainerWorkbenchAction, ContainerWorkbenchRow } from '../types'
|
||||
|
||||
@@ -99,6 +102,136 @@ 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 canReadDirect = computed(() => isOverseas.value && access.can('platform:firstleg:terminal-drayage:list'))
|
||||
const terminalOpen = ref(false), terminalLoading = ref(false), terminalSaving = ref(false)
|
||||
const terminal = ref<TerminalDrayageView | null>(null), terminalError = ref<ApiErrorDetails | null>(null), terminalSaveError = ref<ApiErrorDetails | null>(null)
|
||||
const terminalForm = reactive({ supplierId: '', pickupAt: '', actualAt: '', locationCode: '', locationName: '', reason: '' })
|
||||
const terminalBaseline = ref(JSON.stringify(terminalForm))
|
||||
const terminalSuppliers = ref<Supplier[]>([]), supplierLoading = ref(false), supplierError = ref<ApiErrorDetails | null>(null)
|
||||
type TerminalRequest = { kind: 'fleet'; body: Parameters<typeof selectTerminalDrayageFleet>[1]; key: string } | { kind: 'appointment'; body: Parameters<typeof scheduleTerminalDrayagePickup>[1]; key: string } | { kind: 'actual'; body: Parameters<typeof recordTerminalPickup>[1]; key: string }
|
||||
const terminalPending = ref<TerminalRequest | null>(null)
|
||||
const canSelectFleet = computed(() => canReadDirect.value && access.can('platform:firstleg:terminal-drayage:fleet:edit') && terminal.value?.allowedActions.includes('SELECT_FLEET'))
|
||||
const canSchedulePickup = computed(() => canReadDirect.value && access.can('platform:firstleg:terminal-drayage:pickup-appointment:edit') && terminal.value?.allowedActions.includes('SCHEDULE_PICKUP'))
|
||||
const canRecordPickup = computed(() => canReadDirect.value && access.can('platform:firstleg:port-schedule-query:manual') && !!terminal.value && !terminal.value.actualPickupAt && !terminal.value.actualEmptyReturnAt)
|
||||
async function loadTerminal() {
|
||||
if (!canReadDirect.value) return
|
||||
terminalLoading.value = true; terminalError.value = null
|
||||
try { terminal.value = await getTerminalDrayage(containerId.value) }
|
||||
catch (cause) { terminalError.value = getApiErrorDetails(cause, '提柜资料读取失败') }
|
||||
finally { terminalLoading.value = false }
|
||||
}
|
||||
let supplierRequest = 0
|
||||
async function searchTerminalSuppliers(keyword = '') {
|
||||
if (!access.can('platform:supplier:list')) { supplierError.value = { message: '当前没有供应商读取权限' }; return }
|
||||
const request = ++supplierRequest; supplierLoading.value = true; supplierError.value = null
|
||||
try { const result = await listSuppliers({ page: 1, size: 20, category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE', keyword }); if (request === supplierRequest) terminalSuppliers.value = result.records }
|
||||
catch (cause) { if (request === supplierRequest) supplierError.value = getApiErrorDetails(cause, '海外车队读取失败') }
|
||||
finally { if (request === supplierRequest) supplierLoading.value = false }
|
||||
}
|
||||
async function openTerminal() {
|
||||
if (!canReadDirect.value || !await leaveDirect()) return
|
||||
terminalOpen.value = true; terminalSaveError.value = null; await loadTerminal()
|
||||
if (!terminal.value || terminalError.value) return
|
||||
Object.assign(terminalForm, { supplierId: terminal.value.fleetSupplierId || '', pickupAt: terminal.value.pickupAppointmentAt || '', actualAt: '', locationCode: '', locationName: '', reason: '' })
|
||||
terminalBaseline.value = JSON.stringify(terminalForm)
|
||||
}
|
||||
function isDefiniteRejection(cause: unknown) {
|
||||
const status = cause instanceof ApiResponseError ? cause.status : axios.isAxiosError(cause) ? cause.response?.status : undefined
|
||||
return [400, 403, 404, 409, 422].includes(status || 0) || /^(HTTP_|A0?)?(400|403|404|409|422)$/.test(getApiErrorDetails(cause).code || '')
|
||||
}
|
||||
async function saveTerminal(kind: 'fleet' | 'appointment' | 'actual') {
|
||||
if (terminalLoading.value || terminalSaving.value || terminalError.value || !terminal.value || (kind === 'fleet' ? !canSelectFleet.value : kind === 'appointment' ? !canSchedulePickup.value : !canRecordPickup.value)) return
|
||||
if (terminalPending.value && terminalPending.value.kind !== kind) return
|
||||
if (!terminalPending.value) {
|
||||
const key = crypto.randomUUID(), expectedVersion = terminal.value.version
|
||||
if (kind === 'fleet') {
|
||||
const supplier = terminalSuppliers.value.find(row => row.id === terminalForm.supplierId)
|
||||
if (!supplier || supplierLoading.value || supplierError.value) { ElMessage.warning('请选择查询结果中的有效海外车队'); return }
|
||||
terminalPending.value = { kind, key, body: { expectedVersion, fleetSupplierId: supplier.id, fleetSupplierName: supplier.name, reason: terminalForm.reason.trim() || undefined } }
|
||||
} else if (kind === 'appointment') {
|
||||
if (!terminalForm.pickupAt) { ElMessage.warning('请填写明确的当地提柜预约时间'); return }
|
||||
terminalPending.value = { kind, key, body: { expectedVersion, pickupAppointmentAt: terminalForm.pickupAt, reason: terminalForm.reason.trim() || undefined } }
|
||||
} else {
|
||||
if (!terminalForm.actualAt || !terminalForm.reason.trim()) { ElMessage.warning('请填写真实提柜当地时间和登记依据'); return }
|
||||
terminalPending.value = { kind, key, body: { standardNode: 'PICKED_UP', observedAt: localDateTimeValue(), estimatedAt: null, actualAt: terminalForm.actualAt, locationCode: terminalForm.locationCode.trim() || null, locationName: terminalForm.locationName.trim() || null, reason: terminalForm.reason.trim() } }
|
||||
}
|
||||
}
|
||||
terminalSaving.value = true; terminalSaveError.value = null
|
||||
try {
|
||||
const request = terminalPending.value
|
||||
if (request.kind === 'fleet') await selectTerminalDrayageFleet(containerId.value, request.body, request.key)
|
||||
else if (request.kind === 'appointment') await scheduleTerminalDrayagePickup(containerId.value, request.body, request.key)
|
||||
else await recordTerminalPickup(containerId.value, request.body, request.key)
|
||||
terminalPending.value = null; terminalBaseline.value = JSON.stringify(terminalForm)
|
||||
ElMessage.success(kind === 'fleet' ? '码头车队已保存' : kind === 'appointment' ? '提柜预约已保存' : '实际提柜已登记'); await Promise.all([loadTerminal(), load()])
|
||||
} catch (cause) {
|
||||
terminalSaveError.value = getApiErrorDetails(cause, '操作结果待确认,请重试本次提交')
|
||||
if (isDefiniteRejection(cause)) { terminalPending.value = null; await loadTerminal() }
|
||||
} finally { terminalSaving.value = false }
|
||||
}
|
||||
async function leaveTerminal() {
|
||||
if (terminalSaving.value || terminalPending.value) { ElMessage.warning('本次提柜安排结果尚未确定,请先重试确认结果'); return false }
|
||||
if (!terminalOpen.value || terminalBaseline.value === JSON.stringify(terminalForm)) return true
|
||||
try { await ElMessageBox.confirm('尚有未保存的提柜安排,确认放弃本次输入?', '未保存的安排', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
}
|
||||
async function closeTerminal(done: () => void) { if (await leaveTerminal()) { terminalBaseline.value = JSON.stringify(terminalForm); done() } }
|
||||
onBeforeRouteLeave(leaveTerminal)
|
||||
const canWriteDirect = computed(() => canReadDirect.value && access.can('platform:firstleg:terminal-drayage:direct-unload:confirm'))
|
||||
const directOpen = ref(false), directLoading = ref(false), directSaving = ref(false)
|
||||
const directRows = ref<DirectDelivery[]>([]), directSelected = ref<DirectDelivery | null>(null)
|
||||
const directError = ref<ApiErrorDetails | null>(null), directSaveError = ref<ApiErrorDetails | null>(null)
|
||||
const directForm = reactive({ actualAt: '', timezone: '', receivedBy: '', evidenceNo: '', remark: '' })
|
||||
const directBaseline = ref(JSON.stringify(directForm))
|
||||
const directPending = ref<{ containerId: string; requirementKey: string; body: DirectDeliveryCommand; key: string } | null>(null)
|
||||
async function leaveDirect() {
|
||||
if (directSaving.value || directPending.value) { ElMessage.warning('本次卸货登记结果尚未确定,请先重试确认结果'); return false }
|
||||
if (!directOpen.value || JSON.stringify(directForm) === directBaseline.value) return true
|
||||
try { await ElMessageBox.confirm('尚有未保存的卸货登记,确认放弃本次输入?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
}
|
||||
onBeforeRouteLeave(leaveDirect)
|
||||
onBeforeRouteUpdate(async (to, from) => {
|
||||
if (to.params.containerId === from.params.containerId) return true
|
||||
if (!await leaveDirect() || !await leaveTerminal() || !await leaveEmptyReturn()) return false
|
||||
terminalOpen.value = false; terminal.value = null; emptyReturnOpen.value = false
|
||||
directOpen.value = false; directSelected.value = null; directRows.value = []
|
||||
directBaseline.value = JSON.stringify(directForm); return true
|
||||
})
|
||||
async function closeDirect(done: () => void) { if (await leaveDirect()) { directBaseline.value = JSON.stringify(directForm); done() } }
|
||||
async function loadDirect() {
|
||||
if (!canReadDirect.value) return
|
||||
directLoading.value = true; directError.value = null
|
||||
try {
|
||||
directRows.value = await getDirectDeliveries(containerId.value)
|
||||
if (directSelected.value) directSelected.value = directRows.value.find(row => row.requirementKey === directSelected.value?.requirementKey) || null
|
||||
} catch (cause) { directError.value = getApiErrorDetails(cause, '直送卸货清单读取失败') }
|
||||
finally { directLoading.value = false }
|
||||
}
|
||||
async function openDirect() { if (!canReadDirect.value || directSaving.value) return; directOpen.value = true; await loadDirect() }
|
||||
async function selectDirect(row: DirectDelivery) {
|
||||
if (!await leaveDirect()) return
|
||||
directSelected.value = row; directSaveError.value = null
|
||||
Object.assign(directForm, { actualAt: row.actualAt || '', timezone: row.timezone || '', receivedBy: row.receivedBy || '', evidenceNo: row.evidenceNo || '', remark: row.remark || '' })
|
||||
directBaseline.value = JSON.stringify(directForm)
|
||||
}
|
||||
async function saveDirect() {
|
||||
if (!canWriteDirect.value || directSaving.value || directLoading.value || directError.value || !directSelected.value || (directSelected.value.actualAt && !directPending.value)) return
|
||||
if (!directPending.value) {
|
||||
if (!directForm.actualAt || !directForm.timezone.trim() || !directForm.receivedBy.trim() || !directForm.evidenceNo.trim()) { ElMessage.warning('请填写实际当地时间、地点时区、签收人和真实凭据号'); return }
|
||||
try { new Intl.DateTimeFormat('en-US', { timeZone: directForm.timezone.trim() }) } catch { ElMessage.warning('请填写有效的 IANA 地点时区'); return }
|
||||
directPending.value = { containerId: containerId.value, requirementKey: directSelected.value.requirementKey, key: crypto.randomUUID(), body: { expectedVersion: directSelected.value.version, actualAt: directForm.actualAt.length === 16 ? `${directForm.actualAt}:00` : directForm.actualAt, timezone: directForm.timezone.trim(), receivedBy: directForm.receivedBy.trim(), evidenceNo: directForm.evidenceNo.trim(), remark: directForm.remark.trim() || null } }
|
||||
}
|
||||
directSaving.value = true; directSaveError.value = null
|
||||
try {
|
||||
const request = directPending.value
|
||||
await recordDirectDelivery(request.containerId, request.requirementKey, request.body, request.key)
|
||||
directPending.value = null; directBaseline.value = JSON.stringify(directForm)
|
||||
ElMessage.success('本票直送卸货已登记'); await Promise.all([loadDirect(), load()])
|
||||
} catch (cause) {
|
||||
directSaveError.value = getApiErrorDetails(cause, '卸货登记结果待确认,请重试本次登记')
|
||||
const status = cause instanceof ApiResponseError ? cause.status : axios.isAxiosError(cause) ? cause.response?.status : undefined
|
||||
if ([400, 403, 404, 409, 422].includes(status || 0) || /^(HTTP_|A0?)?(400|403|404|409|422)$/.test(directSaveError.value.code || '')) { directPending.value = null; await loadDirect() }
|
||||
} finally { directSaving.value = false }
|
||||
}
|
||||
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'))
|
||||
@@ -150,6 +283,14 @@ const emptyReturnAt = ref('')
|
||||
const emptyReturnReason = ref('')
|
||||
const emptyReturnSaving = ref(false)
|
||||
const emptyReturnError = ref<ApiErrorDetails | null>(null)
|
||||
const emptyReturnPending = ref<{ body: Parameters<typeof recordTerminalDrayageEmptyReturn>[1]; key: string } | null>(null)
|
||||
async function leaveEmptyReturn() {
|
||||
if (emptyReturnSaving.value || emptyReturnPending.value) { ElMessage.warning('还空登记结果尚未确定,请重试确认结果'); return false }
|
||||
if (!emptyReturnOpen.value || (!emptyReturnAt.value && !emptyReturnReason.value)) return true
|
||||
try { await ElMessageBox.confirm('尚有未保存的还空登记,确认放弃?', '未保存的登记', { customClass: 'admin-density-confirm', type: 'warning' }); return true } catch { return false }
|
||||
}
|
||||
async function closeEmptyReturn(done: () => void) { if (await leaveEmptyReturn()) done() }
|
||||
onBeforeRouteLeave(leaveEmptyReturn)
|
||||
const oceanBookingLinkOpen = ref(false)
|
||||
|
||||
const selectedDocument = computed(() => overview.value?.documents.find((item) => item.documentType === documentType.value) || null)
|
||||
@@ -264,8 +405,8 @@ function localDateTimeValue() {
|
||||
return now.toISOString().slice(0, 19)
|
||||
}
|
||||
function openEmptyReturn() {
|
||||
if (!canRecordEmptyReturn.value || emptyReturnSaving.value) return
|
||||
emptyReturnAt.value = localDateTimeValue()
|
||||
if (!canRecordEmptyReturn.value || emptyReturnSaving.value || emptyReturnPending.value) return
|
||||
emptyReturnAt.value = ''
|
||||
emptyReturnReason.value = ''
|
||||
emptyReturnError.value = null
|
||||
emptyReturnOpen.value = true
|
||||
@@ -275,18 +416,16 @@ async function confirmEmptyReturn() {
|
||||
if (!current || !canRecordEmptyReturn.value || !emptyReturnAt.value || emptyReturnSaving.value) return
|
||||
emptyReturnSaving.value = true
|
||||
emptyReturnError.value = null
|
||||
emptyReturnPending.value ||= { body: { expectedVersion: current.version, actualAt: emptyReturnAt.value, remark: emptyReturnReason.value.trim() || undefined }, key: crypto.randomUUID() }
|
||||
try {
|
||||
await recordTerminalDrayageEmptyReturn(containerId.value, {
|
||||
expectedVersion: current.version,
|
||||
actualAt: emptyReturnAt.value,
|
||||
remark: emptyReturnReason.value.trim() || undefined,
|
||||
})
|
||||
await recordTerminalDrayageEmptyReturn(containerId.value, emptyReturnPending.value.body, emptyReturnPending.value.key)
|
||||
emptyReturnPending.value = null
|
||||
emptyReturnOpen.value = false
|
||||
ElMessage.success('还柜已确认')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
const details = getApiErrorDetails(cause, '确认还柜失败')
|
||||
if (details.code === 'A0409') await load()
|
||||
if (isDefiniteRejection(cause)) { emptyReturnPending.value = null; await load() }
|
||||
emptyReturnError.value = details
|
||||
} finally { emptyReturnSaving.value = false }
|
||||
}
|
||||
@@ -505,15 +644,29 @@ async function completeClearance() {
|
||||
finally { clearanceSaving.value = false }
|
||||
}
|
||||
|
||||
function goBack() { void router.push({ name: isDomestic.value ? 'domestic-container-workbench' : 'overseas-container-workbench' }) }
|
||||
function goBack() { void router.push(appointmentReturnTarget(route.query) || { name: isDomestic.value ? 'domestic-container-workbench' : 'overseas-container-workbench' }) }
|
||||
|
||||
onMounted(async () => { await load(); if (isOverseas.value && route.query.action === 'documents') openDocument('DELIVERY_ORDER') })
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
if (!isOverseas.value) return
|
||||
if (route.query.action === 'documents') openDocument('DELIVERY_ORDER')
|
||||
if (route.query.action === 'pickup') await openTerminal()
|
||||
if (route.query.action === 'direct-unload') {
|
||||
await openDirect()
|
||||
const selected = directRows.value.find(row => row.requirementKey === route.query.requirementKey)
|
||||
if (selected) await selectDirect(selected)
|
||||
}
|
||||
if (route.query.action === 'empty-return') {
|
||||
if (canRecordEmptyReturn.value) openEmptyReturn()
|
||||
else ElMessage.warning('当前还空资格已变化,请核对卸货事实和当前操作权限。')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage class="container-detail-page admin-density">
|
||||
<section class="container-overview surface-panel">
|
||||
<header class="container-overview__header"><el-button link class="density-back" type="primary" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button><div><h1>{{ title }}</h1>
|
||||
<header class="container-overview__header"><el-button link class="density-back" type="primary" @click="goBack"><ArrowLeft :size="16" />{{ appointmentReturnTarget(route.query) ? '返回预约工作' : '返回列表' }}</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>
|
||||
@@ -570,7 +723,7 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<header class="detail-section__heading"><h2 id="container-overseas-receiving-heading">提柜、卸柜与还空</h2><div class="density-actions"><el-button v-if="canReadDirect" @click="openTerminal">提柜安排与执行</el-button><el-button v-if="canReadDirect" @click="openDirect">直送卸货清单</el-button><el-button v-if="canRecordEmptyReturn" type="primary" :loading="emptyReturnSaving" @click="openEmptyReturn">确认还柜</el-button></div></header>
|
||||
<div class="detail-fields"><dl class="density-fields"><div><dt>码头拖车状态</dt><dd>{{ workbenchDetail?.statusSummary?.terminalDrayageStatus || '—' }}</dd></div><div><dt>码头车队</dt><dd>{{ workbenchDetail?.terminalDrayage?.fleetSupplierName || '—' }}</dd></div><div><dt>预计到仓</dt><dd>{{ formatTime(arrivalTime?.estimatedWarehouseArrivalAt) }}</dd></div><div><dt>实际到仓</dt><dd>{{ formatTime(arrivalTime?.actualWarehouseArrivalAt) }}</dd></div><div><dt>卸柜完成</dt><dd>{{ formatTime((workbenchDetail?.actualFacts?.unloadingCompletedAt || workbenchDetail?.terminalDrayage?.unloadingCompletedAt) as string | null) }}</dd></div><div><dt>实际还柜</dt><dd>{{ formatTime(workbenchDetail?.terminalDrayage?.actualEmptyReturnAt) }}</dd></div></dl></div>
|
||||
</section>
|
||||
|
||||
@@ -593,10 +746,35 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
</template>
|
||||
|
||||
<OceanBookingLinkDialog v-model="oceanBookingLinkOpen" :container="detail" @linked="load" />
|
||||
<AppDialog class="admin-density-dialog" 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 class="density-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 class="admin-density-dialog" v-model="emptyReturnOpen" title="确认还柜" width="min(520px, calc(100vw - 28px))" :confirm-text="emptyReturnPending ? '重试本次还空登记' : '确认还柜'" :before-close="closeEmptyReturn" :confirm-loading="emptyReturnSaving" :confirm-disabled="!emptyReturnAt || !canRecordEmptyReturn" @confirm="confirmEmptyReturn"><el-alert title="提交后货柜将进入已还柜状态,请核对实际还柜时间。" type="warning" :closable="false" show-icon /><el-form class="density-form" label-position="top" :disabled="emptyReturnSaving || !!emptyReturnPending"><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 class="admin-density-dialog" v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form class="density-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 class="admin-density-dialog" 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 class="density-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 class="admin-density-dialog" 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 class="density-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>
|
||||
<AppDialog class="admin-density-dialog" v-model="directOpen" title="直送卸货登记" width="1080px" :show-confirm="canWriteDirect && !!directSelected && !directSelected.actualAt" :confirm-text="directPending ? '重试本次登记' : '确认本票卸货'" :confirm-loading="directSaving" :confirm-disabled="directLoading || !!directError" :before-close="closeDirect" @confirm="saveDirect">
|
||||
<p>按真实装柜来源逐票登记签收;全部来源完成后由服务端核验还空资格。海外仓接仓仍使用原仓库收货流程。</p>
|
||||
<LoadErrorState v-if="directError" title="直送卸货清单读取失败" :message="directError.message" :loading="directLoading" @retry="loadDirect"><template #details><ApiErrorMeta :details="directError" /></template></LoadErrorState>
|
||||
<p v-if="directLoading">正在读取实际装柜来源…</p>
|
||||
<table v-if="!directError && !directLoading" class="density-table"><thead><tr><th>操作</th><th>入仓码 / 订单号</th><th>目的地</th><th>箱数</th><th>实际卸货</th></tr></thead><tbody><tr v-for="row in directRows" :key="row.requirementKey"><td><el-button class="density-action" :disabled="directSaving || !!directPending" @click="selectDirect(row)">{{ row.actualAt ? '查看签收' : canWriteDirect ? '登记本票' : '查看本票' }}</el-button></td><td>{{ row.inboundCode || row.orderNo || '—' }}</td><td>{{ row.destinationName || '—' }}</td><td>{{ row.packageCount ?? '—' }}</td><td>{{ row.actualAt || '尚未登记' }} {{ row.timezone || '' }}</td></tr></tbody></table>
|
||||
<p v-if="!directLoading && !directError && !directRows.length">当前没有可登记的直送来源。</p>
|
||||
<template v-if="directSelected"><h3>{{ directSelected.inboundCode || directSelected.orderNo || '本票签收' }}</h3><el-form class="density-form" label-position="top" :disabled="!canWriteDirect || !!directSelected.actualAt || directSaving || !!directPending || directLoading || !!directError"><el-form-item label="实际卸货当地时间" required><el-date-picker v-model="directForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="地点时区(IANA)" required><el-input v-model="directForm.timezone" placeholder="例如 America/Los_Angeles" /></el-form-item><el-form-item label="签收人" required><el-input v-model="directForm.receivedBy" maxlength="128" placeholder="请输入实际签收人" /></el-form-item><el-form-item label="真实签收凭据号" required><el-input v-model="directForm.evidenceNo" maxlength="128" placeholder="请输入真实签收凭据号" /></el-form-item><el-form-item label="备注"><el-input v-model="directForm.remark" type="textarea" maxlength="1000" placeholder="请输入卸货备注(选填)" /></el-form-item></el-form><p v-if="directSelected.actualAt">该票已登记,签收事实只读。</p></template>
|
||||
<LoadErrorState v-if="directSaveError" title="卸货登记未完成" :message="directSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="directSaveError" /></template></LoadErrorState>
|
||||
</AppDialog>
|
||||
<AppDialog class="admin-density-dialog" v-model="terminalOpen" title="提柜安排与执行" width="780px" :show-confirm="false" :before-close="closeTerminal">
|
||||
<LoadErrorState v-if="terminalError" title="提柜资料读取失败" :message="terminalError.message" :loading="terminalLoading" @retry="loadTerminal"><template #details><ApiErrorMeta :details="terminalError" /></template></LoadErrorState>
|
||||
<p v-if="terminalLoading">正在核对码头拖车当前状态…</p>
|
||||
<template v-if="terminal"><p>{{ terminal.containerNo || title }} · 当前车队:{{ terminal.fleetSupplierName || '尚未安排' }}</p><p>实际提柜:{{ terminal.actualPickupAt || '尚未登记' }}。时间沿用原业务当地时间。</p>
|
||||
<el-form class="density-form" label-position="top" :disabled="terminalLoading || terminalSaving || !!terminalPending || !!terminalError">
|
||||
<el-form-item label="码头车队"><el-select v-model="terminalForm.supplierId" filterable remote :remote-method="searchTerminalSuppliers" :loading="supplierLoading" :disabled="!canSelectFleet || !access.can('platform:supplier:list')" placeholder="搜索有效海外车队" @visible-change="visible => visible && searchTerminalSuppliers()"><el-option v-if="terminal.fleetSupplierId && !terminalSuppliers.some(row => row.id === terminal?.fleetSupplierId)" :value="terminal.fleetSupplierId" :label="terminal.fleetSupplierName || '原车队'" disabled /><el-option v-for="supplier in terminalSuppliers" :key="supplier.id" :value="supplier.id" :label="supplier.name" /></el-select></el-form-item>
|
||||
<LoadErrorState v-if="supplierError" title="海外车队读取失败" :message="supplierError.message" :show-retry="false" />
|
||||
<el-form-item label="提柜预约当地时间"><el-date-picker v-model="terminalForm.pickupAt" :disabled="!canSchedulePickup" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item>
|
||||
<template v-if="canRecordPickup"><el-form-item label="实际提柜当地时间(登记实际时必填)"><el-date-picker v-model="terminalForm.actualAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="实际地点代码"><el-input v-model="terminalForm.locationCode" maxlength="100" placeholder="请输入实际提柜地点代码(选填)" /></el-form-item><el-form-item label="实际地点名称"><el-input v-model="terminalForm.locationName" maxlength="200" placeholder="请输入实际提柜地点名称(选填)" /></el-form-item></template>
|
||||
<el-form-item label="处理说明"><el-input v-model="terminalForm.reason" type="textarea" maxlength="1000" placeholder="请输入处理说明;登记实际提柜时必填" /></el-form-item>
|
||||
</el-form>
|
||||
<div class="density-actions"><el-button v-if="canSelectFleet" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'fleet')" :loading="terminalSaving" @click="saveTerminal('fleet')">{{ terminalPending?.kind === 'fleet' ? '重试本次车队保存' : '保存码头车队' }}</el-button><el-button v-if="canSchedulePickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'appointment')" :loading="terminalSaving" @click="saveTerminal('appointment')">{{ terminalPending?.kind === 'appointment' ? '重试本次预约保存' : '保存提柜预约' }}</el-button></div>
|
||||
<el-button v-if="canRecordPickup" type="primary" :disabled="terminalLoading || !!terminalError || (!!terminalPending && terminalPending.kind !== 'actual')" :loading="terminalSaving" @click="saveTerminal('actual')">{{ terminalPending?.kind === 'actual' ? '重试本次实际提柜登记' : '登记实际提柜' }}</el-button>
|
||||
<LoadErrorState v-if="terminalSaveError" title="提柜操作未完成" :message="terminalSaveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="terminalSaveError" /></template></LoadErrorState>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user