This commit is contained in:
2026-11-02 17:15:37 +08:00
parent 7704532e8f
commit f8408b8076
26 changed files with 595 additions and 52 deletions
@@ -0,0 +1,23 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
export type LooseboardPhotoPhase = 'PICKUP' | 'ARRIVAL'
export interface LooseboardPhoto { fileId: string; fileName: string; contentType: string; sizeBytes: string | number }
export interface LooseboardExecutionItem { itemId: string; customerId: string; orderNo: string; destination: string | null; packages: number; pickupPhotos: LooseboardPhoto[]; arrivalPhotos: LooseboardPhoto[]; canPickup: boolean; canArrive: boolean }
export interface LooseboardExecution { dispatchNo: string; recordVersion: string | number; stage: string; direct: boolean; items: LooseboardExecutionItem[] }
const path = (number: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}/looseboard-execution`
const itemPath = (number: string, item: string, phase: LooseboardPhotoPhase) => `${path(number)}/${encodeURIComponent(item)}/${phase}`
export async function getLooseboardExecution(number: string) {
return unwrapApiResponse(await http.get<ApiEnvelope<LooseboardExecution>, ApiEnvelope<LooseboardExecution>>(path(number)))
}
export async function uploadLooseboardPhoto(number: string, item: string, phase: LooseboardPhotoPhase, file: File) {
const body = new FormData(); body.append('file', file)
return unwrapApiResponse(await http.post<ApiEnvelope<LooseboardPhoto>, ApiEnvelope<LooseboardPhoto>>(`${itemPath(number, item, phase)}/uploads`, body))
}
export async function saveLooseboardPhotos(number: string, item: string, phase: LooseboardPhotoPhase, body: { recordVersion: string | number; idempotencyKey: string; fileIds: string[] }) {
return unwrapApiResponse(await http.put<ApiEnvelope<LooseboardExecution>, ApiEnvelope<LooseboardExecution>>(itemPath(number, item, phase), body))
}
export async function readLooseboardPhoto(number: string, item: string, phase: LooseboardPhotoPhase, photo: LooseboardPhoto) {
const blob = await http.get<Blob, Blob>(`${itemPath(number, item, phase)}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' })
if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || blob.type !== photo.contentType || !blob.size) throw new Error('散板照片读取失败')
return blob
}
@@ -17,6 +17,11 @@ export async function listDispatches(query: DispatchQuery) {
return normalizeDispatchPage(unwrapApiResponse(await http.get<ApiEnvelope<DispatchPageWire>, ApiEnvelope<DispatchPageWire>>(base, { params: query })))
}
export const getDispatch = (dispatchNo: string) => get<DispatchDetailWire>(path(dispatchNo)).then(normalizeDispatchDetail)
/** 散板余位页只选择已实收打托且与本车同仓的散板来源。 */
export async function listLooseboardCargo(no: string, page = 1, size = 20) {
return normalizeCandidateList(unwrapApiResponse(await http.get<ApiEnvelope<CandidateListWire>, ApiEnvelope<CandidateListWire>>(`${base}/capacity-demands/${encodeURIComponent(no)}/cargo`, { params: { page, size } })))
}
export const addLooseboardCargo = (no: string, body: AddCargo) => { versioned(body); requireBatchVersions(body.batchAllocations); return postCommand(`${base}/capacity-demands/${encodeURIComponent(no)}/cargo`, body) }
export const getDispatchItems = (no: string) => get<DispatchItemsWire>(`${path(no)}/items`).then(normalizeDispatchItems)
export async function getDispatchAppointmentAllocations(no: string) {
const value = await get<AppointmentAllocations>(`${path(no)}/appointment-allocations`)
@@ -472,6 +472,15 @@ export async function createManualInboundPlan(payload: ManualInboundPlanPayload,
return unwrapApiResponse(response)
}
/** 异常退回以本单预计到仓时间创建计划,后端核对原异常及真实客户归属。 */
export async function createExceptionReturnInboundPlan(plan: ManualInboundPlanPayload, expectedArrivalAt: string, idempotencyKey: string) {
const response = await http.post<ApiEnvelope<InboundPlanDetailDto>, ApiEnvelope<InboundPlanDetailDto>>(
'/platform/v1/last-leg/exception-orders/return-inbound-plans', { plan, expectedArrivalAt },
{ headers: { 'Idempotency-Key': idempotencyKey } },
)
return unwrapApiResponse(response)
}
export async function updateLastMileOrderHold(orderId: string, payload: HoldLastMileOrderPayload, idempotencyKey: string) {
const response = await http.put<ApiEnvelope<HoldLastMileOrderResponse>, ApiEnvelope<HoldLastMileOrderResponse>>(
`/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold`, payload,
@@ -13,6 +13,7 @@ import { getDispatch } from '../api/dispatches'
import type { DispatchDetail } from '../dispatchContracts'
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
import DispatchLooseboardExecution from './DispatchLooseboardExecution.vue'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ dispatchNo: string }>()
@@ -22,10 +23,14 @@ const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = r
const tab = ref('RECEIPT'), selected = ref('')
const editor = ref<InstanceType<typeof DeliveryDocumentsDialog>>()
const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>()
const looseboardEditor = ref<InstanceType<typeof DispatchLooseboardExecution>>()
const directLooseboard = ref(false)
const hasLooseboard = computed(() => detail.value?.orderDetails.some(row => row.sourceBusinessType === 'LOCAL_LOOSEBOARD') === true)
const tabs = computed(() => [...(hasLooseboard.value ? [{ key: 'LOOSEBOARD', label: '散板取货与送达' }] : []), ...(!directLooseboard.value ? [{ key: 'RECEIPT', label: '签收文件' }, { key: 'LOADING', label: '装车资料' }] : [])])
const deliveries = computed(() => detail.value?.documents.deliveries || [])
const beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || ''))
const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value))
const busy = computed(() => Boolean(editor.value?.busy || loadingEditor.value?.busy))
const busy = computed(() => Boolean(editor.value?.busy || loadingEditor.value?.busy || looseboardEditor.value?.busy))
let sequence = 0
onBeforeUnmount(() => { sequence++ })
async function load() {
@@ -36,6 +41,7 @@ async function load() {
if (value.dispatchNo !== props.dispatchNo) throw new Error('资料与当前排车单不一致')
if (value.status !== 'ACTIVE' || !['LOADING', 'AFTER_LOADING', 'DELIVERING', 'DELIVERED'].includes(value.stage)) throw new Error('进入装车中后可管理资料')
if (!detail.value && ['LOADING', 'AFTER_LOADING'].includes(value.stage)) tab.value = 'LOADING'
if (!detail.value && value.orderDetails.some(row => row.sourceBusinessType === 'LOCAL_LOOSEBOARD')) tab.value = 'LOOSEBOARD'
detail.value = value
if (!value.documents.deliveries.some(item => item.deliveryNo === selected.value)) selected.value = value.documents.deliveries[0]?.deliveryNo || ''
} catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '排车资料读取失败') }
@@ -43,7 +49,7 @@ async function load() {
}
async function canLeave() {
if (busy.value) return false
if (!editor.value?.dirty) return true
if (!editor.value?.dirty && !looseboardEditor.value?.dirty) return true
try { await ElMessageBox.confirm('有尚未保存的派送资料,是否放弃修改?', '未保存资料', { customClass: 'admin-density-confirm admin-workbench is-dialog', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }); return true } catch { return false }
}
async function selectDelivery(value: string) { if (value !== selected.value && await canLeave()) selected.value = value }
@@ -52,7 +58,7 @@ function selectTab(value: string) { if (!busy.value) { tab.value = value } }
function saved() { emit('saved') }
async function loadingSaved() { emit('saved'); await load() }
watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null
sequence++; detail.value = null; selected.value = ''; tab.value = 'RECEIPT'; error.value = null; directLooseboard.value = false
if (visible && props.dispatchNo) void load()
}, { immediate: true })
</script>
@@ -60,14 +66,15 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
<AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab === 'RECEIPT' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()">
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="detail">
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in tabs" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
<DispatchLooseboardExecution v-if="hasLooseboard" v-show="tab === 'LOOSEBOARD'" ref="looseboardEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @mode="directLooseboard = $event" @saved="loadingSaved" />
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
<div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture">
<el-form v-if="deliveries.length > 1" class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="['RECEIPT']" @saved="saved" />
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
</div>
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p>
<p v-if="beforeDeparture && tab === 'RECEIPT'">完成装车并补齐装车资料后,进入派送中管理签收文件。</p>
</template>
</AppDialog>
</template>
@@ -0,0 +1,92 @@
import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchLooseboardExecution from './DispatchLooseboardExecution.vue'
import { getLooseboardExecution, uploadLooseboardPhoto, saveLooseboardPhotos } from '../api/dispatchLooseboardExecution'
import type { LooseboardExecution } from '../api/dispatchLooseboardExecution'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('../api/dispatchLooseboardExecution', () => ({ getLooseboardExecution: vi.fn(), uploadLooseboardPhoto: vi.fn(), saveLooseboardPhotos: vi.fn(), readLooseboardPhoto: vi.fn() }))
const value = (direct = true): LooseboardExecution => ({ dispatchNo: 'PC1', recordVersion: '3', stage: direct ? 'LOADING' : 'DELIVERING', direct, items: [{ itemId: '101', customerId: '1', orderNo: 'LB1', destination: 'TEST', packages: 2, pickupPhotos: [], arrivalPhotos: [], canPickup: true, canArrive: false }] })
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getLooseboardExecution).mockResolvedValue(value()) })
it('shows pure looseboard photo flow and uploads to its authoritative item and node', async () => {
vi.mocked(uploadLooseboardPhoto).mockResolvedValue({ fileId: '77', fileName: 'pickup.png', contentType: 'image/png', sizeBytes: 10 })
vi.mocked(saveLooseboardPhotos).mockResolvedValue({ ...value(), stage: 'DELIVERING' })
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.text()).not.toContain('全车为散板')
expect(wrapper.findAll('input[type=file]')).toHaveLength(2)
expect(wrapper.get('input[aria-label="LB1上传送达照片"]').attributes('disabled')).toBeDefined()
const input = wrapper.get('input[type=file]')
Object.defineProperty(input.element, 'files', { value: [new File(['test'], 'pickup.png', { type: 'image/png' })], configurable: true })
await input.trigger('change'); await flushPromises()
expect(uploadLooseboardPhoto).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.any(File))
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', { recordVersion: '3', idempotencyKey: expect.any(String), fileIds: ['77'] })
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('keeps pickup upload visible but disabled until mixed warehouse cargo departs', async () => {
const snapshot = value(false)
snapshot.stage = 'LOADING'
snapshot.items[0]!.canPickup = false
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.text()).not.toContain('混排货物按原流程')
expect(wrapper.text()).toContain('上传取货照片')
expect(wrapper.text()).toContain('完成仓内拣货并确认装车后可上传')
const input = wrapper.get('input[aria-label="LB1上传取货照片"]')
expect(input.attributes('disabled')).toBeDefined()
Object.defineProperty(input.element, 'files', { value: [new File(['test'], 'pickup.png', { type: 'image/png' })], configurable: true })
input.element.dispatchEvent(new Event('change', { bubbles: true })); await flushPromises()
expect(uploadLooseboardPhoto).not.toHaveBeenCalled()
wrapper.unmount()
})
it('enables pickup upload for mixed cargo in delivery', async () => {
vi.mocked(getLooseboardExecution).mockResolvedValue(value(false))
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.get('input[aria-label="LB1上传取货照片"]').attributes('disabled')).toBeUndefined()
wrapper.unmount()
})
it('enables mixed pickup from after loading using server eligibility', async () => {
const snapshot = value(false)
snapshot.stage = 'AFTER_LOADING'
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
expect(wrapper.get('input[aria-label="LB1上传取货照片"]').attributes('disabled')).toBeUndefined()
expect(wrapper.get('input[aria-label="LB1上传送达照片"]').attributes('disabled')).toBeDefined()
wrapper.unmount()
})
it('keeps upload available after saving and appends multiple files to the current list', async () => {
const snapshot = value(false)
const old = { fileId: '77', fileName: 'old.png', contentType: 'image/png', sizeBytes: 10 }
snapshot.items[0]!.pickupPhotos = [old]
snapshot.items[0]!.canArrive = true
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
vi.mocked(uploadLooseboardPhoto)
.mockResolvedValueOnce({ ...old, fileId: '78', fileName: 'new1.png' })
.mockResolvedValueOnce({ ...old, fileId: '79', fileName: 'new2.png' })
vi.mocked(saveLooseboardPhotos).mockResolvedValue(snapshot)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
const input = wrapper.get('input[aria-label="LB1上传取货照片"]')
expect(input.attributes('disabled')).toBeUndefined()
Object.defineProperty(input.element, 'files', { value: ['new1.png', 'new2.png'].map(name => new File(['test'], name, { type: 'image/png' })), configurable: true })
await input.trigger('change'); await flushPromises()
expect(uploadLooseboardPhoto).toHaveBeenCalledTimes(2)
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.objectContaining({ recordVersion: '3', fileIds: ['77', '78', '79'] }))
wrapper.unmount()
})
it('deletes one photo while preserving the others in the versioned save', async () => {
const snapshot = value(false)
const old = { fileId: '77', fileName: 'old.png', contentType: 'image/png', sizeBytes: 10 }
snapshot.items[0]!.pickupPhotos = [old, { ...old, fileId: '78', fileName: 'keep.png' }]
vi.mocked(getLooseboardExecution).mockResolvedValue(snapshot)
const updated = value(false)
updated.items[0]!.pickupPhotos = [snapshot.items[0]!.pickupPhotos[1]!]
vi.mocked(saveLooseboardPhotos).mockResolvedValue(updated)
const wrapper = mount(DispatchLooseboardExecution, { props: { dispatchNo: 'PC1' }, global: { plugins: [ElementPlus] } }); await flushPromises()
await wrapper.get('button[aria-label="删除取货照片old.png"]').trigger('click'); await flushPromises()
expect(saveLooseboardPhotos).toHaveBeenCalledWith('PC1', '101', 'PICKUP', expect.objectContaining({ recordVersion: '3', fileIds: ['78'] }))
expect(wrapper.text()).not.toContain('old.png')
expect(wrapper.text()).not.toContain('keep.png')
expect(wrapper.find('button[aria-label="删除取货照片keep.png"]').exists()).toBe(true)
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
@@ -0,0 +1,147 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { overseasWarehousePermissions as permission } from '../permissions'
import { getLooseboardExecution, uploadLooseboardPhoto, saveLooseboardPhotos, readLooseboardPhoto, type LooseboardExecution, type LooseboardExecutionItem, type LooseboardPhoto, type LooseboardPhotoPhase } from '../api/dispatchLooseboardExecution'
const props = defineProps<{ dispatchNo: string }>()
const emit = defineEmits<{ saved: []; mode: [direct: boolean] }>()
const access = useAccessPreviewStore()
const snapshot = ref<LooseboardExecution | null>(null), busy = ref(false), error = ref<ApiErrorDetails | null>(null)
const previews = ref<Record<string, string>>({})
const pending = ref<{ itemId: string; phase: LooseboardPhotoPhase; photos: LooseboardPhoto[] } | null>(null)
const dirty = computed(() => Boolean(pending.value?.photos.length))
const phases: { key: LooseboardPhotoPhase; label: string }[] = [{ key: 'PICKUP', label: '取货照片' }, { key: 'ARRIVAL', label: '送达照片' }]
function uploadReason(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase): string {
if (busy.value) return '正在处理,请稍候'
if (pending.value) return '请先保存已上传照片'
if ((phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length >= 20) return '每个节点最多20张照片'
if (phase === 'PICKUP' ? item.canPickup : item.canArrive) return ''
if (phase === 'ARRIVAL' && !item.pickupPhotos.length) return '请先上传取货照片'
if (phase === 'PICKUP' && !snapshot.value?.direct && snapshot.value?.stage === 'LOADING') return '完成仓内拣货并确认装车后可上传'
return '当前状态不可上传'
}
const number = props.dispatchNo
let active = true
function accept(value: LooseboardExecution) {
if (!active || number !== props.dispatchNo || value.dispatchNo !== number) throw new Error('排车身份已变化,请重新打开')
snapshot.value = value; emit('mode', value.direct)
}
async function images() {
for (const item of snapshot.value?.items || []) for (const phase of phases) {
const photos = phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
await Promise.allSettled(photos.map(async photo => {
if (previews.value[photo.fileId]) return
const blob = await readLooseboardPhoto(number, item.itemId, phase.key, photo)
if (active) previews.value[photo.fileId] = URL.createObjectURL(blob)
}))
}
}
async function load() {
if (busy.value) return
busy.value = true; error.value = null
try { accept(await getLooseboardExecution(number)); await images() }
catch (cause) { if (active) error.value = getApiErrorDetails(cause, '散板履约资料读取失败') }
finally { busy.value = false }
}
async function save() {
if (!pending.value?.photos.length || !snapshot.value || busy.value) return
busy.value = true; error.value = null
const value = pending.value
try {
const item = snapshot.value.items.find(row => row.itemId === value.itemId)
if (!item) throw new Error('散板明细已变化,请重新打开')
const existing = value.phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
accept(await saveLooseboardPhotos(number, value.itemId, value.phase, { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileIds: [...existing, ...value.photos].map(photo => photo.fileId) }))
pending.value = null; await images(); ElMessage.success('散板照片已保存'); emit('saved')
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '保存失败,已上传照片保留,请核对当前状态后重试')
try {
accept(await getLooseboardExecution(number)); await images()
const item = snapshot.value?.items.find(row => row.itemId === value.itemId)
const bound = (value.phase === 'PICKUP' ? item?.pickupPhotos : item?.arrivalPhotos) || []
if (value.photos.every(photo => bound.some(file => file.fileId === photo.fileId))) { pending.value = null; emit('saved') }
} catch { /* 保留照片和当前错误,用户可重新读取。 */ }
}
} finally { busy.value = false }
}
async function upload(itemId: string, phase: LooseboardPhotoPhase, event: Event) {
const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = ''
const item = snapshot.value?.items.find(row => row.itemId === itemId)
if (!files.length || !item || uploadReason(item, phase) || !access.can(permission.dispatchEdit)) return
const existing = phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
if (files.length + existing.length > 20 || files.some(file => !['image/jpeg', 'image/png'].includes(file.type) || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('每个节点最多20张JPG或PNG,每张不超过10MiB'); return }
busy.value = true; error.value = null; pending.value = { itemId, phase, photos: [] }; let failed = false
try { for (const file of files) { const photo = await uploadLooseboardPhoto(number, itemId, phase, file); if (!active) return; pending.value.photos.push(photo) } }
catch (cause) { failed = true; error.value = getApiErrorDetails(cause, '照片上传失败,已成功上传的照片可继续保存'); if (!pending.value?.photos.length) pending.value = null }
finally { busy.value = false }
if (!failed && pending.value?.photos.length) await save()
}
async function remove(item: LooseboardExecutionItem, phase: LooseboardPhotoPhase, fileId: string) {
if (!snapshot.value || busy.value || pending.value || !access.can(permission.dispatchEdit) || !(phase === 'PICKUP' ? item.canPickup : item.canArrive)) return
busy.value = true; error.value = null
try {
const photos = phase === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos
accept(await saveLooseboardPhotos(number, item.itemId, phase, { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileIds: photos.filter(photo => photo.fileId !== fileId).map(photo => photo.fileId) }))
await images(); ElMessage.success('照片已删除'); emit('saved')
} catch (cause) {
if (active) {
error.value = getApiErrorDetails(cause, '照片删除失败,请刷新后重试')
try { accept(await getLooseboardExecution(number)); await images() } catch { /* 保留原错误。 */ }
}
} finally { busy.value = false }
}
onMounted(() => { void load() })
onBeforeUnmount(() => { active = false; Object.values(previews.value).forEach(URL.revokeObjectURL) })
defineExpose({ busy, dirty })
</script>
<template>
<div v-loading="busy" class="looseboard-execution">
<el-button :disabled="busy" @click="load">刷新资料</el-button>
<LoadErrorState v-if="error" compact title="散板履约资料操作失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="pending"><span>已上传 {{ pending.photos.length }} 张,尚未保存。</span><el-button :disabled="busy" @click="save">保存已上传照片</el-button></div>
<section v-for="item in snapshot?.items || []" :key="item.itemId" class="looseboard-cargo">
<h3>{{ item.orderNo }} · {{ item.destination || '—' }} · {{ item.packages }} 箱</h3>
<div v-for="phase in phases" :key="phase.key" class="looseboard-node">
<h4>{{ phase.label }}</h4>
<label v-if="access.can(permission.dispatchEdit)" class="node-upload" :class="{ 'is-disabled': Boolean(uploadReason(item, phase.key)) }" :title="uploadReason(item, phase.key)">
上传{{ phase.label }}
<input type="file" multiple accept="image/jpeg,image/png" :aria-label="`${item.orderNo}上传${phase.label}`" :disabled="Boolean(uploadReason(item, phase.key))" @change="upload(item.itemId, phase.key, $event)" />
</label>
<span v-if="access.can(permission.dispatchEdit) && uploadReason(item, phase.key)" class="upload-reason">{{ uploadReason(item, phase.key) }}</span>
<p v-if="!(phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos).length">尚未上传{{ phase.label }}</p>
<div v-else class="node-photos">
<div v-for="photo in phase.key === 'PICKUP' ? item.pickupPhotos : item.arrivalPhotos" :key="photo.fileId" class="node-photo">
<el-image v-if="previews[photo.fileId]" :src="previews[photo.fileId]" :preview-src-list="[previews[photo.fileId]!]" preview-teleported fit="contain" class="node-image" :alt="phase.label" />
<button v-if="access.can(permission.dispatchEdit) && (phase.key === 'PICKUP' ? item.canPickup : item.canArrive)" type="button" class="node-photo-remove" :aria-label="`删除${phase.label}${photo.fileName}`" title="删除照片" :disabled="busy || Boolean(pending)" @click.stop="remove(item, phase.key, photo.fileId)"><span aria-hidden="true">×</span></button>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.looseboard-execution { display: flex; flex-direction: column; gap: 12px; }
.looseboard-cargo { border: 1px solid var(--cc-border, var(--el-border-color)); }
.looseboard-cargo > h3 { margin: 0; padding: 8px 12px; font-size: 14px; background: var(--el-fill-color-light); }
.looseboard-node { padding: 8px 12px; border-top: 1px solid var(--el-border-color-lighter); }
.looseboard-node h4 { display: inline-block; margin: 0 16px 8px 0; font-size: 14px; }
.looseboard-node p { color: var(--el-text-color-secondary); }
.node-photos { display: flex; flex-wrap: wrap; gap: 12px; margin: 12px 0 4px; }
.node-photo { position: relative; flex: 0 0 160px; width: 160px; height: 100px; border: 1px solid var(--el-border-color-lighter); border-radius: var(--el-border-radius-base); background: var(--el-fill-color-light); }
.node-image { display: block; width: 100%; height: 100%; border-radius: inherit; }
.node-photo-remove { position: absolute; top: 4px; right: 4px; z-index: 1; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; border: 0; border-radius: 50%; background: var(--el-color-danger); color: var(--el-color-white); font-size: 20px; line-height: 1; cursor: pointer; }
.node-photo-remove:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.node-photo-remove:disabled { opacity: .5; cursor: not-allowed; }
.node-upload { position: relative; display: inline-flex; align-items: center; padding: 6px 12px; border: 1px solid var(--el-color-primary); border-radius: var(--el-border-radius-base); color: var(--el-color-primary); cursor: pointer; }
.node-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.node-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.upload-reason { margin-left: 8px; color: var(--el-text-color-secondary); font-size: 12px; }
.node-upload.is-disabled input { cursor: not-allowed; }
.node-upload.is-disabled { border-color: var(--el-border-color); color: var(--el-text-color-disabled); cursor: not-allowed; }
</style>
+1
View File
@@ -12,6 +12,7 @@ export type { DispatchBatchReference, DispatchBatchAllocation, DispatchDetail, D
export { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts'
export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
export { listLooseboardCargo, addLooseboardCargo } from './api/dispatches'
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
export { useDispatchCandidates } from './useDispatchCandidates'
export { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans } from './api/lastMileOrders'
@@ -2,6 +2,12 @@ import { describe, expect, it } from 'vitest'
import { formatOverseasDateTime, inboundPlanStatusLabel, inventoryBusinessTypeLabel, lastMileOrderReference, lastMileStatusLabel, packageDifferenceText, relabelBusinessStatusLabel, toLastMileOrderListItem } from './lastMilePresentation'
describe('last-mile order presentation adapter', () => {
it('translates raw return order type, source and pending inbound status', () => {
const row = toLastMileOrderListItem({ id: '1', customerId: '2', orderNo: 'WC1', orderType: 'DELIVERY_RETURN', orderTypeName: 'DELIVERY_RETURN', currentStatus: 'PENDING_INBOUND', businessSource: { label: 'DELIVERY_RETURN' } } as Parameters<typeof toLastMileOrderListItem>[0])
expect(row.orderType).toBe('派送异常退回')
expect(row.status).toBe('待入库')
expect(row.businessSource).toBe('派送异常退回')
})
it('keeps completed and processing relabel applications visible together', () => {
const row = toLastMileOrderListItem({
id: '1', customerId: 'customer-1', orderNo: 'RT-001', currentStatus: 'INBOUND_COMPLETED',
@@ -15,6 +15,7 @@ const orderTypeLabels: Record<string, string> = {
}
const statusLabels: Record<string, string> = {
PENDING_INBOUND: '待入库',
PENDING_REVIEW: '待审核',
PENDING_RECEIPT: '待签收入库',
PARTIALLY_RECEIVED: '部分入库',
@@ -156,13 +157,13 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
orderNo: lastMileOrderReference(dto.orderNo),
sourceOrderNo: lastMileOrderReference(dto.sourceOrderNo, ''),
businessType: dto.businessSource?.businessType || '',
businessSource: text(dto.businessSource?.label),
businessSource: lastMileOrderTypeLabel(dto.businessSource?.label),
businessOrderNo: text(dto.businessSource?.businessOrderNo),
externalBookingNo: text(dto.businessSource?.externalBookingNo),
orderTypeCode: String(dto.orderType ?? '').trim(),
currentStatusCode: String(dto.currentStatus ?? '').trim(),
warning: dto.abnormal ? '异常' : '—',
orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)),
orderType: lastMileOrderTypeLabel(dto.orderTypeName || dto.orderType),
rejectedAt: '—',
inboundCode: text(dto.inboundCode),
lxCode: text(dto.lxCode),
@@ -45,6 +45,7 @@ const relatedOrders = computed(() => detail.value?.orderDetails || [])
const orderColumns: DataTableColumn[] = [
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 },
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
{ key: 'sourceBusinessType', label: '业务类型', width: 120, formatter: (row: { sourceBusinessType?: string | null }) => row.sourceBusinessType === 'LOCAL_LOOSEBOARD' ? '本土散板' : row.sourceBusinessType === 'WAREHOUSE_TRANSFER' ? '仓储转运' : '—' },
{ key: 'sourceBusinessNo', prop: 'sourceBusinessNo', label: '来源业务号', minWidth: 180 },
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 },
{ key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' },
@@ -124,7 +124,7 @@ const showReturnForecast = computed(() => isReturnOrder.value && (detail.value?.
const orderFields = computed<DetailField[]>(() => {
const order = detail.value?.order
const fields: DetailField[] = [
{ label: '履约类型', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) },
{ label: '履约类型', value: text(lastMileOrderTypeLabel(order?.orderTypeName || order?.orderType)) },
{ label: '入仓码', value: text(order?.inboundCode) },
{ label: '货主', value: text(order?.customerName) },
{ label: '海外仓', value: warehouseDisplayName.value },
@@ -149,7 +149,7 @@ function isNonzeroDifference(field: DetailField) { return field.label === '箱
const containerFields = computed<DetailField[]>(() => {
const order = detail.value?.order
return [
{ label: '运输渠道', value: text(order?.orderTypeName || lastMileOrderTypeLabel(order?.orderType)) },
{ label: '运输渠道', value: text(lastMileOrderTypeLabel(order?.orderTypeName || order?.orderType)) },
{ label: '集装箱号', value: text(order?.containerNo) },
{ label: '托数', value: Number(order?.palletCount || 0) },
{ label: '箱数', value: Number(order?.packageCount || 0) },
@@ -351,7 +351,7 @@ onMounted(load)
<section aria-label="商业来源与关联单据">
<h2 class="source-heading">关联单据</h2>
<dl class="detail-fields density-fields">
<div class="wide"><dt>业务来源</dt><dd>{{ text(detail?.order.businessSource?.label) }}</dd></div>
<div class="wide"><dt>业务来源</dt><dd>{{ lastMileOrderTypeLabel(detail?.order.businessSource?.label) }}</dd></div>
<div class="wide"><dt>关联商业单号</dt><dd><el-button v-if="commercialLink" link type="primary" @click="router.push(commercialLink)">{{ detail?.order.businessSource?.businessOrderNo }}</el-button><span v-else>{{ text(detail?.order.businessSource?.businessOrderNo) }}</span></dd></div>
<div class="wide"><dt>外接预订号</dt><dd>{{ text(detail?.order.businessSource?.externalBookingNo) }}</dd></div>
<div class="wide"><dt>来源单号</dt><dd>{{ text(detail?.order.sourceOrderNo) }}</dd></div>
@@ -17,6 +17,7 @@ import { listContainerArrivalTimes } from '@/modules/container-loading'
import {
changeContainerReceivingWarehouse,
createManualInboundPlan,
createExceptionReturnInboundPlan,
convertLastMileContainerToInboundPlans,
getContainerInboundPlanPreview,
getContainerPlanProgress,
@@ -46,7 +47,7 @@ vi.mock('../api/lastMileOrders', () => ({
getReturnOrderStatistics: vi.fn(),
listLastMileOrders: vi.fn(), listContainerPlanProgress: vi.fn(), getContainerPlanProgress: vi.fn(),
changeContainerReceivingWarehouse: vi.fn(),
createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
createExceptionReturnInboundPlan: vi.fn(), createManualInboundPlan: vi.fn(), convertLastMileContainerToInboundPlans: vi.fn(), getContainerInboundPlanPreview: vi.fn(), transferLastMileOrders: vi.fn(),
getLastMileOrderManualNodes: vi.fn(), updateLastMileOrderHold: vi.fn(), updateLastMileOrderManualNodes: vi.fn(),
reviewCargoDiscard: vi.fn(), updateLastMileOrderPalletMerge: vi.fn(),
}))
@@ -454,6 +455,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
summary,
orders: [{ orderId: '2092873463568596994', customerId, orderNo: 'LMO-1', inboundCode: 'IN-1', orderStatus: 'INBOUND_PLANNED', planId: '2092873463568597010', planNo: 'LIP-1', planStatus: 'PENDING_RECEIVING', planValid: true, plannedArrivalAt: '2026-09-01T08:00:00' }],
})
vi.mocked(createExceptionReturnInboundPlan).mockReset().mockResolvedValue({} as never)
vi.mocked(createManualInboundPlan).mockReset().mockResolvedValue({} as never)
vi.mocked(listContainerArrivalTimes).mockReset().mockResolvedValue([arrivalTime])
vi.mocked(getContainerInboundPlanPreview).mockReset().mockResolvedValue(preview as never)
@@ -666,6 +668,30 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(wrapper.text()).not.toContain('尾程订单加载失败')
})
it('requires an explicit return ETA and plans the current tail order without a source container', async () => {
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow({ orderNo: 'WC1', sourceOrderNo: 'ZY1', orderTypeCode: 'DELIVERY_RETURN', currentStatusCode: 'PENDING_INBOUND', sourceContainerBookingId: null, inboundCode: null, estimatedWarehouseAt: '', warehouseEta: '—' })])
await button(wrapper, '入库')!.trigger('click')
await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('入库计划 ·'))!
expect(dialog.get('[aria-label="入库来源"]').text()).toContain('WC1')
expect(dialog.text()).toContain('ZY1')
expect(dialog.props('confirmDisabled')).toBe(true)
dialog.vm.$emit('confirm')
await flushPromises()
expect(createExceptionReturnInboundPlan).not.toHaveBeenCalled()
dialog.findComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-05T14:30:00')
await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm')
await flushPromises()
expect(createExceptionReturnInboundPlan).toHaveBeenCalledWith(expect.objectContaining({ customerId, orderId: '2092873463568596994', expectedOrderVersion: 3 }), '2026-11-05T14:30:00', expect.any(String))
expect(createManualInboundPlan).not.toHaveBeenCalled()
expect(listContainerArrivalTimes).not.toHaveBeenCalled()
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
})
it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => {
const wrapper = await openAfterDeparture()
await selectRows(wrapper, [selectedRow()])
@@ -21,6 +21,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, reviewDirectRelabelOrder, reviewReturnOrder, transferLastMileOrders, updateLastMileOrderPalletMerge, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode, type LastMileOrderQuery } from '../api/lastMileOrders'
import { businessOrderLink } from '../businessOrderLink'
import { createExceptionReturnInboundPlan } from '../api/lastMileOrders'
import { getReturnOrderStatistics, type ReturnOrderStatistics } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { reviewCargoDiscard } from '../api/lastMileOrders'
@@ -111,6 +112,8 @@ const inboundPlanSourcePalletCount = ref<number | null>(null)
const inboundPlanPreviewLoading = ref(false)
const inboundPlanPreviewError = ref('')
const inboundPlanIsDropship = computed(() => actionTarget.value?.orderTypeCode === 'ONE_CLICK_DROPSHIP')
const inboundPlanIsExceptionReturn = computed(() => actionTarget.value?.orderTypeCode === 'DELIVERY_RETURN')
const exceptionReturnArrival = ref('')
const inboundPlanArrival = ref<ContainerArrivalTime | null>(null)
const inboundPlanArrivalLoading = ref(false)
const inboundPlanArrivalError = ref('')
@@ -119,7 +122,7 @@ const inboundPlanArrivalProjection = computed(() => {
const arrival = inboundPlanArrival.value
return projectWarehouseArrival(arrival ? { ...arrival, plannedAt: arrival.estimatedWarehouseArrivalAt } : undefined)
})
const inboundPlanSubmitDisabled = computed(() => inboundPlanArrivalLoading.value || !inboundPlanArrivalProjection.value.date
const inboundPlanSubmitDisabled = computed(() => inboundPlanArrivalLoading.value || (inboundPlanIsExceptionReturn.value ? !exceptionReturnArrival.value : !inboundPlanArrivalProjection.value.date)
|| (inboundPlanIsDropship.value
&& (inboundPlanPreviewLoading.value || inboundPlanSourcePalletCount.value == null
|| inboundPlanSourcePalletCount.value <= 0)))
@@ -646,6 +649,7 @@ function openInboundPlan(source?: object) {
actionError.value = null
inboundPlanArrival.value = null
inboundPlanArrivalError.value = ''
exceptionReturnArrival.value = target.orderTypeCode === 'DELIVERY_RETURN' ? target.estimatedWarehouseAt || '' : ''
inboundPlanOpen.value = true
void refreshInboundPlanArrival(target)
void refreshManualPalletMinimum(target)
@@ -653,6 +657,7 @@ function openInboundPlan(source?: object) {
/** 每次打开重新读取柜级权威日期,避免使用列表旧值或在页面推算默认日期。 */
async function refreshInboundPlanArrival(target: LastMileOrderListItem) {
const request = ++inboundPlanArrivalRequest
if (target.orderTypeCode === 'DELIVERY_RETURN') { inboundPlanArrivalLoading.value = false; return }
inboundPlanArrivalLoading.value = true
try {
if (!target.sourceContainerBookingId) {
@@ -676,6 +681,7 @@ async function refreshInboundPlanArrival(target: LastMileOrderListItem) {
}
}
async function refreshManualPalletMinimum(target: LastMileOrderListItem) {
if (target.orderTypeCode === 'DELIVERY_RETURN') return
if (!target.sourceContainerBookingId) return
inboundPlanPreviewLoading.value = true
inboundPlanPreviewError.value = ''
@@ -710,23 +716,26 @@ async function submitInboundPlan() {
ElMessage.warning('一件代发必须按头程已封托托盘数整体生成入库计划')
return
}
if (!row || !row.overseasWarehouseId || inboundPlanArrivalLoading.value || !inboundPlanArrivalProjection.value.date || !frozenInboundCode || frozenInboundCode === '—' || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
ElMessage.warning('当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数')
if (!row || !row.overseasWarehouseId || inboundPlanSubmitDisabled.value || (!inboundPlanIsExceptionReturn.value && (!frozenInboundCode || frozenInboundCode === '—')) || inboundPlanForm.palletCount < inboundPlanMinimumPalletCount.value) {
ElMessage.warning(inboundPlanIsExceptionReturn.value ? '请选择预计到仓时间,并确认退回仓和计划托数' : '当前柜须已分配接柜仓、具备预计到仓时间,并完整填写托数')
return
}
actionLoading.value = true; actionError.value = null
try {
const result = await createManualInboundPlan({
const payload = {
customerId: row.customerId, orderId: row.id, expectedOrderVersion: row.version,
palletCount: inboundPlanForm.palletCount,
palletType: inboundPlanForm.palletType, assigned: inboundPlanForm.assigned,
mergeRequired: inboundPlanForm.mergeRequired, urgent: inboundPlanForm.urgent,
remark: inboundPlanForm.remark.trim() || undefined,
}, idempotencyKey('manual-inbound-plan'))
}
const result = inboundPlanIsExceptionReturn.value
? await createExceptionReturnInboundPlan(payload, exceptionReturnArrival.value, idempotencyKey('exception-return-plan'))
: await createManualInboundPlan(payload, idempotencyKey('manual-inbound-plan'))
const palletCodesReady = result.pallets?.length === result.palletCount
&& result.pallets.every((pallet) => Boolean(pallet.warehousePalletId && pallet.palletNo))
inboundPlanOpen.value = false; selectedRows.value = []
ElMessage.success(palletCodesReady
ElMessage.success(inboundPlanIsExceptionReturn.value ? '入库计划已生成,请在异常退回验收中完成收货' : palletCodesReady
? '入库计划和托盘码已自动生成'
: '入库计划已保存,整柜计划齐全后将自动生成托盘码')
await load()
@@ -1332,15 +1341,17 @@ onMounted(() => { void load() })
<el-alert v-if="inboundPlanPreviewError" :title="inboundPlanPreviewError" type="error" :closable="false" show-icon />
<el-alert v-if="inboundPlanArrivalError" :title="inboundPlanArrivalError" type="error" :closable="false" show-icon />
<el-form class="inbound-plan-form command-form density-form density-dialog-sections" label-position="left" label-width="110px" :disabled="actionLoading">
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">来源摘要 <InlineHelp title="入库来源说明">优先使用整柜人工维护的预计到仓时间;未维护时,按到港当地日期加两个自然日默认推算。首个入库计划生成后冻结日期,同柜计划保持一致。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。</InlineHelp></h3>
<section class="density-dialog-section" data-tone="source" aria-label="入库来源"><h3 class="density-dialog-section__title">来源摘要 <InlineHelp title="入库来源说明">{{ inboundPlanIsExceptionReturn ? '异常退回按本次退回安排填写预计到仓时间,计划关联当前尾程订单,收货时从原出库来源恢复库存。' : '优先使用整柜人工维护的预计到仓时间;未维护时,按到港当地日期加两个自然日默认推算。首个入库计划生成后冻结日期,同柜计划保持一致。接柜仓按柜统一;如需变更,请在柜子工作台执行“变更接柜仓”。' }}</InlineHelp></h3>
<table class="density-table last-mile-dialog-facts inbound-plan-source"><colgroup><col class="inbound-plan-source__label" /><col /><col class="inbound-plan-source__label" /><col /></colgroup><tbody>
<tr><th scope="row">预计到仓时间</th><td colspan="3"><div class="inbound-plan-arrival">{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></div></td></tr>
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
<tr v-if="inboundPlanIsExceptionReturn"><th scope="row">尾程单号</th><td>{{ actionTarget?.orderNo }}</td><th scope="row">来源尾程单号</th><td>{{ actionTarget?.sourceOrderNo || '—' }}</td></tr>
<tr v-if="inboundPlanIsExceptionReturn"><th scope="row">预计到仓时间 <span class="el-form-item__required">*</span></th><td colspan="3"><el-date-picker v-model="exceptionReturnArrival" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择本次退回预计到仓时间" clearable /></td></tr>
<tr v-else><th scope="row">预计到仓时间</th><td colspan="3"><div class="inbound-plan-arrival">{{ inboundPlanExpectedArrival }} <el-button v-if="access.can('platform:firstleg:terminal-drayage:list') && actionTarget?.containerNo" link type="primary" @click="router.push({ name: 'overseas-container-workbench', query: { keyword: actionTarget.containerNo } })">前往货柜维护预计到仓</el-button></div></td></tr>
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td><th scope="row">入仓码</th><td>{{ inboundPlanIsExceptionReturn ? actionTarget?.orderNo : inboundPlanForm.inboundCode || '—' }}</td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划设置</h3>
<table class="density-table density-table--editable last-mile-command-table"><tbody>
<tr><td><el-form-item label="托数" required><div class="inbound-plan-pallet-field"><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></div></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="托数" required><div class="inbound-plan-pallet-field"><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="inbound-plan-pallet-note density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="inbound-plan-pallet-note density-dialog-note" v-else>{{ inboundPlanIsExceptionReturn ? '按本次退回货物安排计划托数。' : `按柜级预览至少 ${inboundPlanMinimumPalletCount} 托,可增加,不能减少。` }}</span></div></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="是否外配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></td></tr>