260 lines
20 KiB
Vue
260 lines
20 KiB
Vue
<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>
|