252 lines
17 KiB
Vue
252 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, reactive, ref } from 'vue'
|
|
import { Eye } from '@lucide/vue'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import { useRouter } from 'vue-router'
|
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
|
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
|
import { convertLastMileOrderToInboundPlan, listLastMileOrders, setLastMileOrderDetained, setLastMileOrderPalletMergeRequired, transferLastMileOrders } from '../api/lastMileOrders'
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
import { overseasWarehousePermissions as permission } from '../permissions'
|
|
import { toLastMileOrderListItem } from '../lastMilePresentation'
|
|
import type { LastMileOrderListItem } from '../types'
|
|
|
|
const rows = ref<LastMileOrderListItem[]>([])
|
|
const access = useAccessPreviewStore()
|
|
const loading = ref(false)
|
|
const loadError = ref<ApiErrorDetails | null>(null)
|
|
const router = useRouter()
|
|
const page = ref(1)
|
|
const pageSize = ref(10)
|
|
const total = ref(0)
|
|
const selectedRows = ref<LastMileOrderListItem[]>([])
|
|
const actionLoading = ref(false)
|
|
const actionError = ref<ApiErrorDetails | null>(null)
|
|
const transferOpen = ref(false)
|
|
const detainedOpen = ref(false)
|
|
const detainedTarget = ref<LastMileOrderListItem | null>(null)
|
|
const detainedRemark = ref('')
|
|
const transferForm = reactive({ warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
|
|
const statusTab = ref<'全部' | '待分配'>('全部')
|
|
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
|
|
const fields = computed<QueryField[]>(() => [
|
|
{ key: 'orderNo', type: 'input', label: '尾程单号', placeholder: '请输入尾程单号' },
|
|
{ key: 'inboundCode', type: 'input', label: '入仓码', placeholder: '请输入入仓码' },
|
|
{ key: 'orderType', type: 'select', label: '订单类型', placeholder: '全部类型', options: [
|
|
{ label: '头程订单', value: 'FIRST_LEG' }, { label: '换标下单', value: 'RELABEL_APPLICATION' },
|
|
{ label: '仓储转运', value: 'WAREHOUSE_TRANSFER' }, { label: '本土散板', value: 'LOCAL_LOOSEBOARD' },
|
|
{ label: '退货换标', value: 'RETURN_RELABEL' }, { label: '一件代发', value: 'ONE_CLICK_DROPSHIP' },
|
|
] },
|
|
{ key: 'warehouse', type: 'input', label: '海外仓', placeholder: '请输入仓库名称或编码' },
|
|
{ key: 'sourceOrderNo', type: 'input', label: '来源订单号', placeholder: '请输入来源订单号' },
|
|
{ key: 'containerNo', type: 'input', label: '集装箱号', placeholder: '请输入集装箱号' },
|
|
{ key: 'transferWarehouseCode', type: 'input', label: '转仓代码', placeholder: '请输入转仓代码' },
|
|
{ key: 'status', type: 'select', label: '当前状态', placeholder: '全部状态', options: [
|
|
{ label: '待分配', value: 'PENDING_ASSIGNMENT' },
|
|
{ label: '待入库', value: 'PENDING_INBOUND' },
|
|
{ label: '入库完成', value: 'INBOUND_COMPLETED' },
|
|
{ label: '待派送', value: 'PENDING_DELIVERY' },
|
|
{ label: '派送中', value: 'DELIVERING' },
|
|
{ label: '派送完成', value: 'DELIVERED' },
|
|
] },
|
|
{ key: 'abnormal', type: 'select', label: '是否异常', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
|
|
{ key: 'detained', type: 'select', label: '是否暂扣', placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
|
|
{ key: 'createdAt', type: 'daterange', label: '创建时间' },
|
|
])
|
|
const columns: DataTableColumn[] = [
|
|
{ key: 'actions', label: '操作', width: 270, fixed: 'left', slotName: 'actions' },
|
|
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
|
|
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170 },
|
|
{ key: 'warning', prop: 'warning', label: '预警', width: 90 },
|
|
{ key: 'orderType', prop: 'orderType', label: '订单类型', minWidth: 110 },
|
|
{ key: 'rejectedAt', prop: 'rejectedAt', label: '拒收时间', minWidth: 150 },
|
|
{ key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 140 },
|
|
{ key: 'owner', prop: 'owner', label: '货主', minWidth: 150 },
|
|
{ key: 'warehouse', prop: 'warehouse', label: '海外仓', minWidth: 170 },
|
|
{ key: 'transferWarehouse', prop: 'transferWarehouse', label: '转仓', minWidth: 140 },
|
|
{ key: 'fbaWarehouse', prop: 'fbaWarehouse', label: 'FBA仓库', minWidth: 130 },
|
|
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 150 },
|
|
{ key: 'abnormal', prop: 'abnormal', label: '是否异常', width: 100 },
|
|
{ key: 'held', prop: 'held', label: '是否暂扣', width: 100 },
|
|
{ key: 'pallets', prop: 'pallets', label: '托数', width: 80, align: 'right' },
|
|
{ key: 'cartons', prop: 'cartons', label: '箱数', width: 80, align: 'right' },
|
|
{ key: 'volume', prop: 'volume', label: '体积(m³)', width: 100, align: 'right' },
|
|
{ key: 'quantity', prop: 'quantity', label: '数量', width: 80, align: 'right' },
|
|
{ key: 'volumeWeight', prop: 'volumeWeight', label: '体积重量', width: 100, align: 'right' },
|
|
{ key: 'portEta', prop: 'portEta', label: '预计到港时间', minWidth: 150 },
|
|
{ key: 'portAta', prop: 'portAta', label: '实际到港时间', minWidth: 150 },
|
|
{ key: 'warehouseEta', prop: 'warehouseEta', label: '预计到仓', minWidth: 140 },
|
|
{ key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 150 },
|
|
{ key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 150 },
|
|
{ key: 'dispatchOrderNo', prop: 'dispatchOrderNo', label: '派送单号', minWidth: 150 },
|
|
{ key: 'relabelOrderNo', prop: 'relabelOrderNo', label: '换标单号', minWidth: 150 },
|
|
{ key: 'relabelStatus', prop: 'relabelStatus', label: '换标状态', width: 110 },
|
|
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100 },
|
|
{ key: 'status', prop: 'status', label: '当前状态', width: 110 },
|
|
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
|
|
{ key: 'internalRemark', prop: 'internalRemark', label: '备注(仅自己)', minWidth: 160 },
|
|
{ key: 'customerRemark', prop: 'customerRemark', label: '备注(客户)', minWidth: 160 },
|
|
{ key: 'warehouseRemark', prop: 'warehouseRemark', label: '备注(仓库)', minWidth: 160 },
|
|
]
|
|
|
|
async function load(model: QueryModel = filters.value) {
|
|
Object.assign(filters.value, model)
|
|
loading.value = true
|
|
loadError.value = null
|
|
try {
|
|
const dateRange = Array.isArray(filters.value.createdAt) ? filters.value.createdAt.map(String) : []
|
|
const booleanValue = (value: unknown) => value === 'true' ? true : value === 'false' ? false : undefined
|
|
const result = await listLastMileOrders({
|
|
page: page.value,
|
|
size: pageSize.value,
|
|
orderNo: String(filters.value.orderNo || '').trim() || undefined,
|
|
orderType: String(filters.value.orderType || '').trim() || undefined,
|
|
warehouse: String(filters.value.warehouse || '').trim() || undefined,
|
|
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
|
|
sourceOrderNo: String(filters.value.sourceOrderNo || '').trim() || undefined,
|
|
containerNo: String(filters.value.containerNo || '').trim() || undefined,
|
|
currentStatus: statusTab.value === '待分配' ? 'PENDING_ASSIGNMENT' : String(filters.value.status || '').trim() || undefined,
|
|
transferWarehouseCode: String(filters.value.transferWarehouseCode || '').trim() || undefined,
|
|
abnormal: booleanValue(filters.value.abnormal),
|
|
detained: booleanValue(filters.value.detained),
|
|
createdFrom: dateRange[0] ? `${dateRange[0]}T00:00:00` : undefined,
|
|
createdTo: dateRange[1] ? `${dateRange[1]}T23:59:59` : undefined,
|
|
})
|
|
rows.value = result.records.map(toLastMileOrderListItem)
|
|
total.value = result.total
|
|
} catch (error) {
|
|
rows.value = []
|
|
total.value = 0
|
|
loadError.value = error instanceof Error && error.message === '当前环境未签发海外访问令牌'
|
|
? { message: error.message }
|
|
: getApiErrorDetails(error, '尾程订单加载失败,请重试')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function search(model: QueryModel) { page.value = 1; void load(model) }
|
|
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
|
function changeStatusTab() { page.value = 1; void load() }
|
|
function onSelectionChange(selection: object[]) { selectedRows.value = selection as LastMileOrderListItem[] }
|
|
function openTransfer() {
|
|
if (!selectedRows.value.length) { ElMessage.warning('请至少选择 1 条尾程订单'); return }
|
|
Object.assign(transferForm, { warehouseId: '', warehouseCode: '', warehouseName: '', remark: '' })
|
|
actionError.value = null
|
|
transferOpen.value = true
|
|
}
|
|
function openDetained(row: LastMileOrderListItem) {
|
|
detainedTarget.value = row
|
|
detainedRemark.value = ''
|
|
actionError.value = null
|
|
detainedOpen.value = true
|
|
}
|
|
async function submitDetained() {
|
|
const target = detainedTarget.value
|
|
if (!target) return
|
|
if (!detainedRemark.value.trim()) { ElMessage.warning('请填写操作备注'); return }
|
|
actionLoading.value = true; actionError.value = null
|
|
try {
|
|
await setLastMileOrderDetained(target.id, { detained: !target.detained, remark: detainedRemark.value.trim() })
|
|
detainedOpen.value = false; detainedTarget.value = null
|
|
ElMessage.success(target.detained ? '已解除暂扣,库存已转为可用' : '已标记暂扣,后续入库将进入暂扣库存')
|
|
await load()
|
|
} catch (error) { actionError.value = getApiErrorDetails(error, target.detained ? '解除暂扣失败,请重试' : '暂扣失败,请重试') }
|
|
finally { actionLoading.value = false }
|
|
}
|
|
async function togglePalletMerge(row: LastMileOrderListItem) {
|
|
const required = row.palletMergeFlag !== 'REQUIRED'
|
|
try {
|
|
await ElMessageBox.confirm(
|
|
required ? `标记后,PDA扫描入仓码 ${row.inboundCode} 的箱唛时会显示合托提示。` : '确认取消该订单的PDA合托提示?',
|
|
required ? '合托标记' : '取消合托标记',
|
|
{ type: 'warning', confirmButtonText: required ? '确认标记' : '确认取消', cancelButtonText: '返回' },
|
|
)
|
|
} catch { return }
|
|
actionLoading.value = true; actionError.value = null
|
|
try {
|
|
await setLastMileOrderPalletMergeRequired(row.id, { palletMergeRequired: required })
|
|
ElMessage.success(required ? '已标记合托,PDA扫描箱唛时将显示提示' : '已取消合托标记')
|
|
await load()
|
|
} catch (error) { actionError.value = getApiErrorDetails(error, required ? '合托标记失败,请重试' : '取消合托标记失败,请重试') }
|
|
finally { actionLoading.value = false }
|
|
}
|
|
async function submitTransfer() {
|
|
if (!transferForm.warehouseId.trim() || !transferForm.warehouseCode.trim() || !transferForm.warehouseName.trim()) {
|
|
ElMessage.warning('请填写目的仓 ID、代码和名称')
|
|
return
|
|
}
|
|
actionLoading.value = true; actionError.value = null
|
|
try {
|
|
await transferLastMileOrders({ orderIds: selectedRows.value.map((row) => row.id), warehouseId: transferForm.warehouseId.trim(), warehouseCode: transferForm.warehouseCode.trim(), warehouseName: transferForm.warehouseName.trim(), remark: transferForm.remark.trim() || undefined })
|
|
transferOpen.value = false; selectedRows.value = []; ElMessage.success('转仓成功'); await load()
|
|
} catch (error) { actionError.value = getApiErrorDetails(error, '转仓失败,请重试') }
|
|
finally { actionLoading.value = false }
|
|
}
|
|
async function convertInboundPlan() {
|
|
if (selectedRows.value.length !== 1) { ElMessage.warning('生成入库计划时请选择 1 条尾程订单'); return }
|
|
actionLoading.value = true; actionError.value = null
|
|
try {
|
|
await convertLastMileOrderToInboundPlan({ orderId: selectedRows.value[0].id })
|
|
selectedRows.value = []; ElMessage.success('入库计划生成成功'); await load()
|
|
} catch (error) { actionError.value = getApiErrorDetails(error, '生成入库计划失败,请重试') }
|
|
finally { actionLoading.value = false }
|
|
}
|
|
|
|
function openDetail(source: object) {
|
|
const row = source as LastMileOrderListItem
|
|
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
|
|
}
|
|
|
|
onMounted(() => { void load() })
|
|
</script>
|
|
|
|
<template>
|
|
<AdminPage><OperationErrorToast :details="actionError" /><ListPageLayout aria-label="尾程订单列表">
|
|
<template #toolbar>
|
|
<section class="surface-panel last-mile-toolbar" aria-label="尾程订单状态与筛选">
|
|
<el-radio-group v-model="statusTab" aria-label="尾程订单状态" @change="changeStatusTab">
|
|
<el-radio-button value="全部">全部</el-radio-button>
|
|
<el-radio-button value="待分配">待分配</el-radio-button>
|
|
</el-radio-group>
|
|
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" ariaLabel="尾程订单筛选" preference-key="overseas-last-mile-orders-query" @search="search" @reset="reset" />
|
|
</section>
|
|
</template>
|
|
<template v-if="loadError" #state>
|
|
<LoadErrorState title="尾程订单加载失败" :message="loadError.message" :loading="loading" @retry="load">
|
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
|
</LoadErrorState>
|
|
</template>
|
|
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" :row-class-name="({ row }: { row: LastMileOrderListItem }) => row.detained ? 'is-detained-row' : ''" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
|
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="convertInboundPlan">生成入库计划</el-button></template>
|
|
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" size="small" plain :type="(row as LastMileOrderListItem).detained ? 'success' : 'danger'" :disabled="actionLoading" @click="openDetained(row as LastMileOrderListItem)">{{ (row as LastMileOrderListItem).detained ? '解除暂扣' : '暂扣' }}</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer) && (row as LastMileOrderListItem).palletMergeFlag !== 'MERGED'" size="small" plain :type="(row as LastMileOrderListItem).palletMergeFlag === 'REQUIRED' ? 'warning' : 'primary'" :disabled="actionLoading" @click="togglePalletMerge(row as LastMileOrderListItem)">{{ (row as LastMileOrderListItem).palletMergeFlag === 'REQUIRED' ? '取消合托' : '合托标记' }}</el-button></template>
|
|
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
|
|
</DataTable></section>
|
|
</ListPageLayout></AdminPage>
|
|
<AppDialog v-model="transferOpen" title="转仓" confirm-text="确认转仓" :confirm-loading="actionLoading" @confirm="submitTransfer">
|
|
<el-form label-position="top">
|
|
<el-form-item label="目的仓 ID" required><el-input v-model="transferForm.warehouseId" placeholder="请输入目的仓 ID" /></el-form-item>
|
|
<el-form-item label="目的仓代码" required><el-input v-model="transferForm.warehouseCode" placeholder="请输入目的仓代码" /></el-form-item>
|
|
<el-form-item label="目的仓名称" required><el-input v-model="transferForm.warehouseName" placeholder="请输入目的仓名称" /></el-form-item>
|
|
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" /></el-form-item>
|
|
</el-form>
|
|
</AppDialog>
|
|
<AppDialog v-model="detainedOpen" :title="detainedTarget?.detained ? '解除暂扣' : '暂扣订单'" :confirm-text="detainedTarget?.detained ? '确认解除' : '确认暂扣'" :confirm-loading="actionLoading" @confirm="submitDetained">
|
|
<el-form label-position="top"><el-form-item label="操作备注" required><el-input v-model="detainedRemark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写暂扣或解除原因" /></el-form-item></el-form>
|
|
</AppDialog>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.last-mile-toolbar { padding: 14px 16px 0; }
|
|
.last-mile-toolbar :deep(.query-toolbar) { margin-top: 12px; padding: 12px 0 16px; border: 0; border-top: 1px solid var(--cc-border); border-radius: 0; }
|
|
.table-panel :deep(.el-table .is-detained-row > td.el-table__cell) { background: #fff1f0 !important; color: #b42318; }
|
|
.table-panel :deep(.el-table .is-detained-row:hover > td.el-table__cell) { background: #ffe4e1 !important; }
|
|
</style>
|