fix(dispatch): align dispatch actions and native return workflows

This commit is contained in:
2026-10-18 16:37:37 +08:00
parent 4806f88d7c
commit a5a1976924
29 changed files with 854 additions and 150 deletions
@@ -3,14 +3,14 @@ import { physicalWeightLb, totalPhysicalWeightLb } from '../dispatchPhysicalWeig
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { GripVertical, Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, releaseDispatchDraft, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
import { formatOverseasDateTime } from '../lastMilePresentation'
import type { InboundOrderListItem } from '../types'
import { dispatchVehicleSpecs } from '../dispatchVehicleSpecs'
@@ -37,6 +37,10 @@ const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const draftError = ref<ApiErrorDetails | null>(null)
const draftLoading = ref(false)
const draftLoaded = ref(false)
const draftBlocked = computed(() => draftLoading.value || !draftLoaded.value || Boolean(draftError.value))
const mutationError = ref<ApiErrorDetails | null>(null)
const candidates = ref<DispatchPalletCandidateDto[]>([])
const palletSnapshots = ref<Record<string, DispatchPalletCandidateDto>>({})
@@ -73,7 +77,7 @@ const visibleCandidates = computed(() =>
const orderGroups = computed(() => {
const groups = new Map<string, DispatchPalletCandidateDto[]>()
for (const item of visibleCandidates.value) {
const key = String(item.orderId || item.orderNo || item.planId)
const key = item.sourceStage === 'RELABEL' ? `RELABEL:${String(item.orderRefs || '')}` : String(item.orderId || item.orderNo || item.planId)
groups.set(key, [...(groups.get(key) || []), item])
}
return [...groups.entries()].map(([key, pallets]) => ({
@@ -99,6 +103,7 @@ const containerOverweight = (container: LoadingContainer) => {
}
function palletId(item: DispatchPalletCandidateDto) {
if (item.sourceStage === 'RELABEL') return String(item.warehousePalletId || '')
if (item.sourceType === 'RETURN_RECEIPT') return item.returnReceiptId ? `RETURN:${item.returnReceiptId}` : ''
return String(item.planPalletId || item.warehousePalletId || '')
}
@@ -166,6 +171,7 @@ async function hydrateDraftPallets() {
}
async function load() {
if (draftBlocked.value) return
loading.value = true
loadError.value = null
try {
@@ -198,12 +204,12 @@ function reset() {
}
function addContainer() {
if (saving.value) return
if (saving.value || draftBlocked.value) return
selectedVehicle.value = ''
vehicleDialogOpen.value = true
}
async function confirmAddContainer() {
if (saving.value) return
if (saving.value || draftBlocked.value) return
const spec = selectedVehicleSpec.value
if (!spec) {
ElMessage.warning('请选择车型')
@@ -226,7 +232,7 @@ async function confirmAddContainer() {
async function movePallet(item: DispatchPalletCandidateDto, containerId: string) {
const id = palletId(item)
if (saving.value || busyPalletIds.value.includes(id)) return
if (saving.value || draftBlocked.value || busyPalletIds.value.includes(id)) return
const target = containers.value.find((container) => container.id === containerId)
if (!target || target.palletIds.includes(id)) return
if (!String(item.overseasWarehouseId || '').trim()) {
@@ -270,7 +276,7 @@ async function movePallet(item: DispatchPalletCandidateDto, containerId: string)
}
async function removePallet(id: string) {
if (saving.value || busyPalletIds.value.includes(id)) return
if (saving.value || draftBlocked.value || busyPalletIds.value.includes(id)) return
const owner = containerFor(id)
if (!owner) return
busyPalletIds.value.push(id)
@@ -286,13 +292,17 @@ async function removePallet(id: string) {
}
async function deleteContainer(container: LoadingContainer) {
if (saving.value) return
if (saving.value || draftBlocked.value) return
for (const id of [...container.palletIds]) {
await removePallet(id)
}
if (!container.palletIds.length) {
await deleteDispatchDraftContainer(ensureDraftToken(), container.id)
containers.value = containers.value.filter((item) => item.id !== container.id)
try {
await deleteDispatchDraftContainer(ensureDraftToken(), container.id)
containers.value = containers.value.filter((item) => item.id !== container.id)
} catch (error) {
mutationError.value = getApiErrorDetails(error, '删除装车车型失败,请重试')
}
}
}
function dragStart(event: DragEvent, item: DispatchPalletCandidateDto) {
@@ -311,11 +321,15 @@ async function close(value = false) {
async function submit() {
if (saving.value) return
if (draftBlocked.value) {
ElMessage.warning('排车草稿尚未恢复,请先重试恢复草稿;原选择已保留')
return
}
if (busyPalletIds.value.length) {
ElMessage.warning('托盘来源正在锁定,请稍后再预排车')
return
}
if (loading.value || unresolvedAssignedIds.value.length) {
if (loading.value || loadError.value || unresolvedAssignedIds.value.length) {
ElMessage.warning('草稿中部分已选来源尚未恢复,请重试加载后再预排;原选择已保留')
return
}
@@ -358,9 +372,16 @@ async function submit() {
mutationError.value = null
try {
if (relabelOrders.length) {
const latest = await listDispatchCandidates({ sourceStage: 'RELABEL', orderNo: relabelOrders[0], draftToken: ensureDraftToken() })
const required = latest.filter(item => item.sourceStage === 'RELABEL' && String(item.orderRefs || '').trim() === relabelOrders[0])
const selected = new Set(assignedIds.value)
if (!required.length || required.length !== selected.size || required.some(item => !selected.has(palletId(item)))) {
mutationError.value = { message: '换标必须一次选择本申请全部应排托盘,并放入同一装车容器;候选可能已变化,请重新查询核对,原选择已保留' }
return
}
await preArrangeDispatch({
planPalletIds: assigned.value.filter(item => item.sourceType !== 'RETURN_RECEIPT').map(palletId),
returnReceiptIds: assigned.value.filter(item => item.sourceType === 'RETURN_RECEIPT').map(item => String(item.returnReceiptId)),
planPalletIds: assigned.value.map(palletId),
returnReceiptIds: [],
relabelNo: relabelOrders[0],
})
} else {
@@ -385,6 +406,10 @@ async function submit() {
}
emit('update:modelValue', false)
emit('created')
draftLoaded.value = false
draftToken.value = ''
containers.value = []
palletSnapshots.value = {}
ElMessage.success('排车明细已同步到预排列表')
await router.push({ name: 'overseas-dispatches' })
} catch (error) {
@@ -394,37 +419,72 @@ async function submit() {
}
}
async function restoreDraft() {
if (draftLoading.value) return
draftLoading.value = true
draftError.value = null
try {
const drafts = await listDispatchDrafts()
const draft = draftToken.value ? drafts.find(item => item.draftToken === draftToken.value) : drafts[0]
if (draftToken.value && containers.value.length && (!draft || containers.value.some(container =>
container.palletIds.some(id => palletSnapshot(id)?.sourceStage === 'RELABEL') && !draft.containers?.some(item => item.id === container.id)))) {
draftError.value = { message: '原草稿已失效或装车容器已变化,原选择已保留。请重试恢复;仍无法恢复时可释放草稿后重新选择。' }
return
}
if (draft?.draftToken) {
const restoredIds = new Set((draft.containers || []).flatMap(container => container.palletIds || []))
if (assignedIds.value.some(id => palletSnapshot(id)?.sourceStage !== 'RELABEL' && !restoredIds.has(id))) {
ElMessage.warning('原草稿部分来源锁已释放,已按服务端当前草稿恢复,请重新选择这些来源')
}
draftToken.value = draft.draftToken
containers.value = (draft.containers || []).map((c) => {
const spec = dispatchVehicleSpecs.find((item) => item.capacity === c.capacity) || dispatchVehicleSpecs[0]!
const localRelabel = containers.value.find(item => item.id === c.id)?.palletIds.filter(id => palletSnapshot(id)?.sourceStage === 'RELABEL') || []
return { id: c.id, capacity: c.capacity, maxWeightLb: spec.maxWeightLb, vehicleLabel: spec.label, palletIds: [...new Set([...(c.palletIds || []), ...localRelabel])] }
})
} else {
ensureDraftToken()
}
draftLoaded.value = true
} catch (error) {
draftError.value = getApiErrorDetails(error, '排车草稿恢复失败,原选择已保留,请重试恢复后继续')
} finally {
draftLoading.value = false
}
if (draftLoaded.value && !draftError.value) await load()
}
async function restartDraft() {
if (draftLoading.value || saving.value || !draftToken.value) return
try {
await ElMessageBox.confirm('释放原草稿的普通及退回来源锁,并清空本页换标选择后重新开始?', '重新选择排车来源', { customClass: 'admin-density-confirm', confirmButtonText: '释放并重新选择', cancelButtonText: '保留草稿', type: 'warning' })
} catch { return }
draftLoading.value = true
try {
await releaseDispatchDraft(draftToken.value)
draftToken.value = ''
containers.value = []
palletSnapshots.value = {}
draftLoaded.value = false
draftError.value = null
} catch (error) {
draftError.value = getApiErrorDetails(error, '释放草稿失败,原选择已保留,请重试')
} finally { draftLoading.value = false }
if (!draftError.value) await restoreDraft()
}
watch(
() => props.modelValue,
(open) => {
if (!open) return
draftToken.value = makeDraftToken()
containers.value = []
palletSnapshots.value = {}
query.orderNo = ''
query.orderType = ''
query.sourceStage = 'RECEIVED'
query.fbaWarehouseCode = ''
warehouseTab.value = 'ALL'
mutationError.value = null
void (async () => {
const drafts = await listDispatchDrafts().catch(() => [])
const draft = drafts[0]
if (draft?.draftToken) {
draftToken.value = draft.draftToken
containers.value = (draft.containers || []).map((c) => {
const spec = dispatchVehicleSpecs.find((item) => item.capacity === c.capacity) || dispatchVehicleSpecs[0]!
return {
id: c.id,
capacity: c.capacity,
maxWeightLb: spec.maxWeightLb,
vehicleLabel: spec.label,
palletIds: c.palletIds || [],
}
})
}
await load()
})()
draftLoaded.value = false
void restoreDraft()
},
{ immediate: true },
)
@@ -445,7 +505,7 @@ watch([warehouseTab, () => query.orderType], () => {
<el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button :disabled="loading" @click="reset">重置</el-button>
</section>
<p v-if="query.sourceStage === 'RELABEL'" class="dispatch-source-hint">仅展示客户已确认、卡车派送且尚未排车的换标单。沿用原入库库存和托盘,按本次换标箱唛及新目的仓排车,无需重新生成入库单。</p>
<p v-if="query.sourceStage === 'RELABEL'" class="dispatch-source-hint">仅展示客户已确认、卡车派送且尚未排车的换标单。必须一次选择本申请全部应排托盘并放入同一装车容器。换标选择仅保留在当前页面,尚未锁定;刷新页面后需重新选择,提交时由服务端重新校验。</p>
<nav class="warehouse-tabs density-tabs" aria-label="海外仓筛选">
<button
@@ -463,10 +523,12 @@ watch([warehouseTab, () => query.orderType], () => {
</button>
</nav>
<LoadErrorState v-if="loadError" title="可排车入仓单加载失败" :message="loadError.message" :loading="loading" @retry="load"
<LoadErrorState v-if="draftError" title="排车草稿恢复失败" :message="draftError.message" :loading="draftLoading" @retry="restoreDraft"><template #details><ApiErrorMeta :details="draftError" /></template></LoadErrorState>
<p v-else-if="draftLoading" role="status">正在恢复排车草稿,请稍候…</p>
<LoadErrorState v-else-if="loadError" title="可排车入仓单加载失败" :message="loadError.message" :loading="loading" @retry="load"
><template #details><ApiErrorMeta :details="loadError" /></template
></LoadErrorState>
<div v-else class="dispatch-workbench">
<div v-else v-loading="draftLoading" class="dispatch-workbench">
<section class="order-pane" aria-label="可排车入仓单">
<header>
<div>
@@ -549,7 +611,7 @@ watch([warehouseTab, () => query.orderType], () => {
</div>
</div>
</div>
<el-tag v-if="containerFor(palletId(item))" type="success" size="small">已锁定</el-tag>
<el-tag v-if="containerFor(palletId(item))" :type="item.sourceStage === 'RELABEL' ? 'warning' : 'success'" size="small">{{ item.sourceStage === 'RELABEL' ? '已选择,尚未锁定' : '已锁定' }}</el-tag>
<el-dropdown v-else trigger="click" :disabled="!containers.length || busyPalletIds.includes(palletId(item))" @command="movePallet(item, String($event))">
<el-button class="density-action" data-tone="primary" :loading="busyPalletIds.includes(palletId(item))">移入 </el-button>
<template #dropdown
@@ -599,7 +661,7 @@ watch([warehouseTab, () => query.orderType], () => {
><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span
><el-button class="density-action" data-tone="danger" :disabled="saving" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button>
</div>
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span>
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘;换标选择提交时校验,其他来源移入后锁定</span>
</div>
</div>
<el-empty v-if="!containers.length" :image-size="64" description="请先添加装车托盘" />
@@ -608,8 +670,8 @@ watch([warehouseTab, () => query.orderType], () => {
</div>
<template #footer><footer class="dialog-footer">
<span>托盘移入即锁定,重量按 1kg = 2.2046lb 换算。</span>
<div><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="!assignedIds.length || busyPalletIds.length > 0" @click="submit">预排车</el-button></div>
<span>普通/退回来源移入后锁定;换标选择尚未锁定,仅保留在当前页面。重量按 1kg = 2.2046lb 换算。</span>
<div><el-button v-if="draftError && draftToken" :disabled="draftLoading" @click="restartDraft">释放草稿并重新选择</el-button><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="draftBlocked || !assignedIds.length || busyPalletIds.length > 0" @click="submit">预排车</el-button></div>
</footer></template>
</AppDialog>
<el-dialog class="admin-density-dialog" v-model="vehicleDialogOpen" title="选择车型" width="360px" append-to-body>