2026-10-15 17:11:03 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-10-20 10:11:31 +08:00
|
|
|
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
2026-10-19 17:56:31 +08:00
|
|
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
2026-10-15 17:11:03 +08:00
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
2026-10-19 17:56:31 +08:00
|
|
|
|
import { useAuthStore } from '@/core/auth/auth'
|
2026-10-20 10:11:31 +08:00
|
|
|
|
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
|
2026-10-15 17:11:03 +08:00
|
|
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
2026-10-19 17:56:31 +08:00
|
|
|
|
import { 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-19 17:56:31 +08:00
|
|
|
|
import { useWorkDraft } from './useWorkDraft'
|
2026-10-15 17:11:03 +08:00
|
|
|
|
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-19 17:56:31 +08:00
|
|
|
|
type BatchRow = BatchDispatchCandidate & { key: string; batchKey: string; native: Candidate | null; blockReasons: string[] }
|
|
|
|
|
|
type ReadyRow = BatchRow & { native: Candidate }
|
|
|
|
|
|
type Step = { action: 'CREATE' | 'CONTAINER' | 'LOCK' | 'CONVERT' | 'RELEASE'; body: Record<string, unknown>; attempted: boolean; rejected: boolean }
|
|
|
|
|
|
type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; dispatchKey: string; draft: DispatchDraft | null; containerId: string | null; lockedKeys: string[]; step: Step | null; converted: ConvertedDraft | null; releasing: boolean }
|
|
|
|
|
|
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
|
|
|
|
|
|
const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}`
|
|
|
|
|
|
const contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
|
2026-10-15 17:11:03 +08:00
|
|
|
|
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
|
2026-10-19 17:56:31 +08:00
|
|
|
|
const capacity = ref(30), result = ref<DispatchDetail | null>(null), pending = ref<Preparation | null>(null), corrupted = ref(false)
|
|
|
|
|
|
const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false)
|
|
|
|
|
|
const existing = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), baseline = ref('[]')
|
|
|
|
|
|
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
|
|
|
|
|
|
const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create'))
|
|
|
|
|
|
const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value))
|
|
|
|
|
|
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.key)))
|
|
|
|
|
|
const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value)
|
|
|
|
|
|
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
|
|
|
|
|
|
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
|
|
|
|
|
let generation = 0
|
|
|
|
|
|
function ensureOwner() { if (!owner || auth.user?.id !== owner || JSON.stringify([...new Set(props.batchKeys)].sort()) !== scope) throw new DispatchInputError('排车登录身份或批次范围已变化,请重新进入原事项') }
|
|
|
|
|
|
function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey) }
|
|
|
|
|
|
try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation }
|
|
|
|
|
|
catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } }
|
|
|
|
|
|
function reasons(row: BatchRow) { return row.blockReasons }
|
2026-10-20 10:11:31 +08:00
|
|
|
|
function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size }
|
2026-10-19 17:56:31 +08:00
|
|
|
|
function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` }
|
2026-10-15 17:11:03 +08:00
|
|
|
|
async function load() {
|
2026-10-19 17:56:31 +08:00
|
|
|
|
if (!readable.value || pending.value || corrupted.value) return
|
|
|
|
|
|
const current = ++generation, batches = [...new Set(props.batchKeys)]
|
|
|
|
|
|
loading.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []
|
2026-10-16 15:41:56 +08:00
|
|
|
|
try {
|
2026-10-19 17:56:31 +08:00
|
|
|
|
if (!batches.length || batches.some(key => !/^FBA_APPOINTMENT:\d+$/.test(key))) throw new DispatchInputError('请从真实预约批次进入排车,不能重组其他业务批次')
|
|
|
|
|
|
const receiving = await Promise.all(batches.map(key => getReceivingContext(key)))
|
|
|
|
|
|
if (current !== generation) return
|
|
|
|
|
|
const references: typeof existing.value = []
|
2026-10-19 08:44:26 +08:00
|
|
|
|
receiving.forEach((context, index) => {
|
|
|
|
|
|
const batchKey = batches[index]!
|
2026-10-19 17:56:31 +08:00
|
|
|
|
if (context.batch.batchKey !== batchKey) throw new DispatchInputError('运输关系与当前预约批次不一致,请重新读取')
|
2026-10-19 08:44:26 +08:00
|
|
|
|
for (const vehicle of context.transport.vehicles.filter(row => row.batchKey === batchKey && row.status !== 'CANCELLED')) {
|
2026-10-19 17:56:31 +08:00
|
|
|
|
if (!vehicle.dispatchNo) throw new DispatchInputError('本批已有运输安排但缺少排车单号,请核对原记录')
|
2026-10-19 08:44:26 +08:00
|
|
|
|
references.push({ batchKey, dispatchNo: vehicle.dispatchNo, customerId: vehicle.customerId })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-10-19 17:56:31 +08:00
|
|
|
|
existing.value = references
|
2026-10-19 08:44:26 +08:00
|
|
|
|
if (references.length && !arrangingRemaining.value) return
|
2026-10-19 17:56:31 +08:00
|
|
|
|
const origins = [...new Set(receiving.map(context => context.batch.originWarehouseId).filter(Boolean))]
|
|
|
|
|
|
if (origins.length !== 1 || receiving.some(context => !context.batch.originWarehouseId)) throw new DispatchInputError('所选批次须有明确且相同的起运仓,请从同仓批次继续')
|
|
|
|
|
|
warehouseId.value = origins[0]!
|
|
|
|
|
|
const batchCandidates = await Promise.all(batches.map(key => getBatchDispatchCandidates(key)))
|
|
|
|
|
|
if (current !== generation) return
|
|
|
|
|
|
if (batchCandidates.some((context, index) => context.batchKey !== batches[index])) throw new DispatchInputError('实物候选与当前预约批次不一致')
|
|
|
|
|
|
requireBatchVersions(batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)))
|
|
|
|
|
|
contexts.value = batchCandidates
|
|
|
|
|
|
const planIds = [...new Set(batchCandidates.flatMap(context => context.candidates.map(row => row.planId)))]
|
|
|
|
|
|
const native = planIds.length ? await listDispatchCandidates({ warehouseId: warehouseId.value, planIds }) : { candidates: [], complete: true }
|
|
|
|
|
|
if (current !== generation) return
|
|
|
|
|
|
requireBatchVersions([...batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)), ...native.candidates.flatMap(row => row.appointmentBatches)])
|
|
|
|
|
|
if (native.complete !== true) throw new DispatchInputError('本仓原生候选未完整返回,不能按部分结果建单')
|
|
|
|
|
|
rows.value = batchCandidates.flatMap((context, index) => context.candidates.map(row => {
|
|
|
|
|
|
const matches = native.candidates.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
|
|
|
|
|
|
const candidate = matches.length === 1 ? matches[0]! : null
|
|
|
|
|
|
const sources = receiving[index]!.batch.items
|
|
|
|
|
|
const blocks = [...context.blockReasons, ...row.blockReasons]
|
|
|
|
|
|
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
|
2026-10-20 10:11:31 +08:00
|
|
|
|
if (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) }
|
2026-10-19 17:56:31 +08:00
|
|
|
|
if (row.packageCount == null || row.packageCount <= 0 || candidate && candidate.availablePackages !== row.packageCount) blocks.push('批次箱数与本托真实可排量不一致,请核对分批或拆托')
|
|
|
|
|
|
if (!row.allocations.length || row.allocations.some(allocation => allocation.planPalletId !== row.planPalletId || allocation.customerId !== row.customerId || allocation.batchKey !== context.batchKey || !Number.isInteger(allocation.packageCount) || allocation.packageCount <= 0 || !sources.some(source => source.customerId === allocation.customerId && source.sourceKey === allocation.sourceKey && source.destinationKey === allocation.destinationKey && source.packageCount != null && source.packageCount >= allocation.packageCount))) blocks.push('候选不属于当前批次、客户和真实来源范围')
|
|
|
|
|
|
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致')
|
|
|
|
|
|
return { ...row, key: `${context.batchKey}:${row.customerId}:${row.planPalletId}`, batchKey: context.batchKey, native: candidate, blockReasons: [...new Set(blocks)] }
|
|
|
|
|
|
}))
|
|
|
|
|
|
for (const row of rows.value) if (row.native && rows.value.filter(other => other.native?.sourceKey === row.native!.sourceKey).length > 1) row.blockReasons.push('同一实物出现在多条批次候选,请先核对分配范围')
|
|
|
|
|
|
const eligible = rows.value.filter(row => !reasons(row).length)
|
|
|
|
|
|
selected.value = batches.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
|
|
|
|
|
|
baseline.value = JSON.stringify(selected.value)
|
|
|
|
|
|
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
|
|
|
|
|
|
finally { if (current === generation) loading.value = false }
|
2026-10-15 17:11:03 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
function select(row: BatchRow, event: Event) {
|
2026-10-15 17:11:03 +08:00
|
|
|
|
const input = event.target as HTMLInputElement
|
2026-10-19 17:56:31 +08:00
|
|
|
|
if (!writable.value || unresolved.value || loading.value || error.value || reasons(row).length) { input.checked = selected.value.includes(row.key); return }
|
|
|
|
|
|
if (!input.checked) selected.value = selected.value.filter(key => key !== row.key)
|
|
|
|
|
|
else if (!selected.value.includes(row.key)) {
|
|
|
|
|
|
if (physicalCount([...chosen.value, row]) > capacity.value) ElMessage.warning('已达到本组计划托位上限,请核对实际托盘')
|
|
|
|
|
|
else selected.value.push(row.key)
|
|
|
|
|
|
}
|
|
|
|
|
|
input.checked = selected.value.includes(row.key)
|
|
|
|
|
|
}
|
|
|
|
|
|
function step(action: Step['action'], body: object) { pending.value!.step = { action, body: clone(body) as Record<string, unknown>, attempted: false, rejected: false }; persist() }
|
|
|
|
|
|
function common(draft: DispatchDraft) { return { recordVersion: requireVersion(draft.recordVersion), idempotencyKey: crypto.randomUUID(), draftToken: draft.draftToken } }
|
|
|
|
|
|
async function executeStep() {
|
|
|
|
|
|
const request = pending.value!, command = request.step!, previouslyAttempted = command.attempted
|
|
|
|
|
|
command.attempted = true; persist()
|
|
|
|
|
|
try {
|
|
|
|
|
|
let value: DispatchDraft | ConvertedDraft
|
|
|
|
|
|
if (command.action === 'CREATE') value = await createDispatchDraft(command.body as unknown as { idempotencyKey: string; warehouseId: string })
|
|
|
|
|
|
else if (command.action === 'CONTAINER') value = await createDispatchDraftContainer(command.body as unknown as VersionCommand & { draftToken: string; capacity: number })
|
|
|
|
|
|
else if (command.action === 'LOCK') value = await lockDispatchDraftPallet(command.body as unknown as DraftSelection)
|
|
|
|
|
|
else if (command.action === 'RELEASE') value = await releaseDispatchDraft(command.body as unknown as VersionCommand & { draftToken: string })
|
|
|
|
|
|
else value = await convertDispatchDraft(command.body as unknown as ConvertDraft)
|
|
|
|
|
|
ensureOwner()
|
|
|
|
|
|
if (!value.draftToken || command.body.draftToken && value.draftToken !== command.body.draftToken) throw new DispatchInputError('草稿结果与原请求不一致,请保留原请求')
|
|
|
|
|
|
if (command.action === 'CONVERT') {
|
|
|
|
|
|
const converted = value as ConvertedDraft
|
|
|
|
|
|
if (converted.vehicles.length !== 1 || converted.vehicles[0]?.draftContainerId !== request.containerId) throw new DispatchInputError('整车结果与原装车容器不一致')
|
|
|
|
|
|
validateCommandResult(converted.vehicles[0]!.dispatch, { dispatchNo: '', action: 'CREATE', body: { idempotencyKey: request.dispatchKey }, attempted: true, rejected: false, result: null })
|
|
|
|
|
|
request.converted = converted
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const draft = value as DispatchDraft; requireVersion(draft.recordVersion)
|
|
|
|
|
|
if (draft.warehouseId !== request.warehouseId) throw new DispatchInputError('草稿起运仓与原批次不一致')
|
|
|
|
|
|
if (command.action === 'CONTAINER') {
|
|
|
|
|
|
if (draft.containers.length !== 1) throw new DispatchInputError('本次草稿装车容器不唯一,请保留原请求核对')
|
|
|
|
|
|
request.containerId = draft.containers[0]!.draftContainerId
|
|
|
|
|
|
}
|
|
|
|
|
|
if (command.action === 'LOCK') {
|
|
|
|
|
|
const selectedSource = command.body.source as DraftSelection['source']
|
|
|
|
|
|
const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion)
|
|
|
|
|
|
const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId)
|
|
|
|
|
|
if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求')
|
2026-10-20 10:11:31 +08:00
|
|
|
|
const group = requireSelectionGroup(source)
|
|
|
|
|
|
assertSelectionGroupVersions([expected!.native], [source])
|
|
|
|
|
|
const boundKeys = draft.containers.find(row => row.draftContainerId === request.containerId)!.sourceKeys
|
|
|
|
|
|
if (group.sources.some(required => !boundKeys.includes(required.sourceKey))) throw new DispatchInputError('完整选货组尚未原子进入本装车组,请核对原请求')
|
|
|
|
|
|
request.lockedKeys.push(...group.sources.map(required => required.sourceKey))
|
2026-10-19 17:56:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
request.draft = draft
|
|
|
|
|
|
}
|
|
|
|
|
|
request.step = null; persist()
|
|
|
|
|
|
} catch (cause) {
|
|
|
|
|
|
ensureOwner(); if (!previouslyAttempted && isDefiniteDispatchRejection(cause)) command.rejected = true
|
|
|
|
|
|
persist(); throw cause
|
|
|
|
|
|
}
|
2026-10-15 17:11:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
async function save() {
|
2026-10-19 17:56:31 +08:00
|
|
|
|
if (saving.value || loading.value || corrupted.value || !canCreate.value) return
|
|
|
|
|
|
saveError.value = null
|
2026-10-15 17:11:03 +08:00
|
|
|
|
try {
|
2026-10-19 17:56:31 +08:00
|
|
|
|
ensureOwner()
|
|
|
|
|
|
if (!pending.value) {
|
|
|
|
|
|
if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
|
|
|
|
|
|
pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist()
|
2026-10-15 17:11:03 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
saving.value = true
|
|
|
|
|
|
const request = pending.value
|
|
|
|
|
|
while (!request.converted) {
|
|
|
|
|
|
if (!request.step) {
|
|
|
|
|
|
if (request.releasing) { step('RELEASE', common(request.draft!)) }
|
|
|
|
|
|
else if (!request.draft) step('CREATE', { idempotencyKey: crypto.randomUUID(), warehouseId: request.warehouseId })
|
|
|
|
|
|
else if (!request.containerId) step('CONTAINER', { ...common(request.draft), capacity: request.capacity })
|
|
|
|
|
|
else {
|
|
|
|
|
|
const next = request.rows.find(row => !request.lockedKeys.includes(row.native.sourceKey))
|
|
|
|
|
|
if (next) step('LOCK', { ...common(request.draft), draftContainerId: request.containerId, source: next.native.selection })
|
|
|
|
|
|
else {
|
|
|
|
|
|
const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key))
|
|
|
|
|
|
if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对')
|
2026-10-20 10:11:31 +08:00
|
|
|
|
step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo as Candidate[]), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft)
|
2026-10-19 17:56:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
await executeStep()
|
|
|
|
|
|
if (request.releasing) { pending.value = null; persist(); selected.value = []; await load(); return }
|
|
|
|
|
|
}
|
|
|
|
|
|
// Immutable CREATE proof is persisted before reading current facts; GET failure never reconverts.
|
|
|
|
|
|
const proof = request.converted!.vehicles[0]!.dispatch, latest = await getDispatch(proof.dispatchNo)
|
|
|
|
|
|
ensureOwner(); validateCurrentDispatch(latest, proof)
|
|
|
|
|
|
result.value = latest; pending.value = null; persist(); selected.value = []; baseline.value = '[]'
|
|
|
|
|
|
ElMessage.success(`整车排车单 ${latest.dispatchNo} 已创建`); emit('updated')
|
|
|
|
|
|
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '本次整车结果待确认,请核对并重试原请求') }
|
2026-10-15 17:11:03 +08:00
|
|
|
|
finally { saving.value = false }
|
|
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
async function releaseRejected() {
|
|
|
|
|
|
if (saving.value || !pending.value?.step?.rejected || pending.value.converted) return
|
|
|
|
|
|
saving.value = true; saveError.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
ensureOwner(); const request = pending.value
|
|
|
|
|
|
if (!request.draft) { pending.value = null; persist(); return }
|
|
|
|
|
|
const owned = (await listDispatchDrafts()).find(row => row.draftToken === request.draft!.draftToken)
|
|
|
|
|
|
ensureOwner()
|
|
|
|
|
|
if (!owned || owned.warehouseId !== request.warehouseId) throw new DispatchInputError('原拥有者草稿尚未读回,请保留现场核对')
|
|
|
|
|
|
request.draft = owned; request.releasing = true; request.step = null; persist()
|
|
|
|
|
|
} catch (cause) { saveError.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '原草稿版本读取失败') }
|
2026-10-15 17:11:03 +08:00
|
|
|
|
finally { saving.value = false }
|
2026-10-19 17:56:31 +08:00
|
|
|
|
if (pending.value?.releasing) await save()
|
|
|
|
|
|
else if (!pending.value) await load()
|
2026-10-15 17:11:03 +08:00
|
|
|
|
}
|
2026-10-19 17:56:31 +08:00
|
|
|
|
async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
|
2026-10-15 17:11:03 +08:00
|
|
|
|
onMounted(load)
|
2026-10-19 17:56:31 +08:00
|
|
|
|
onBeforeUnmount(() => { generation++ })
|
2026-10-15 17:11:03 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
<template>
|
2026-10-19 17:56:31 +08:00
|
|
|
|
<section class="work-form" aria-label="按预约批次创建整车排车">
|
2026-10-20 10:11:31 +08:00
|
|
|
|
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }} <InlineHelp title="排车说明"><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p></InlineHelp></h3>
|
2026-10-19 17:56:31 +08:00
|
|
|
|
<p v-if="!readable">读取批次实物需要预约、尾程订单、入仓单和排车列表权限。</p>
|
2026-10-15 17:11:03 +08:00
|
|
|
|
<LoadErrorState v-if="error" title="批次可排实物读取失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
2026-10-19 17:56:31 +08:00
|
|
|
|
<p v-if="loading">正在核对批次、客户、起运仓与实物来源…</p>
|
|
|
|
|
|
<template v-if="existing.length && !loading && !error && !result && !pending">
|
|
|
|
|
|
<WorkExistingDispatchForm :key="JSON.stringify(existing)" :dispatches="existing" :can-write="canWrite" @ready="existingReady = $event" @updated="emit('updated')" @external="path => emit('external', path)" />
|
|
|
|
|
|
<el-button v-if="!arrangingRemaining && writable" @click="remaining">核对本批剩余可排实物</el-button>
|
2026-10-19 08:44:26 +08:00
|
|
|
|
</template>
|
2026-10-19 17:56:31 +08:00
|
|
|
|
<template v-if="result"><p>已创建整车排车单:{{ result.dispatchNo }}</p><el-button @click="emit('external', detailPath(result.dispatchNo))">继续维护本车</el-button><el-button v-if="canWrite && access.can('platform:overseas-warehouse:dispatch:edit') && result.actions.ADD_CARGO?.allowed === true" @click="adding = true">为本车添加同仓货物</el-button><WorkSharedTripForm v-if="adding" :dispatch-no="result.dispatchNo" :can-write="canWrite" @busy="addingBusy = $event" @updated="emit('updated')" @external="path => emit('external', path)" /><el-button :disabled="addingBusy" @click="remaining">继续核对剩余实物</el-button></template>
|
|
|
|
|
|
<template v-else-if="!pending && !corrupted && readable && !loading && !error && (!existing.length || arrangingRemaining && existingReady)">
|
|
|
|
|
|
<p v-for="reason in [...new Set(contexts.flatMap(context => context.blockReasons))]" :key="reason" role="alert">{{ reason }}</p>
|
|
|
|
|
|
<el-button v-if="batchKeys.length === 1" :disabled="!writable || unresolved" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p>部分箱数先按本批逐来源数量分批;不对应完整实物时,先在原库存作业拆托。</p>
|
|
|
|
|
|
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
|
|
|
|
|
|
<table class="density-table"><thead><tr><th>选择</th><th>客户</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.key}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.key)" :disabled="!writable || unresolved || reasons(row).length > 0" @change="select(row, $event)"></td><td>{{ row.native?.customerName || '客户待核实' }}</td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : '计划托盘' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table><p v-if="!rows.length">当前批次尚无可排实物。</p>
|
|
|
|
|
|
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
|
2026-10-15 17:11:03 +08:00
|
|
|
|
</template>
|
2026-10-19 17:56:31 +08:00
|
|
|
|
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
|
|
|
|
|
|
<LoadErrorState v-if="saveError" title="排车尚未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button v-if="pending?.step?.rejected && !pending.converted" :loading="saving" @click="releaseRejected">按明确拒绝结果释放本草稿并重新核对</el-button>
|
2026-10-15 17:11:03 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
</template>
|