Files
admin-web/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
T

188 lines
11 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 { inquiryReturnTarget } from '@/modules/overseas-master-data'
import { physicalWeightKg } from '../dispatchPhysicalWeight'
import '@/styles/adminDensity.css'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
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 { getDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
import OverseasDetailShell from '../components/OverseasDetailShell.vue'
import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions'
interface DetailField { label: string; value: string | number }
interface RelatedOrderRow {
orderNo: string
weight: number | null
palletCount: number
fbaWarehouse: string
volume: number
containerNo: string
}
const route = useRoute()
const router = useRouter()
const access = useAccessPreviewStore()
const dispatchNo = computed(() => String(route.params.dispatchNo || ''))
const detail = ref<DispatchSummaryDto | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const actionBlockReasons = computed(() => {
const actions = detail.value?.actions
if (!actions) return detail.value ? ['操作资格暂未返回,请刷新详情'] : []
return [...new Set([actions.editBlockReason, actions.cancelBlockReason, actions.removePalletBlockReason, actions.transferBlockReason].filter(Boolean))]
})
function openTrip() {
if (!detail.value?.workflow || !access.can('platform:fleet:trip:list')) return
void router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: detail.value.workflow.tripNo }, query: { tab: 'overview' } })
}
function openOutbound() {
if (!detail.value?.actions?.outboundNo || !access.can(permission.outboundList)) return
void router.push({ name: 'overseas-outbounds', query: { documentNo: detail.value.actions.outboundNo } })
}
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>; weight: number | null; 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>(), weight: 0, 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)
const itemWeight = physicalWeightKg(item.grossWeightKg)
current.weight = current.weight === null || itemWeight === null ? null : current.weight + itemWeight
current.volume += Number(item.volumeCbm || 0)
grouped.set(orderNo, current)
}
}
return [...grouped.entries()].map(([orderNo, value]) => ({
orderNo,
weight: value.weight === null ? null : Number(value.weight.toFixed(3)),
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: '重量(kg)', 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(inquiryReturnTarget(route.query) || { name: 'overseas-dispatches' }) }
onMounted(load)
</script>
<template>
<OverseasDetailShell class="admin-density" dense title="排车详情" reference-label="排车单号" :reference-value="detail?.dispatchNo || dispatchNo" :back-text="inquiryReturnTarget(route.query) ? '返回原拼车登记' : undefined" :back-label="inquiryReturnTarget(route.query) ? '返回原拼车登记' : '返回排车列表'" 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">
<section v-if="detail" class="detail-section" aria-label="排车操作提示"><p v-for="reason in actionBlockReasons" :key="String(reason)">{{ reason }}</p><div class="density-actions"><el-button v-if="detail.workflow && access.can('platform:fleet:trip:list')" class="density-action" @click="openTrip">处理原车次 {{ detail.workflow.tripNo }}</el-button><el-button v-if="detail.actions?.outboundNo && access.can(permission.outboundList)" class="density-action" @click="openOutbound">查看原出库 {{ detail.actions.outboundNo }}</el-button></div></section>
<div class="summary-grid">
<section class="detail-section" aria-labelledby="dispatch-vehicle-heading"><h2 id="dispatch-vehicle-heading">车辆信息</h2><dl class="detail-fields density-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 density-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 density-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>
</div>
</OverseasDetailShell>
</template>
<style scoped>
.detail-section { min-width: 0; padding: var(--admin-space-5); }
.summary-grid > .detail-section:nth-child(2) { border-right: 0; }
.document-section { grid-column: 1 / -1; }
.order-section h2 { margin-right: var(--admin-space-1); margin-left: var(--admin-space-1); }
@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, .document-section .detail-fields { grid-template-columns: 1fr; }
.detail-fields > div { grid-template-columns: 132px minmax(0, 1fr); }
}
.dispatch-detail { min-width: 0; min-height: 0; }
.summary-grid { display: grid; grid-template-columns: minmax(0, 1fr); border: 0; border-bottom: 0; background: #fff; }
.summary-grid > .detail-section { border-right: 0; border-bottom: 1px solid var(--cc-border); }
.detail-section h2 { margin: 0 0 var(--admin-space-5); color: var(--cc-text); font-size: var(--admin-font-section); }
.order-section { overflow: hidden; border: 0; border-top: 0; background: #fff; }
</style>