Files
admin-web/src/modules/overseas-warehouse/components/SmartDispatchExistingCargo.vue
T
2026-10-21 15:10:54 +08:00

260 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 InlineHelp from '@/shared/components/InlineHelp.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 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(value: string) {
if (frozen.value) return
queriedKeyword.value = 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>
<section class="density-dialog-section existing-cargo-search" aria-label="已有排车查询">
<div class="existing-cargo-query">
<el-select :model-value="selectedNo" aria-label="选择已有排车" placeholder="输入单号搜索并选择排车" filterable remote :remote-method="searchDispatches" :loading="listing" :disabled="frozen" @update:model-value="(value: string) => selectDispatch(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 />
<template #footer><el-pagination aria-label="已有排车分页" :current-page="page" :page-size="size" :total="total" :disabled="frozen || listing" layout="total, prev, pager, next" @update:current-page="changePage" /></template>
</el-select>
<template v-if="selectedNo"><el-input id="existing-cargo-keyword" v-model="cargoKeyword" aria-label="查找可排货物" placeholder="订单号/入仓码" :disabled="locked || !canAdd" @keyup.enter="searchCargo" /><el-button type="primary" :disabled="locked || !canAdd" @click="searchCargo">查询货物</el-button><el-button :disabled="locked" @click="selectDispatch(selectedNo)">刷新本车</el-button></template>
</div>
</section>
<template v-if="selectedNo">
<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 #vehicle-heading>
<h3 class="existing-cargo-title">本车货物 · {{ selectedNo }} · {{ warehouseName || '—' }}</h3>
<InlineHelp title="本车货物调整说明"><p>加入后立即保存;移除前填写原因并确认。货物变化后需重新核对审核。</p></InlineHelp>
</template>
<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)">
<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 { margin-top: var(--admin-space-5); }
.existing-cargo-search { display: flex; flex-direction: column; gap: var(--admin-space-5); }
.existing-cargo-query { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
.existing-cargo-query > .el-button { margin-left: 0; }
.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-title { margin: 0; font-size: var(--admin-font-section); overflow-wrap: anywhere; }
.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>