Files
admin-web/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
T
2026-08-31 14:42:28 +08:00

236 lines
15 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { completeDispatchPda, getDispatch, getDispatchPdaTask, pickDispatchPda, pushDispatchPda, type DispatchItemDto, type DispatchPdaItemDto, type DispatchPdaTaskDto, type DispatchSummaryDto } from '../api/dispatches'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime } from '../lastMilePresentation'
interface DetailField { label: string; value: string | number }
interface RelatedOrderRow {
orderNo: string
weight: string
palletCount: number
fbaWarehouse: string
volume: number
containerNo: string
}
const route = useRoute()
const router = useRouter()
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
const detail = ref<DispatchSummaryDto | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const pdaTask = ref<DispatchPdaTaskDto | null>(null)
const pdaLoading = ref(false)
const pdaMutating = ref(false)
const pdaError = ref<ApiErrorDetails | null>(null)
const pdaPushKey = ref('')
const pdaReason = ref('')
const pickForm = ref({ itemId: 0, positionId: 0, packages: 1, productQuantity: 1, idempotencyKey: '' })
const dispatchStatusLabels: Record<string, string> = {
PENDING_DEPARTURE: '待发车',
PICKING: '拣货中',
READY_TO_DEPART: '待发车(拣货完成)',
PARTIAL_READY_TO_DEPART: '待发车(部分完成)',
DEPARTED: '已发车',
PARTIAL_DEPARTED: '已发车(部分完成)',
CANCELLED: '已取消',
}
const appointmentStatusLabels: Record<string, string> = {
PENDING: '待预约',
BOOKED: '已预约',
RESERVED: '已预约',
CONFIRMED: '已确认',
CANCELLED: '已取消',
}
function text(value: unknown) { return String(value ?? '').trim() || '—' }
function dispatchStatus(value: unknown) {
const code = String(value ?? '').trim()
return dispatchStatusLabels[code] || text(code)
}
function appointmentStatus(value: unknown) {
const code = String(value ?? '').trim()
return appointmentStatusLabels[code] || text(code)
}
function orderReferences(item: DispatchItemDto) {
const references = String(item.orderRefs || '').split(/[,,;;\s]+/).map((value) => value.trim()).filter(Boolean)
return references.length ? references : [text(item.orderNo)]
}
const vehicleFields = computed<DetailField[]>(() => [
{ label: '车牌号', value: text(detail.value?.vehiclePlate) },
{ label: '司机', value: text(detail.value?.driverName) },
{ label: '联系电话', value: text(detail.value?.driverPhone) },
{ label: '拖车公司名称', value: text(detail.value?.carrierName) },
{ label: '车辆状态', value: dispatchStatus(detail.value?.status) },
])
const dispatchFields = computed<DetailField[]>(() => [
{ label: '路线', value: text(detail.value?.route) },
{ label: '发车时间', value: formatOverseasDateTime(detail.value?.departAt) },
{ label: '预计到达时间', value: formatOverseasDateTime(detail.value?.estimatedArrivalAt) },
{ label: 'FBA预约', value: appointmentStatus(detail.value?.fbaAppointmentStatus) },
{ label: '车辆预约', value: appointmentStatus(detail.value?.vehicleAppointmentStatus) },
])
const documentFields = computed<DetailField[]>(() => [
{ label: '排车单号', value: text(detail.value?.dispatchNo || dispatchNo.value) },
{ label: '关联订单', value: text(detail.value?.orderNos) },
{ label: '创建时间', value: formatOverseasDateTime(detail.value?.createdAt) },
{ label: '更新时间', value: '—' },
{ label: '备注', value: '—' },
...(detail.value?.partialCompletionReason
? [{ label: '部分完成原因', value: text(detail.value.partialCompletionReason) }]
: []),
])
const relatedOrders = computed<RelatedOrderRow[]>(() => {
const grouped = new Map<string, { pallets: Set<string>; destinations: Set<string>; volume: number }>()
for (const item of detail.value?.items || []) {
for (const orderNo of orderReferences(item)) {
const current = grouped.get(orderNo) || { pallets: new Set<string>(), destinations: new Set<string>(), volume: 0 }
current.pallets.add(String(item.warehousePalletId || item.planPalletId || item.id))
const destination = text(item.fbaWarehouseCode || item.destinationWarehouseCode || item.destinationWarehouseName)
if (destination !== '—') current.destinations.add(destination)
current.volume += Number(item.volumeCbm || 0)
grouped.set(orderNo, current)
}
}
return [...grouped.entries()].map(([orderNo, value]) => ({
orderNo,
weight: '—',
palletCount: value.pallets.size,
fbaWarehouse: [...value.destinations].join('、') || '—',
volume: Number(value.volume.toFixed(3)),
containerNo: '—',
}))
})
const orderColumns: DataTableColumn[] = [
{ key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 },
{ key: 'weight', prop: 'weight', label: '重量(lb)', minWidth: 130, align: 'right' },
{ key: 'palletCount', prop: 'palletCount', label: '托数', minWidth: 100, align: 'right' },
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: '目的FBA仓', minWidth: 150 },
{ key: 'volume', prop: 'volume', label: '体积(m³)', minWidth: 130, align: 'right' },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 170 },
]
async function load() {
if (!dispatchNo.value) {
loadError.value = { message: '详情缺少排车单号,请从排车列表重新进入' }
return
}
loading.value = true
loadError.value = null
try { detail.value = await getDispatch(dispatchNo.value) }
catch (error) { loadError.value = getApiErrorDetails(error, '排车详情加载失败,请重试') }
finally { loading.value = false }
}
function back() { void router.push({ name: 'overseas-dispatches' }) }
function makeKey(prefix: string) { return `${prefix}-${dispatchNo.value}-${Date.now()}-${Math.random().toString(16).slice(2)}` }
function selectPdaItem(item: DispatchPdaItemDto) {
const position = item.positions?.find((value) => value.availablePackages > 0) || item.positions?.[0]
pickForm.value = { itemId: item.dispatchItemId, positionId: Number(position?.id || 0), packages: 1, productQuantity: 1, idempotencyKey: makeKey('web-pick') }
}
function selectPdaRow(item: unknown) { selectPdaItem(item as DispatchPdaItemDto) }
async function loadPdaTask() {
if (!dispatchNo.value) return
pdaLoading.value = true; pdaError.value = null
try { pdaTask.value = await getDispatchPdaTask(dispatchNo.value); if (!pickForm.value.itemId && pdaTask.value.items?.[0]) selectPdaItem(pdaTask.value.items[0]) }
catch (error) { pdaError.value = getApiErrorDetails(error, 'PDA任务加载失败,请先启动拣货任务') }
finally { pdaLoading.value = false }
}
async function startPda() {
pdaMutating.value = true; pdaError.value = null
try { pdaTask.value = await pushDispatchPda(dispatchNo.value, { idempotencyKey: pdaPushKey.value.trim() || makeKey('web-push') }); pdaPushKey.value = ''; if (pdaTask.value.items?.[0]) selectPdaItem(pdaTask.value.items[0]); ElMessage.success('PDA拣货任务已启动') }
catch (error) { pdaError.value = getApiErrorDetails(error, '启动PDA任务失败') }
finally { pdaMutating.value = false }
}
async function submitPick() {
const form = pickForm.value
if (!form.itemId || !form.positionId || form.packages < 1) { ElMessage.warning('请选择拣货明细和有效库位、数量'); return }
pdaMutating.value = true; pdaError.value = null
try { await pickDispatchPda(dispatchNo.value, form.itemId, { positionId: form.positionId, packages: form.packages, productQuantity: form.productQuantity || form.packages, idempotencyKey: form.idempotencyKey.trim() || makeKey('web-pick') }); ElMessage.success('拣货成功,库存已扣减'); await loadPdaTask() }
catch (error) { pdaError.value = getApiErrorDetails(error, '拣货失败,请检查库位库存') }
finally { pdaMutating.value = false }
}
async function completePda() {
pdaMutating.value = true; pdaError.value = null
try { await completeDispatchPda(dispatchNo.value, { idempotencyKey: makeKey('web-complete'), reason: pdaReason.value.trim() || '正常完成拣货' }); ElMessage.success('拣货任务已完成'); await loadPdaTask(); await load() }
catch (error) { pdaError.value = getApiErrorDetails(error, '完成拣货失败') }
finally { pdaMutating.value = false }
}
onMounted(load)
</script>
<template>
<OverseasDetailShell title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" back-label="返回排车列表" domain="排车详情" :pending="false" :status-text="detail ? dispatchStatus(detail.status) : '加载中'" @back="back">
<LoadErrorState v-if="loadError" title="排车详情加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<div v-else v-loading="loading" class="dispatch-detail" :aria-busy="loading">
<div class="summary-grid">
<section class="detail-section" aria-labelledby="dispatch-vehicle-heading"><h2 id="dispatch-vehicle-heading">车辆信息</h2><dl class="detail-fields"><div v-for="field in vehicleFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section" aria-labelledby="dispatch-info-heading"><h2 id="dispatch-info-heading">排车信息</h2><dl class="detail-fields"><div v-for="field in dispatchFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
<section class="detail-section document-section" aria-labelledby="dispatch-document-heading"><h2 id="dispatch-document-heading">单据信息</h2><dl class="detail-fields"><div v-for="field in documentFields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value }}</dd></div></dl></section>
</div>
<section class="detail-section order-section" aria-labelledby="dispatch-orders-heading"><h2 id="dispatch-orders-heading">关联订单明细</h2><DataTable :data="relatedOrders" :columns="orderColumns" column-settings-mode="hide" row-key="orderNo" empty-text="暂无关联订单明细" /></section>
<section class="detail-section pda-section" aria-labelledby="dispatch-pda-heading">
<div class="pda-header"><h2 id="dispatch-pda-heading">PDA拣货与库存扣减</h2><div class="pda-actions"><el-input v-model="pdaPushKey" placeholder="启动幂等键(可选)" clearable /><el-button type="primary" :loading="pdaMutating" @click="startPda">启动拣货</el-button><el-button :loading="pdaLoading" @click="loadPdaTask">刷新任务</el-button></div></div>
<el-alert v-if="pdaError" :title="pdaError.message" type="error" :closable="false" class="pda-alert" />
<div v-if="pdaTask" v-loading="pdaLoading" class="pda-body">
<div class="pda-meta">任务号:{{ pdaTask.taskNo }} 状态:<strong>{{ dispatchStatus(pdaTask.status) }}</strong> 仓库:{{ text(pdaTask.warehouseId) }}</div>
<el-table :data="pdaTask.items || []" size="small" border @row-click="selectPdaItem">
<el-table-column label="托盘" prop="palletNo" min-width="140" /><el-table-column label="转仓提示" min-width="150"><template #default="{ row }"><el-tag v-if="row.warehouseRouteLabel" type="warning" effect="plain">{{ row.warehouseRouteLabel }}</el-tag><span v-else>{{ text(row.fbaWarehouseCode || row.destinationWarehouseCode) }}</span></template></el-table-column><el-table-column label="计划箱数" prop="scheduledPackages" width="100" /><el-table-column label="已拣箱数" prop="pickedPackages" width="100" />
<el-table-column label="库位(可用)" min-width="220"><template #default="{ row }"><el-button v-for="position in row.positions || []" :key="position.id" link type="primary" @click.stop="selectPdaRow(row); pickForm.positionId = Number(position.id)">{{ position.id }}({{ position.availablePackages }})</el-button></template></el-table-column>
</el-table>
<div class="pick-form"><span class="pick-label">拣货明细:{{ pickForm.itemId || '未选择' }}</span><el-input-number v-model="pickForm.positionId" :min="1" controls-position="right" placeholder="库位ID" /><el-input-number v-model="pickForm.packages" :min="1" controls-position="right" placeholder="箱数" @change="(value) => { pickForm.productQuantity = Number(value || 1) }" /><el-input v-model="pickForm.idempotencyKey" placeholder="拣货幂等键" /><el-button type="success" :loading="pdaMutating" @click="submitPick">拣货并扣减库存</el-button></div>
<div class="pda-complete"><el-input v-model="pdaReason" placeholder="完成原因(可选)" /><el-button type="warning" :loading="pdaMutating" @click="completePda">完成拣货</el-button></div>
</div>
<el-empty v-else description="尚未加载PDA任务,请点击启动拣货" />
</section>
</div>
</OverseasDetailShell>
</template>
<style scoped>
.dispatch-detail { min-width: 0; min-height: 260px; }
.summary-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); border: 1px solid var(--cc-border); border-bottom: 0; background: #fff; }
.detail-section { min-width: 0; padding: 18px; }
.summary-grid > .detail-section { border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.summary-grid > .detail-section:nth-child(2) { border-right: 0; }
.document-section { grid-column: 1 / 2; }
.detail-section h2 { margin: 0 0 14px; color: var(--el-color-primary); font-size: 16px; }
.detail-fields { margin: 0; border-top: 1px solid var(--cc-border); }
.detail-fields > div { display: grid; min-height: 48px; grid-template-columns: 180px minmax(0, 1fr); border-bottom: 1px solid var(--cc-border); }
.detail-fields dt, .detail-fields dd { display: flex; min-width: 0; align-items: center; padding: 9px 12px; }
.detail-fields dt { color: var(--cc-muted); }
.detail-fields dd { margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; }
.order-section { overflow: hidden; border: 1px solid var(--cc-border); border-top: 0; background: #fff; }
.order-section h2 { margin-right: 4px; margin-left: 4px; }
.pda-section { border: 1px solid var(--cc-border); border-top: 0; background: #fff; }
.pda-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.pda-header h2 { margin-bottom: 0; }
.pda-actions, .pick-form, .pda-complete { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pda-actions :deep(.el-input) { width: 230px; }
.pda-alert { margin: 12px 0; }
.pda-meta { margin: 12px 0; color: var(--cc-muted); }
.pick-form { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--cc-border); }
.pick-form :deep(.el-input), .pda-complete :deep(.el-input) { width: 220px; }
.pick-label { color: var(--cc-muted); }
.pda-complete { justify-content: flex-end; margin-top: 12px; }
@media (max-width: 960px) {
.summary-grid { grid-template-columns: 1fr; }
.summary-grid > .detail-section, .summary-grid > .detail-section:nth-child(2) { border-right: 0; }
.document-section { grid-column: auto; }
.detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); }
.pda-actions { width: 100%; }
}
</style>