This commit is contained in:
2026-10-21 13:22:42 +08:00
parent eb0b0f85d1
commit 2333bd8bab
114 changed files with 1766 additions and 526 deletions
@@ -0,0 +1,251 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import { useDispatchCandidates } from '../useDispatchCandidates'
import { useDispatchCommandRecovery, type PendingDispatchCommand } from '../dispatchCommandRecovery'
import { canDispatchAction } from '../dispatchPresentation'
import { DispatchInputError, expandSelectionGroups, requireBatchVersions, requireSelectionGroup, requireVersion, selectionIdentity } from '../dispatchContracts'
import type { AddCargo, Candidate, DispatchCargoItem, DispatchDetail, DispatchItems, DispatchListRow, ReasonCommand } from '../dispatchContracts'
import SmartDispatchCargoBoard from './SmartDispatchCargoBoard.vue'
const props = defineProps<{ dispatchNo?: string }>()
const emit = defineEmits<{ saved: []; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore()
const recovery = useDispatchCommandRecovery(() => auth.user?.id || '')
const { pending, busy } = recovery
const error = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error)
const { candidates, page: cargoPage, size: cargoSize, total: cargoTotal } = candidatePage
const rows = ref<DispatchListRow[]>([]), page = ref(1), size = ref(10), total = ref(0)
const keyword = ref(''), queriedKeyword = ref(''), cargoKeyword = ref(''), queriedCargoKeyword = ref('')
const selectedNo = ref(''), warehouseId = ref(''), warehouseName = ref('')
const detail = ref<DispatchDetail | null>(null), items = ref<DispatchItems | null>(null)
const reading = ref(false), listing = ref(false), preparing = ref(false), stale = ref(true)
const removing = ref<DispatchCargoItem | null>(null), reason = ref('')
let generation = 0, listGeneration = 0, alive = true
const cargoPending = computed(() => pending.value?.action === 'ADD_CARGO' || pending.value?.action === 'REMOVE_CARGO')
const pendingMessage = computed(() => !cargoPending.value ? '另一个排车操作尚待核对,请返回原操作入口处理。' : pending.value?.result ? '货物调整已完成,请刷新当前资料;不会重复提交。' : '原货物调整结果尚待核对,保留原版本和原请求内容。')
const frozen = computed(() => busy.value || preparing.value || Boolean(pending.value))
const loading = computed(() => reading.value || candidatePage.loading.value)
const editable = computed(() => !stale.value && detail.value?.status === 'ACTIVE' && detail.value.stage === 'BEFORE_LOADING' && detail.value.taskType === 'DELIVERY')
const canAdd = computed(() => editable.value && canDispatchAction(detail.value?.actions, 'ADD_CARGO', access.can))
const canRemove = computed(() => editable.value && canDispatchAction(detail.value?.actions, 'REMOVE_CARGO', access.can))
const locked = computed(() => frozen.value || loading.value)
const equalVersion = (left: unknown, right: unknown) => String(requireVersion(left)).replace(/^0+(?=\d)/, '') === String(requireVersion(right)).replace(/^0+(?=\d)/, '')
function report(cause: unknown, fallback: string) {
error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : fallback)
}
async function loadList() {
const sequence = ++listGeneration
listing.value = true
try {
const result = await api.listDispatches({ page: page.value, size: size.value, status: 'ACTIVE', stage: 'BEFORE_LOADING', dispatchNo: queriedKeyword.value || undefined })
if (!alive || sequence !== listGeneration) return
if (result.page !== page.value || result.size !== size.value) throw new DispatchInputError('排车列表页码不一致,请重新查询')
rows.value = result.records; total.value = result.total
} catch (cause) { if (alive && sequence === listGeneration) { rows.value = []; report(cause, '排车列表读取失败') } }
finally { if (alive && sequence === listGeneration) listing.value = false }
}
async function searchDispatches() {
if (frozen.value) return
queriedKeyword.value = keyword.value.trim(); page.value = 1
await loadList()
}
async function changePage(value: number) { if (!frozen.value) { page.value = value; await loadList() } }
async function readFacts(no: string) {
const [current, cargo] = await Promise.all([api.getDispatch(no), api.getDispatchItems(no)])
if (current.dispatchNo !== no || cargo.dispatchNo !== no || !equalVersion(current.recordVersion, cargo.recordVersion) || current.stage !== cargo.stage || current.status !== cargo.status) {
throw new DispatchInputError('排车与货物清单已变化,请重新读取一致版本后操作')
}
return { current, cargo }
}
async function loadCandidates() {
if (!warehouseId.value || !detail.value || !canAdd.value || frozen.value) return
await candidatePage.load({ warehouseId: warehouseId.value, warehouseTab: 'ALL', orderNo: queriedCargoKeyword.value || undefined, page: cargoPage.value, size: cargoSize.value })
}
async function selectDispatch(no: string, row?: DispatchListRow) {
if (!no || busy.value || pending.value && (!cargoPending.value || pending.value.dispatchNo !== no)) return
const sequence = ++generation
selectedNo.value = no; reading.value = true; stale.value = true; error.value = null
detail.value = null; items.value = null; removing.value = null; reason.value = ''
warehouseId.value = ''; warehouseName.value = ''; candidatePage.reset()
try {
const origin = row || (await api.listDispatches({ page: 1, size: 10, dispatchNo: no, status: 'ALL' })).records.find(value => value.dispatchNo === no)
if (!alive || sequence !== generation) return
if (!origin || origin.dispatchNo !== no || !origin.warehouseId) throw new DispatchInputError('排车起运仓未完整返回,请重新读取')
const facts = await readFacts(no)
if (!alive || sequence !== generation) return
warehouseId.value = origin.warehouseId; warehouseName.value = origin.warehouseName || origin.warehouseCode || '—'
detail.value = facts.current; items.value = facts.cargo; stale.value = false
if (!pending.value) await loadCandidates()
} catch (cause) { if (alive && sequence === generation) report(cause, '排车货物读取失败') }
finally { if (alive && sequence === generation) reading.value = false }
}
async function searchCargo() {
if (locked.value) return
queriedCargoKeyword.value = cargoKeyword.value.trim(); cargoPage.value = 1
await loadCandidates()
}
async function changeCargoPage(value: number) { if (!locked.value) { cargoPage.value = value; await loadCandidates() } }
async function changeCargoSize(value: number) { if (!locked.value) { cargoSize.value = value; cargoPage.value = 1; await loadCandidates() } }
async function completeGroup(row: Candidate): Promise<Candidate[]> {
const group = requireSelectionGroup(row)
const found = new Map<string, Candidate>()
function collect(values: Candidate[]) {
for (const value of values) {
const source = group.sources.find(source => source.sourceKey === value.sourceKey)
if (!source) continue
if (value.warehouseId !== warehouseId.value || value.customerId !== source.customerId || value.sourceVersion !== source.selection.sourceVersion || selectionIdentity(value.selection) !== selectionIdentity(source.selection) || value.selectionGroup.groupKey !== group.groupKey || value.selectionGroup.groupVersion !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请刷新后重试')
requireSelectionGroup(value)
found.set(value.sourceKey, value)
}
}
collect(candidates.value)
// A filtered page may hide another source on this physical pallet, including its appointments.
for (let next = 1; found.size < group.sources.length; next++) {
const result = await api.listDispatchCandidates({ warehouseId: warehouseId.value, warehouseTab: 'ALL', page: next, size: 100 })
if (result.page !== next || result.size !== 100) throw new DispatchInputError('货物来源页码不一致,请重新读取')
collect(result.candidates)
if (next >= result.pages || result.candidates.length === 0) break
}
if (found.size !== group.sources.length) throw new DispatchInputError('同托货物及预约资料未完整返回,请刷新后重试')
return group.sources.map(source => found.get(source.sourceKey)!)
}
function buildAddition(row: Candidate, sources: Candidate[]): AddCargo {
if (!canAdd.value || !detail.value || !candidates.value.includes(row)) throw new DispatchInputError('当前货物或排车不允许调整,请刷新核对')
candidatePage.assertCurrent(sources); requireBatchVersions(sources.flatMap(source => source.appointmentBatches))
if (sources.some(source => source.warehouseId !== warehouseId.value || source.blockReasons.length || !source.sourceVersion || source.sourceVersion !== source.selection.sourceVersion || source.appointmentBatches.some(batch => batch.customerId !== source.customerId))) throw new DispatchInputError('请选择同仓、来源完整且预约客户一致的货物')
return {
recordVersion: requireVersion(detail.value.recordVersion), idempotencyKey: crypto.randomUUID(), cargo: expandSelectionGroups(sources),
batchAllocations: sources.flatMap(member => member.appointmentBatches.flatMap(batch => batch.sources.map(source => {
if (!member.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对')
return { planPalletId: member.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion }
}))),
}
}
async function addCargo(row: Candidate) {
if (locked.value) return
error.value = null; preparing.value = true
const sequence = generation
try {
const sources = await completeGroup(row)
if (!alive || sequence !== generation) return
recovery.begin(selectedNo.value, 'ADD_CARGO', buildAddition(row, sources)); preparing.value = false; await execute()
}
catch (cause) { report(cause, '货物未加入,请核对来源') }
finally { preparing.value = false }
}
function removable(row: DispatchCargoItem) {
return canRemove.value && row.remove.allowed === true && row.pickedPackages === 0 && !sharedPallet(row) && Boolean(row.dispatchItemId) && items.value?.items.includes(row)
}
function sharedPallet(row: DispatchCargoItem) { return Boolean(row.warehousePalletId && (items.value?.items.filter(item => item.warehousePalletId === row.warehousePalletId).length || 0) > 1) }
function chooseRemoval(row: DispatchCargoItem) {
if (locked.value || !removable(row)) return
removing.value = row; reason.value = ''
}
async function removeCargo() {
if (locked.value) return
error.value = null
try {
const row = removing.value
if (!row || !removable(row) || !reason.value.trim() || !items.value) throw new DispatchInputError('请选择允许移除的未扫码货物并填写原因')
const body: ReasonCommand = { recordVersion: requireVersion(items.value.recordVersion), idempotencyKey: crypto.randomUUID(), reason: reason.value.trim() }
recovery.begin(selectedNo.value, 'REMOVE_CARGO', body, { itemId: row.dispatchItemId })
await execute()
} catch (cause) { report(cause, '货物未移除,请核对当前作业情况') }
}
function send(command: PendingDispatchCommand) {
if (!access.can('platform:overseas-warehouse:dispatch:edit')) throw new DispatchInputError('当前账号没有调整排车货物权限')
if (command.action === 'ADD_CARGO') return api.addDispatchCargo(command.dispatchNo, command.body as unknown as AddCargo)
if (command.action === 'REMOVE_CARGO' && command.itemId) return api.removeDispatchItem(command.dispatchNo, command.itemId, command.body as unknown as ReasonCommand)
throw new DispatchInputError('请回到原操作入口核对该请求')
}
async function execute() {
if (busy.value || !cargoPending.value) return
const no = pending.value!.dispatchNo
error.value = null
try {
const result = await recovery.execute(send, async target => {
const facts = await readFacts(target)
return facts.current
}, api.getDispatchCommand)
if (!result || !alive) return
ElMessage.success(result.replayed ? '原货物调整结果已核对' : '本车货物已更新,请重新核对审核')
emit('saved')
await selectDispatch(no)
await loadList()
} catch (cause) {
if (!alive) return
stale.value = true
report(cause, pending.value?.result ? '货物已调整,当前资料待刷新' : pending.value ? '操作结果尚未确认,请核对原请求' : '本次货物调整未通过,请刷新当前资料')
}
}
async function retry() { if (cargoPending.value) await execute(); else if (selectedNo.value) await selectDispatch(selectedNo.value); else await loadList() }
watch(() => [loading.value, listing.value, busy.value, preparing.value], () => emit('busy', loading.value || listing.value || busy.value || preparing.value), { immediate: true })
watch(() => props.dispatchNo, no => {
const target = cargoPending.value ? pending.value!.dispatchNo : no
if (target) void selectDispatch(target)
}, { immediate: true })
void loadList()
onBeforeUnmount(() => { alive = false; generation++; listGeneration++; candidatePage.reset(); emit('busy', false) })
</script>
<template>
<div class="existing-cargo density-dialog-sections">
<el-alert v-if="pending" :title="pendingMessage" type="warning" :closable="false" show-icon />
<el-button v-if="cargoPending" :loading="busy" @click="execute">{{ pending?.result ? '刷新当前资料' : '核对并重试原请求' }}</el-button>
<LoadErrorState v-if="error" title="排车货物资料待核对" :message="error.message" :loading="loading || busy" retry-text="重新核对" @retry="retry"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div class="existing-cargo-query">
<el-input v-model="keyword" aria-label="查找已有排车" placeholder="排车单号" :disabled="frozen || listing" @keyup.enter="searchDispatches" />
<el-button :disabled="frozen || listing" @click="searchDispatches">查询排车</el-button>
<el-select :model-value="selectedNo" aria-label="选择已有排车" placeholder="选择装车前排车" :disabled="frozen || listing" @update:model-value="value => selectDispatch(String(value), rows.find(row => row.dispatchNo === value))">
<el-option v-for="row in rows" :key="row.dispatchNo" :value="row.dispatchNo" :label="`${row.dispatchNo} · ${row.warehouseName || row.warehouseCode || '—'}`" :disabled="row.taskType !== 'DELIVERY' || row.status !== 'ACTIVE' || row.stage !== 'BEFORE_LOADING'" />
<el-option v-if="selectedNo && !rows.some(row => row.dispatchNo === selectedNo)" :value="selectedNo" :label="selectedNo" disabled />
</el-select>
<el-pagination aria-label="已有排车分页" :current-page="page" :page-size="size" :total="total" :disabled="frozen || listing" layout="total, prev, pager, next" @update:current-page="changePage" />
</div>
<p v-if="!selectedNo" class="density-dialog-note">选择已有排车后,在这里统一增减本车计划货物。</p>
<template v-if="selectedNo">
<div class="existing-cargo-query"><strong>{{ selectedNo }} · {{ warehouseName || '—' }}</strong><el-input v-model="cargoKeyword" aria-label="查找可排货物" placeholder="订单号/入仓码" :disabled="locked || !canAdd" @keyup.enter="searchCargo" /><el-button :disabled="locked || !canAdd" @click="searchCargo">查询货物</el-button><el-button :disabled="locked" @click="selectDispatch(selectedNo)">刷新本车</el-button></div>
<p v-if="detail && !editable" class="density-dialog-note">仅有效、装车前的仓库派送排车允许调整计划货物。真实拣货仍在 PDA 完成。</p>
<p v-else-if="detail && !canAdd" class="density-dialog-note">{{ detail.actions.ADD_CARGO?.blockReason || '当前状态或权限不允许添加货物' }}</p>
<SmartDispatchCargoBoard :candidates="candidates" :draft="null" :active-container-id="selectedNo" :capacity="0" :disabled="locked || !canAdd" :warehouse-selected="Boolean(warehouseId)" :page="cargoPage" :size="cargoSize" :total="cargoTotal" @add="addCargo" @change-page="changeCargoPage" @change-size="changeCargoSize">
<template #vehicles="{ dragOver, drop, dropTarget }">
<section class="existing-cargo-manifest" :class="{ 'is-drop-target': dropTarget === selectedNo }" aria-label="当前排车货物" @dragover="dragOver($event, selectedNo)" @drop="drop($event, selectedNo)">
<h3>本车货物 · {{ selectedNo }}</h3>
<p class="density-dialog-note">加入后立即保存;移除前填写原因并确认。货物变化后需重新核对审核。</p>
<p v-if="!items" class="density-dialog-note">请读取完整货物清单</p>
<p v-else-if="!items.items.length" class="density-dialog-note">本车暂无货物,可从左侧选择托盘加入。</p>
<div v-for="row in items?.items || []" :key="row.dispatchItemId" class="existing-cargo-item">
<div><strong>{{ row.palletNo || row.inboundCode || row.orderNo || row.relabelNo || '—' }}</strong><p>{{ row.customerName || '—' }} · {{ row.orderNo || row.inboundCode || row.relabelNo || '—' }}</p><p>{{ row.scheduledPackages }} 箱 · 真实已拣 {{ row.pickedPackages }} 箱 · {{ row.destination.warehouseCode || row.destination.address || '—' }}</p><small v-if="!removable(row)">{{ sharedPallet(row) ? '同一实物托盘包含多个来源,不能单独移除此项' : row.remove.blockReason || detail?.actions.REMOVE_CARGO?.blockReason || '当前状态或权限不允许移除' }}</small></div>
<el-button link type="danger" :disabled="locked || !removable(row)" @click="chooseRemoval(row)">移除货物</el-button>
</div>
<div v-if="removing" class="existing-cargo-removal" aria-label="确认移除本车货物"><strong>移除 {{ removing.palletNo || removing.orderNo || removing.inboundCode || '所选货物' }}</strong><el-input v-model="reason" aria-label="移除原因" placeholder="请填写移除原因(必填)" type="textarea" :disabled="locked" /><el-button type="danger" :disabled="locked || !reason.trim() || !removable(removing)" @click="removeCargo">确认移除</el-button><el-button :disabled="locked" @click="removing = null">取消移除</el-button></div>
</section>
</template>
</SmartDispatchCargoBoard>
</template>
</div>
</template>
<style scoped>
.existing-cargo-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
.existing-cargo-query > .el-input { width: 240px; }
.existing-cargo-query > .el-select { width: 350px; }
.existing-cargo-manifest { min-width: 0; border: 1px solid var(--admin-table-border); border-radius: var(--admin-dialog-section-radius); padding: var(--admin-space-5); max-height: 58vh; overflow-y: auto; }
.existing-cargo-manifest.is-drop-target { border-color: var(--cc-primary); background: var(--admin-action-primary-hover); }
.existing-cargo-manifest h3 { margin: 0; font-size: var(--admin-font-section); }
.existing-cargo-item { display: flex; gap: var(--admin-space-3); justify-content: space-between; padding: var(--admin-space-4) 0; border-bottom: 1px solid var(--admin-table-border); overflow-wrap: anywhere; }
.existing-cargo-item > div { min-width: 0; }
.existing-cargo-item > .el-button { flex: none; }
.existing-cargo-item p { margin: var(--admin-space-2) 0; }
.existing-cargo-item small { color: var(--cc-muted); }
.existing-cargo-removal { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); margin-top: var(--admin-space-5); }
</style>