Files
admin-web/src/modules/appointment-calendar/WorkDispatchForm.vue
T

249 lines
25 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
2026-10-19 17:56:31 +08:00
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
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'
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, 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'
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'
import WorkSharedTripForm from './WorkSharedTripForm.vue'
import WorkExistingDispatchForm from './WorkExistingDispatchForm.vue'
import { getReceivingContext } from './receivingApi'
2026-10-19 17:56:31 +08:00
import { useWorkDraft } from './useWorkDraft'
const props = defineProps<{ batchKeys: string[]; canWrite: boolean }>()
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('')
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error, listDispatchCandidates)
const { page: candidatePageNumber, size: candidatePageSize, total: candidateTotal } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const selectedRows = ref<Record<string, BatchRow>>({})
let receivingContexts: Awaited<ReturnType<typeof getReceivingContext>>[] = []
let nativeGeneration = 0
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(() => selected.value.map(key => selectedRows.value[key]).filter((row): row is BatchRow => Boolean(row)))
2026-10-19 17:56:31 +08:00
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 }
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)}` }
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)]
working.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []; selectedRows.value = {}; candidatePage.reset(); nativeGeneration++
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 = []
receiving.forEach((context, index) => {
const batchKey = batches[index]!
2026-10-19 17:56:31 +08:00
if (context.batch.batchKey !== batchKey) throw new DispatchInputError('运输关系与当前预约批次不一致,请重新读取')
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('本批已有运输安排但缺少排车单号,请核对原记录')
references.push({ batchKey, dispatchNo: vehicle.dispatchNo, customerId: vehicle.customerId })
}
})
2026-10-19 17:56:31 +08:00
existing.value = references
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; receivingContexts = receiving
await loadCandidatePage(true)
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
finally { if (current === generation) working.value = false }
}
async function loadCandidatePage(autoSelect = false) {
const sequence = ++nativeGeneration, current = generation
const planIds = [...new Set(contexts.value.flatMap(context => context.candidates.map(row => row.planId)))]
if (!planIds.length) { candidatePage.reset(); rows.value = []; return }
await candidatePage.load({ warehouseId: warehouseId.value, planIds, page: candidatePageNumber.value, size: candidatePageSize.value, warehouseTab: 'ALL' })
if (sequence !== nativeGeneration || current !== generation) return
if (error.value) { rows.value = []; return }
try {
requireBatchVersions(candidatePage.candidates.value.flatMap(row => row.appointmentBatches))
rows.value = contexts.value.flatMap((context, index) => context.candidates.map(row => {
const matches = candidatePage.candidates.value.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
2026-10-19 17:56:31 +08:00
const candidate = matches.length === 1 ? matches[0]! : null
const sources = receivingContexts[index]!.batch.items
2026-10-19 17:56:31 +08:00
const blocks = [...context.blockReasons, ...row.blockReasons]
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
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)] }
}))
if (candidateTotal.value > candidatePageSize.value) rows.value = rows.value.filter(row => row.native)
2026-10-19 17:56:31 +08:00
for (const row of rows.value) if (row.native && rows.value.filter(other => other.native?.sourceKey === row.native!.sourceKey).length > 1) row.blockReasons.push('同一实物出现在多条批次候选,请先核对分配范围')
if (autoSelect && candidateTotal.value <= candidatePageSize.value) {
const eligible = rows.value.filter(row => !reasons(row).length)
selected.value = props.batchKeys.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
selectedRows.value = Object.fromEntries(eligible.filter(row => selected.value.includes(row.key)).map(row => [row.key, clone(row)]))
baseline.value = JSON.stringify(selected.value)
}
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
}
function changeCandidatePage(page: number) { candidatePageNumber.value = page; void loadCandidatePage() }
function changeCandidateSize(size: number) { candidatePageSize.value = size; candidatePageNumber.value = 1; void loadCandidatePage() }
2026-10-19 17:56:31 +08:00
function select(row: BatchRow, event: Event) {
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 { selectedRows.value[row.key] = clone(row); selected.value.push(row.key) }
2026-10-19 17:56:31 +08:00
}
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('原来源未确认进入本装车组,请核对原请求')
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
}
}
async function save() {
2026-10-19 17:56:31 +08:00
if (saving.value || loading.value || corrupted.value || !canCreate.value) return
saveError.value = null
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('请核对本仓实物资格、来源批次与计划托位')
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
2026-10-19 17:56:31 +08:00
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-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('草稿真实来源尚未完整恢复,请保留原请求核对')
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 : '本次整车结果待确认,请核对并重试原请求') }
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 : '原草稿版本读取失败') }
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-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() } }
onMounted(load)
onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset() })
</script>
<template>
2026-10-19 17:56:31 +08:00
<section class="work-form" aria-label="按预约批次创建整车排车">
<h3>{{ existing.length ? '沿用本批已有排车单' : '按批次选择同仓真实货物' }} <InlineHelp title="排车说明"><p>同一张排车单可装同仓多个客户与目的仓,客户和预约归属逐来源保留。</p></InlineHelp></h3>
2026-10-19 17:56:31 +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>
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>
</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-pagination aria-label="批次关联候选分页" :current-page="candidatePageNumber" :page-size="candidatePageSize" :page-sizes="[10, 20, 50]" :total="candidateTotal" :disabled="unresolved || loading" layout="total, sizes, prev, pager, next" @update:current-page="changeCandidatePage" @update:page-size="changeCandidateSize" />
<p>上方总数为关联计划的候选数;当前页展示其中属于本批次的实物。已选 {{ chosen.length }} 条(含其他页)。</p>
2026-10-19 17:56:31 +08:00
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
</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>
</section>
</template>