210 lines
13 KiB
Vue
210 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { Eye, RotateCcw, Send } from '@lucide/vue'
|
||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||
import DataTable from '@/shared/components/DataTable.vue'
|
||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||
import { listPlatformUnifiedOrders, type UnifiedOrderResource } from '@/modules/unified-orders'
|
||
import { listContainers, type ContainerBookingResource } from '@/modules/container-loading'
|
||
import { changeOutboundDispatch, getOutboundDispatchDetail, listOutboundDispatches } from '../api/platformOutboundDispatch'
|
||
import type { OutboundDispatchDetail, OutboundDispatchQuery, OutboundDispatchResource } from '../types'
|
||
import { oceanBookingStatusLabel, outboundDispatchStatusLabel, pickTaskStatusLabel, timelineActionLabel, timelineModuleLabel, timelineStatusLabel, truckStatusLabel } from '../statusLabels'
|
||
|
||
type DispatchFilters = QueryModel & { customerId: string; orderId: string; containerBookingId: string }
|
||
|
||
const access = useAccessPreviewStore()
|
||
const rows = ref<OutboundDispatchResource[]>([])
|
||
const loading = ref(false)
|
||
const error = ref<ApiErrorDetails | null>(null)
|
||
const queried = ref(false)
|
||
const filters = ref<DispatchFilters>({ customerId: '', orderId: '', containerBookingId: '' })
|
||
const customers = ref<PlatformTenantResource[]>([])
|
||
const orders = ref<UnifiedOrderResource[]>([])
|
||
const containers = ref<ContainerBookingResource[]>([])
|
||
const optionsLoading = ref(false)
|
||
const optionsError = ref('')
|
||
const activeQuery = ref<OutboundDispatchQuery | null>(null)
|
||
let listRequestId = 0
|
||
let optionRequestId = 0
|
||
let detailRequestId = 0
|
||
let disposed = false
|
||
|
||
const fields = computed<QueryField[]>(() => [
|
||
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', options: customers.value.map((item) => ({ label: item.legalName?.trim() || item.tenantName, value: String(item.id), disabled: item.status !== 'ACTIVE' })), props: { filterable: true, loading: optionsLoading.value } },
|
||
{ key: 'orderId', type: 'select', label: '订单 / 入库单', width: 280, placeholder: filters.value.customerId ? '请选择该客户的订单' : '请选择订单或入库单', options: orders.value.map((item) => ({ label: `${item.orderNo || item.orderId} / ${item.inboundCode || item.inboundId || '未入库'}`, value: String(item.orderId), disabled: item.inboundId == null })), props: { filterable: true, loading: optionsLoading.value } },
|
||
{ key: 'containerBookingId', type: 'select', label: '柜次', width: 240, placeholder: '请选择柜号或集装箱号', options: containers.value.map((item) => ({ label: `${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`, value: item.id })), props: { filterable: true, loading: optionsLoading.value } },
|
||
])
|
||
|
||
const columns = [
|
||
{ key: 'actions', label: '操作', fixed: 'left', width: 240, slotName: 'actions', hideable: false, movable: false },
|
||
{ prop: 'orderNo', label: '订单号', minWidth: 145 },
|
||
{ prop: 'inboundCode', label: '入库单号', minWidth: 145 },
|
||
{ prop: 'customerName', label: '客户', minWidth: 140 },
|
||
{ prop: 'containerNo', label: '柜号', minWidth: 130 },
|
||
{ prop: 'oceanBookingStatus', label: '订舱', width: 105, slotName: 'booking' },
|
||
{ prop: 'truckAppointmentStatus', label: '拖车', width: 105, slotName: 'truck' },
|
||
{ prop: 'pickTaskStatus', label: '拣货', width: 105, slotName: 'pick' },
|
||
{ prop: 'dispatchStatus', label: '出仓状态', width: 115, slotName: 'status' },
|
||
{ key: 'gate', label: '门禁', width: 90, slotName: 'gate' },
|
||
]
|
||
|
||
function currentQuery(): OutboundDispatchQuery | null {
|
||
const order = orders.value.find((item) => String(item.orderId) === filters.value.orderId)
|
||
const params = {
|
||
orderId: order ? String(order.orderId) : undefined,
|
||
inboundId: order?.inboundId == null ? undefined : String(order.inboundId),
|
||
containerBookingId: String(filters.value.containerBookingId || '').trim() || undefined,
|
||
}
|
||
return params.orderId || params.inboundId || params.containerBookingId ? params : null
|
||
}
|
||
|
||
async function load(params = activeQuery.value) {
|
||
if (!params) return
|
||
const requestId = ++listRequestId
|
||
loading.value = true
|
||
error.value = null
|
||
queried.value = true
|
||
try {
|
||
const result = await listOutboundDispatches(params)
|
||
if (disposed || requestId !== listRequestId) return
|
||
rows.value = result
|
||
} catch (cause) {
|
||
if (disposed || requestId !== listRequestId) return
|
||
rows.value = []
|
||
error.value = getApiErrorDetails(cause)
|
||
} finally {
|
||
if (!disposed && requestId === listRequestId) loading.value = false
|
||
}
|
||
}
|
||
|
||
function search() {
|
||
const params = currentQuery()
|
||
if (!params) {
|
||
listRequestId += 1
|
||
activeQuery.value = null
|
||
rows.value = []
|
||
error.value = null
|
||
loading.value = false
|
||
queried.value = false
|
||
return
|
||
}
|
||
activeQuery.value = params
|
||
void load(params)
|
||
}
|
||
|
||
function resetSearch() {
|
||
listRequestId += 1
|
||
activeQuery.value = null
|
||
rows.value = []
|
||
error.value = null
|
||
loading.value = false
|
||
queried.value = false
|
||
}
|
||
|
||
async function loadOptions(customerId = filters.value.customerId) {
|
||
const requestId = ++optionRequestId
|
||
optionsLoading.value = true
|
||
optionsError.value = ''
|
||
const results = await Promise.allSettled([
|
||
listPlatformTenants({ page: 1, size: 200 }),
|
||
listPlatformUnifiedOrders({ customerId: customerId || undefined, page: 1, size: 200 }),
|
||
listContainers({ page: 1, size: 200 }),
|
||
])
|
||
if (disposed || requestId !== optionRequestId) return
|
||
if (results[0].status === 'fulfilled') customers.value = results[0].value.records
|
||
if (results[1].status === 'fulfilled') orders.value = results[1].value.records
|
||
if (results[2].status === 'fulfilled') containers.value = results[2].value.records
|
||
const failed = ['客户', '订单', '柜次'].filter((_, index) => results[index]?.status === 'rejected')
|
||
if (failed.length) optionsError.value = `${failed.join('、')}选项加载失败,可重新加载后再查询`
|
||
optionsLoading.value = false
|
||
}
|
||
|
||
watch(() => filters.value.customerId, (customerId, previous) => {
|
||
if (customerId === previous) return
|
||
filters.value = { ...filters.value, orderId: '' }
|
||
orders.value = []
|
||
void loadOptions(customerId)
|
||
})
|
||
|
||
const detailOpen = ref(false)
|
||
const detail = ref<OutboundDispatchDetail | null>(null)
|
||
const detailTarget = ref<OutboundDispatchResource | null>(null)
|
||
const detailLoading = ref(false)
|
||
const actionError = ref<ApiErrorDetails | null>(null)
|
||
async function open(value: unknown) {
|
||
const row = value as OutboundDispatchResource
|
||
const requestId = ++detailRequestId
|
||
detailOpen.value = true
|
||
detailTarget.value = row
|
||
detail.value = null
|
||
detailLoading.value = true
|
||
actionError.value = null
|
||
try {
|
||
const result = await getOutboundDispatchDetail({ inboundId: row.inboundId, containerBookingId: row.containerBookingId || undefined })
|
||
if (disposed || requestId !== detailRequestId) return
|
||
detail.value = result
|
||
} catch (cause) {
|
||
if (disposed || requestId !== detailRequestId) return
|
||
actionError.value = getApiErrorDetails(cause)
|
||
} finally {
|
||
if (!disposed && requestId === detailRequestId) detailLoading.value = false
|
||
}
|
||
}
|
||
function retryDetail() { if (detailTarget.value) void open(detailTarget.value) }
|
||
|
||
async function action(value: unknown, name: 'confirm-dispatch' | 'reverse-dispatch') {
|
||
const row = value as OutboundDispatchResource
|
||
try {
|
||
await ElMessageBox.confirm(name === 'confirm-dispatch' ? '确认国内出仓?系统将校验全部装柜门禁。' : '确认撤销国内出仓?', '出仓状态确认', { type: name === 'reverse-dispatch' ? 'warning' : 'info' })
|
||
await changeOutboundDispatch(name, { inboundId: row.inboundId, containerBookingId: row.containerBookingId, expectedVersion: row.version, remark: name === 'confirm-dispatch' ? '后台确认出仓' : '后台撤销出仓' })
|
||
ElMessage.success('出仓状态已更新')
|
||
await load()
|
||
} catch (cause) {
|
||
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) error.value = getApiErrorDetails(cause)
|
||
if (error.value?.code === 'A0409') await load()
|
||
}
|
||
}
|
||
|
||
onMounted(() => void loadOptions())
|
||
onBeforeUnmount(() => { disposed = true; listRequestId += 1; optionRequestId += 1; detailRequestId += 1 })
|
||
</script>
|
||
|
||
<template>
|
||
<AdminPage as="div">
|
||
<ListPageLayout aria-label="国内出仓列表">
|
||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="国内出仓筛选" :fields="fields" :search-on-reset="false" @search="search" @reset="resetSearch" /></template>
|
||
<template v-if="optionsError || error" #state>
|
||
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions()">重新加载选项</el-button></el-alert>
|
||
<LoadErrorState v-if="error" title="国内出仓加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
</template>
|
||
<section v-if="!error" class="surface-panel table-panel">
|
||
<el-empty v-if="!queried" description="请选择订单 / 入库单或柜次后查询" />
|
||
<DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="inboundId" preference-key="outbound-dispatches" empty-text="暂无符合条件的出仓记录">
|
||
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" link type="primary" @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus === 'READY_FOR_DISPATCH'" link type="primary" @click="action(row, 'confirm-dispatch')"><Send :size="15" />确认</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:reverse') && row.dispatchStatus === 'WAITING_FOR_LOADING'" link type="danger" @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></template>
|
||
<template #booking="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.oceanBookingStatus)" /></template><template #truck="{ row }"><StatusBadge :status="truckStatusLabel(row.truckAppointmentStatus)" /></template><template #pick="{ row }"><StatusBadge :status="pickTaskStatusLabel(row.pickTaskStatus)" /></template><template #status="{ row }"><StatusBadge :status="outboundDispatchStatusLabel(row.dispatchStatus)" /></template><template #gate="{ row }"><el-tag :type="row.gate?.passed ? 'success' : 'danger'">{{ row.gate?.passed ? '通过' : '未通过' }}</el-tag></template>
|
||
</DataTable>
|
||
</section>
|
||
</ListPageLayout>
|
||
<AppDialog v-model="detailOpen" title="国内出仓详情" width="900px" :show-confirm="false">
|
||
<LoadErrorState v-if="actionError" title="出仓详情加载失败" :message="actionError.message" :loading="detailLoading" @retry="retryDetail"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState>
|
||
<div v-else-if="detailLoading" class="detail-loading" aria-busy="true"><el-skeleton :rows="5" animated /></div>
|
||
<template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="订单号">{{ detail.aggregate.orderNo }}</el-descriptions-item><el-descriptions-item label="入库单号">{{ detail.aggregate.inboundCode }}</el-descriptions-item><el-descriptions-item label="柜号">{{ detail.aggregate.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="出仓状态"><StatusBadge :status="outboundDispatchStatusLabel(detail.aggregate.dispatchStatus)" /></el-descriptions-item></el-descriptions><h3>统一门禁</h3><el-table :data="detail.aggregate.gate?.items || []"><el-table-column label="结果" width="80"><template #default="scope"><el-tag :type="scope.row.passed ? 'success' : 'danger'">{{ scope.row.passed ? '通过' : '阻断' }}</el-tag></template></el-table-column><el-table-column prop="name" label="门禁" min-width="140" /><el-table-column prop="actual" label="当前情况" min-width="180" /><el-table-column prop="requirement" label="要求" min-width="180" /></el-table><h3>整体时间线</h3><el-timeline><el-timeline-item v-for="entry in detail.timeline" :key="`${entry.module}-${entry.occurredAt}-${entry.action}`" :timestamp="entry.occurredAt">{{ timelineModuleLabel(entry.module) }} · {{ timelineActionLabel(entry.action) }}:{{ timelineStatusLabel(entry.module, entry.fromStatus) }} → {{ timelineStatusLabel(entry.module, entry.toStatus) }}<small>{{ entry.operatorName || '系统' }} {{ entry.remark || '' }}</small></el-timeline-item></el-timeline></template>
|
||
</AppDialog>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
h3 { margin: 20px 0 12px; }
|
||
small { display: block; color: var(--cc-muted); }
|
||
.detail-loading { padding: 12px 0; }
|
||
</style>
|