2026-10-15 17:11:03 +08:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, onMounted, ref } from 'vue'
|
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
|
import { getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, dispatchVehicleSpecs, type BatchDispatchCandidate, type BatchDispatchCandidates, type DispatchSummaryDto } from '@/modules/overseas-warehouse'
|
|
|
|
|
|
import { TripPreparationDialog } from '@/modules/overseas-master-data'
|
|
|
|
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
2026-10-19 08:44:26 +08:00
|
|
|
|
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
2026-10-16 15:41:56 +08:00
|
|
|
|
import WorkSharedTripForm from './WorkSharedTripForm.vue'
|
2026-10-19 08:44:26 +08:00
|
|
|
|
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
|
|
|
|
|
|
import { getReceivingContext } from './receivingApi'
|
2026-10-15 17:11:03 +08:00
|
|
|
|
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
|
|
|
|
|
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
|
2026-10-16 15:41:56 +08:00
|
|
|
|
const emit = defineEmits<{ updated: []; external: [path: string]; batch: [key: string] }>()
|
2026-10-15 17:11:03 +08:00
|
|
|
|
const access = useAccessPreviewStore(), contexts = ref<BatchDispatchCandidates[]>([]), selected = ref<string[]>([])
|
|
|
|
|
|
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const capacity = ref(30), result = ref<DispatchSummaryDto | null>(null), prepareOpen = ref(false), rejected = ref(false)
|
2026-10-16 15:41:56 +08:00
|
|
|
|
const sharedTripOpen = ref(false), sharedTripBusy = ref(false)
|
2026-10-19 08:44:26 +08:00
|
|
|
|
const existingDispatches = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), arrangingRemaining = ref(false)
|
|
|
|
|
|
const existingReady = ref(false)
|
2026-10-16 15:41:56 +08:00
|
|
|
|
const selectionBaseline = ref('[]')
|
2026-10-15 17:11:03 +08:00
|
|
|
|
const pending = ref<{ token: string; capacity: number; rows: BatchDispatchCandidate[]; containerId: string | null; lockedIds: string[]; submitting: boolean } | null>(null)
|
|
|
|
|
|
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list'].every(permission => access.can(permission)))
|
2026-10-19 08:44:26 +08:00
|
|
|
|
const writable = computed(() => props.canWrite && readable.value && (!existingDispatches.value.length || existingReady.value) && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'))
|
2026-10-15 17:11:03 +08:00
|
|
|
|
const rows = computed(() => contexts.value.flatMap(context => context.candidates.map(row => ({ ...row, blockReasons: [...context.blockReasons, ...row.blockReasons] }))))
|
|
|
|
|
|
function reasons(row: BatchDispatchCandidate) {
|
|
|
|
|
|
const duplicates = rows.value.filter(other => other.planPalletId === row.planPalletId).length > 1
|
2026-10-16 15:41:56 +08:00
|
|
|
|
return [...row.blockReasons, ...(row.packageCount == null || row.packageCount <= 0 ? ['实物箱数待核实'] : []), ...(!row.allocations.length ? ['缺少逐来源批次分配'] : []), ...(row.allocations.some(allocation => !props.batchKeys.includes(allocation.batchKey) || allocation.customerId !== row.customerId) ? ['候选不属于当前批次和客户范围'] : []), ...(duplicates ? ['同一实物出现在多个批次,请核对分批范围'] : [])]
|
2026-10-15 17:11:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.planPalletId)))
|
2026-10-16 15:41:56 +08:00
|
|
|
|
useWorkDraft(computed(() => JSON.stringify(selected.value) !== selectionBaseline.value && !result.value), computed(() => saving.value || !!pending.value))
|
2026-10-15 17:11:03 +08:00
|
|
|
|
async function load() {
|
|
|
|
|
|
if (!readable.value || pending.value) return
|
2026-10-19 08:44:26 +08:00
|
|
|
|
loading.value = true; error.value = null; existingReady.value = false
|
2026-10-16 15:41:56 +08:00
|
|
|
|
try {
|
2026-10-19 08:44:26 +08:00
|
|
|
|
// 从服务端当前运输关系恢复排车单;读取失败时禁止退回创建流程。
|
|
|
|
|
|
const batches = [...new Set(props.batchKeys)]
|
|
|
|
|
|
const receiving = await Promise.all(batches.map(batch => getReceivingContext(batch)))
|
|
|
|
|
|
const references: typeof existingDispatches.value = []
|
|
|
|
|
|
receiving.forEach((context, index) => {
|
|
|
|
|
|
const batchKey = batches[index]!
|
|
|
|
|
|
if (context.batch.batchKey !== batchKey) throw new ApiResponseError('运输关系与当前预约批次不一致,请重新读取', 'WORK_DISPATCH_MISMATCH')
|
|
|
|
|
|
for (const vehicle of context.transport.vehicles.filter(row => row.batchKey === batchKey && row.status !== 'CANCELLED')) {
|
|
|
|
|
|
if (!vehicle.dispatchNo) throw new ApiResponseError('本批已有运输安排但缺少排车单号,请核对原车次', 'WORK_DISPATCH_MISMATCH')
|
|
|
|
|
|
references.push({ batchKey, dispatchNo: vehicle.dispatchNo, customerId: vehicle.customerId })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
existingDispatches.value = references
|
|
|
|
|
|
contexts.value = []; selected.value = []; selectionBaseline.value = '[]'
|
|
|
|
|
|
if (references.length && !arrangingRemaining.value) return
|
|
|
|
|
|
contexts.value = await Promise.all(batches.map(batch => getBatchDispatchCandidates(batch)))
|
2026-10-16 15:41:56 +08:00
|
|
|
|
const eligible = rows.value.filter(row => !reasons(row).length && row.packageCount != null && row.packageCount > 0)
|
|
|
|
|
|
selected.value = props.batchKeys.length === 1 && eligible.length <= capacity.value && new Set(eligible.map(row => row.customerId)).size === 1 ? eligible.map(row => row.planPalletId) : []
|
|
|
|
|
|
selectionBaseline.value = JSON.stringify(selected.value)
|
|
|
|
|
|
}
|
2026-10-15 17:11:03 +08:00
|
|
|
|
catch (cause) { error.value = getApiErrorDetails(cause, '批次可排实物读取失败') }
|
|
|
|
|
|
finally { loading.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
function select(row: BatchDispatchCandidate, checked: boolean) {
|
|
|
|
|
|
if (!writable.value || pending.value || saving.value || loading.value || error.value || reasons(row).length) return
|
|
|
|
|
|
if (!checked) { selected.value = selected.value.filter(id => id !== row.planPalletId); return }
|
2026-10-16 15:41:56 +08:00
|
|
|
|
if (selected.value.includes(row.planPalletId)) return
|
2026-10-15 17:11:03 +08:00
|
|
|
|
if (chosen.value.some(current => current.customerId !== row.customerId)) { ElMessage.warning('一张排车单只选择同一客户实物;其余客户可继续分别安排'); return }
|
|
|
|
|
|
if (selected.value.length >= capacity.value) { ElMessage.warning('已达到所选车型托位上限'); return }
|
|
|
|
|
|
selected.value = [...selected.value, row.planPalletId]
|
|
|
|
|
|
}
|
|
|
|
|
|
function changeSelection(row: BatchDispatchCandidate, event: Event) {
|
|
|
|
|
|
const input = event.target as HTMLInputElement
|
|
|
|
|
|
select(row, input.checked); input.checked = selected.value.includes(row.planPalletId)
|
|
|
|
|
|
}
|
|
|
|
|
|
async function save() {
|
|
|
|
|
|
if (!writable.value || saving.value || loading.value || error.value || result.value) return
|
|
|
|
|
|
if (!pending.value) {
|
|
|
|
|
|
if (!chosen.value.length || chosen.value.length > capacity.value || chosen.value.some(row => reasons(row).length) || new Set(chosen.value.map(row => row.customerId)).size !== 1) { ElMessage.warning('请核对所选客户、实物资格和托位数量'); return }
|
|
|
|
|
|
pending.value = { token: crypto.randomUUID(), capacity: capacity.value, rows: structuredClone(chosen.value.map(row => ({ ...row, allocations: row.allocations.map(allocation => ({ ...allocation })), blockReasons: [...row.blockReasons] }))), containerId: null, lockedIds: [], submitting: false }
|
|
|
|
|
|
}
|
|
|
|
|
|
saving.value = true; saveError.value = null; rejected.value = false
|
|
|
|
|
|
try {
|
|
|
|
|
|
const request = pending.value
|
|
|
|
|
|
if (!request.containerId) {
|
|
|
|
|
|
const drafts = await listDispatchDrafts(), existing = drafts.find(draft => draft.draftToken === request.token)?.containers || []
|
|
|
|
|
|
if (existing.length > 1) throw new Error('本次草稿出现多组装车容器,请核对保存结果')
|
|
|
|
|
|
request.containerId = existing[0]?.id || (await createDispatchDraftContainer(request.token, request.capacity)).id
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!request.submitting) for (const row of request.rows) if (!request.lockedIds.includes(row.planPalletId)) { await lockDispatchDraftPallet(row.planPalletId, request.token, request.containerId); request.lockedIds.push(row.planPalletId) }
|
|
|
|
|
|
request.submitting = true
|
|
|
|
|
|
const response = await preArrangeDraftDispatch({ draftToken: request.token, groups: [{ draftContainerId: request.containerId, planPalletIds: request.rows.map(row => row.planPalletId), returnReceiptIds: [], batchAllocations: request.rows.flatMap(row => row.allocations) }] })
|
|
|
|
|
|
if (response.length !== 1 || response[0]?.draftContainerId !== request.containerId || !response[0].dispatch.dispatchNo) throw new Error('排车结果与本次分组不一致,请重试核对原请求')
|
|
|
|
|
|
result.value = response[0].dispatch; pending.value = null; selected.value = []
|
|
|
|
|
|
ElMessage.success(`排车单 ${result.value.dispatchNo} 已创建`); emit('updated')
|
|
|
|
|
|
} catch (cause) { saveError.value = getApiErrorDetails(cause, '本次排车结果待确认,请重试原请求'); rejected.value = rejectedCommand(cause) }
|
|
|
|
|
|
finally { saving.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
async function refreshRejected() {
|
|
|
|
|
|
if (!rejected.value || !pending.value || saving.value) return
|
|
|
|
|
|
saving.value = true
|
|
|
|
|
|
try { await releaseDispatchDraft(pending.value.token); pending.value = null; selected.value = []; rejected.value = false; await load() }
|
|
|
|
|
|
catch (cause) { saveError.value = getApiErrorDetails(cause, '本次草稿尚未释放,请重试') }
|
|
|
|
|
|
finally { saving.value = false }
|
|
|
|
|
|
}
|
|
|
|
|
|
function tripSaved(value: { trip: { tripNo: string } }) { prepareOpen.value = false; emit('updated'); emit('external', `/overseas-master-data/trips/${encodeURIComponent(value.trip.tripNo)}`) }
|
2026-10-19 08:44:26 +08:00
|
|
|
|
async function continueArranging() { if (!pending.value && !saving.value && !sharedTripBusy.value) { sharedTripOpen.value = false; result.value = null; arrangingRemaining.value = true; await load() } }
|
2026-10-15 17:11:03 +08:00
|
|
|
|
onMounted(load)
|
|
|
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<section class="work-form" aria-label="按预约批次排车">
|
2026-10-19 08:44:26 +08:00
|
|
|
|
<h3>{{ existingDispatches.length ? '沿用本批已有排车单' : '按批次选择真实托盘' }}</h3><p>已有排车单沿用原实物安排车辆;只有尚未分配的实物才需要创建排车单。</p>
|
|
|
|
|
|
<el-button v-if="batchKeys.length === 1 && !result && !existingDispatches.length" :disabled="loading || saving || !!pending" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p v-if="batchKeys.length === 1 && !result && !existingDispatches.length">只安排部分箱数时,先核对本批逐来源数量并分批;新的当前批次继续使用原排车资格。若箱数无法对应完整实物,请先在原库存作业中完成拆托;不把整票重新选入。</p>
|
2026-10-15 17:11:03 +08:00
|
|
|
|
<p v-if="!readable">读取批次实物需要预约、尾程订单和入仓单列表权限。</p>
|
|
|
|
|
|
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
|
|
|
|
|
<p v-if="loading">正在核对批次、客户与实物来源…</p>
|
2026-10-19 08:44:26 +08:00
|
|
|
|
<template v-if="existingDispatches.length && !loading && !error && !result">
|
|
|
|
|
|
<WorkExistingDispatchForm :key="JSON.stringify(existingDispatches)" :dispatches="existingDispatches" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
|
|
|
|
|
|
<el-button v-if="!arrangingRemaining && writable" @click="continueArranging">核对本批剩余可排实物</el-button>
|
|
|
|
|
|
</template>
|
2026-10-16 15:41:56 +08:00
|
|
|
|
<template v-if="result"><p>已创建排车单:{{ result.dispatchNo }}</p><el-button v-if="result.overseasWarehouseId && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" type="primary" :disabled="sharedTripBusy" @click="prepareOpen = true">继续安排本批车辆</el-button><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list') && access.can('platform:fleet:trip:list') && access.can('platform:fleet:trip:edit')" @click="sharedTripOpen = true">与同仓其他排车单共车</el-button><WorkSharedTripForm v-if="sharedTripOpen" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="sharedTripBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><p v-if="!result.overseasWarehouseId">排车结果未提供起运仓,需在原排车单核对。</p><el-button v-if="access.can('platform:overseas-warehouse:dispatch:list')" @click="emit('external', `/overseas-warehouse/dispatches?dispatchNo=${encodeURIComponent(result.dispatchNo)}`)">查看原排车单</el-button><el-button :disabled="sharedTripBusy" @click="continueArranging">继续安排其他实物</el-button></template>
|
2026-10-19 08:44:26 +08:00
|
|
|
|
<template v-else-if="readable && !loading && !error && (!existingDispatches.length || arrangingRemaining && existingReady)"><p v-for="reason in contexts.flatMap(context => context.blockReasons)" :key="reason">{{ reason }}</p><label>预排车型<select v-model.number="capacity" :disabled="saving || !!pending || !writable"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
2026-10-15 17:11:03 +08:00
|
|
|
|
<table class="density-table"><thead><tr><th>选择</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.planPalletId}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.planPalletId)" :disabled="!writable || loading || saving || !!pending || !!error || !!reasons(row).length" @change="changeSelection(row, $event)"></td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : row.sourceStage === 'PLAN' ? '计划托盘' : '原业务来源' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table>
|
|
|
|
|
|
<p v-if="!loading && !error && !rows.length">当前批次尚无可排实物。</p><el-button type="primary" :disabled="!writable || loading || !!error" :loading="saving" @click="save">{{ pending ? '重试本次预排' : '创建本客户排车单' }}</el-button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="rejected && pending" :loading="saving" @click="refreshRejected">释放本次草稿并重新核对</el-button>
|
|
|
|
|
|
<TripPreparationDialog v-if="result?.overseasWarehouseId" v-model="prepareOpen" :warehouse-id="result.overseasWarehouseId" :warehouse-name="result.overseasWarehouseName || '起运仓待核实'" :dispatch-nos="[result.dispatchNo]" @saved="tripSaved" />
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</template>
|