236 lines
15 KiB
Vue
236 lines
15 KiB
Vue
<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>
|