This commit is contained in:
2026-08-31 14:42:28 +08:00
parent f7328e7c60
commit d0770517dc
8 changed files with 129 additions and 20 deletions
@@ -187,7 +187,7 @@ onMounted(load)
<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="目的仓" prop="fbaWarehouseCode" min-width="110" /><el-table-column label="计划箱数" prop="scheduledPackages" width="100" /><el-table-column label="已拣箱数" prop="pickedPackages" width="100" />
<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>
@@ -12,12 +12,12 @@ 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, transferLastMileOrders, downloadLastMileOrders } from '../api/lastMileOrders'
import { convertLastMileOrderToInboundPlan, listLastMileOrders, setLastMileOrderDetained, setLastMileOrderPalletMergeBatch, transferLastMileOrders, downloadLastMileOrders } from '../api/lastMileOrders'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { toLastMileOrderListItem } from '../lastMilePresentation'
import type { LastMileOrderListItem } from '../types'
import { listReceivingOverseasWarehouses, type ReceivingOverseasWarehouseDto } from '../api/pallets'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { saveCsv } from '@/modules/overseas-master-data/downloadCsv'
const rows = ref<LastMileOrderListItem[]>([])
@@ -29,16 +29,17 @@ const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const selectedRows = ref<LastMileOrderListItem[]>([])
const cancellableMergeCount = computed(() => selectedRows.value.filter((row) => row.palletMergeFlag === 'REQUIRED').length)
const actionLoading = ref(false)
const exportLoading = ref(false)
async function exportOrders() { exportLoading.value = true; try { const { createdAt: range, ...rest } = filters.value; saveCsv(await downloadLastMileOrders({ ...rest, page: undefined, size: undefined, createdFrom: Array.isArray(range) && range[0] ? `${String(range[0]).slice(0, 10)}T00:00:00` : undefined, createdTo: Array.isArray(range) && range[1] ? `${String(range[1]).slice(0, 10)}T23:59:59` : undefined }) as Blob, '尾程订单.csv') } catch { ElMessage.error('尾程订单导出失败,请重试') } finally { exportLoading.value = false } }
async function exportOrders() { exportLoading.value = true; try { const { createdAt: range, ...rest } = filters.value; saveCsv(await downloadLastMileOrders({ ...rest, createdFrom: Array.isArray(range) && range[0] ? `${String(range[0]).slice(0, 10)}T00:00:00` : undefined, createdTo: Array.isArray(range) && range[1] ? `${String(range[1]).slice(0, 10)}T23:59:59` : undefined }) as Blob, '尾程订单.csv') } catch (cause) { ElMessage.error(getApiErrorDetails(cause, '尾程订单导出失败,请重试').message) } finally { exportLoading.value = 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 warehouses = ref<ReceivingOverseasWarehouseDto[]>([])
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
const statusTab = ref<'全部' | '待分配'>('全部')
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
const fields = computed<QueryField[]>(() => [
@@ -69,7 +70,8 @@ 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: 'palletMergeRelation', prop: 'palletMergeRelation', label: '合托关联订单', minWidth: 220, hideable: false },
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false },
{ key: 'palletMergeRelation', prop: 'palletMergeRelation', label: '合托关联入仓单', minWidth: 220, hideable: false },
{ key: 'warning', prop: 'warning', label: '预警', width: 90 },
{ key: 'orderType', prop: 'orderType', label: '订单类型', minWidth: 110 },
{ key: 'rejectedAt', prop: 'rejectedAt', label: '拒收时间', minWidth: 150 },
@@ -166,6 +168,32 @@ async function submitDetained() {
} catch (error) { actionError.value = getApiErrorDetails(error, target.detained ? '解除暂扣失败,请重试' : '暂扣失败,请重试') }
finally { actionLoading.value = false }
}
async function batchPalletMerge() {
const targets = selectedRows.value.filter((row) => row.palletMergeFlag !== 'MERGED')
if (!targets.length) { ElMessage.warning('请选择至少 1 条未完成合托的尾程订单'); return }
try {
await ElMessageBox.confirm(`确定为选中的 ${targets.length} 条尾程订单标记合托吗?PDA 扫描对应箱唛时将显示合托提示。`, '批量合托标记', { type: 'warning', confirmButtonText: '确认标记', cancelButtonText: '返回' })
} catch { return }
actionLoading.value = true; actionError.value = null
try {
await setLastMileOrderPalletMergeBatch(targets.map((row) => row.id), true)
selectedRows.value = []; ElMessage.success(`已标记 ${targets.length} 条订单合托`); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '批量合托标记失败,请重试') }
finally { actionLoading.value = false }
}
async function cancelBatchPalletMerge() {
const targets = selectedRows.value.filter((row) => row.palletMergeFlag === 'REQUIRED')
if (!targets.length) { ElMessage.warning('请选择至少 1 条待合托订单'); return }
try {
await ElMessageBox.confirm(`确定取消选中的 ${targets.length} 条订单合托标记吗?`, '取消合托标记', { type: 'warning', confirmButtonText: '确认取消', cancelButtonText: '返回' })
} catch { return }
actionLoading.value = true; actionError.value = null
try {
await setLastMileOrderPalletMergeBatch(targets.map((row) => row.id), false)
selectedRows.value = []; ElMessage.success(`已取消 ${targets.length} 条订单合托标记`); await load()
} catch (error) { actionError.value = getApiErrorDetails(error, '取消合托标记失败,请重试') }
finally { actionLoading.value = false }
}
async function submitTransfer() {
if (!transferForm.warehouseId.trim() || !transferForm.warehouseCode.trim() || !transferForm.warehouseName.trim()) {
ElMessage.warning('请填写目的仓 ID、代码和名称')
@@ -194,7 +222,13 @@ function openDetail(source: object) {
}
onMounted(() => { void load(); void loadWarehouses() })
async function loadWarehouses() { try { warehouses.value = (await listReceivingOverseasWarehouses()).records } catch { warehouses.value = [] } }
async function loadWarehouses() {
try {
warehouses.value = await listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })
} catch {
warehouses.value = []
}
}
</script>
<template>
@@ -214,14 +248,14 @@ async function loadWarehouses() { try { warehouses.value = (await listReceivingO
</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.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><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 #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" type="primary" plain :disabled="!selectedRows.length || actionLoading" @click="batchPalletMerge">合托标记</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" type="warning" plain :disabled="!cancellableMergeCount || actionLoading" @click="cancelBatchPalletMerge">取消合托</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></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="目的仓" required><el-select v-model="transferForm.warehouseId" filterable clearable placeholder="请选择目的仓" style="width:100%" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); transferForm.warehouseCode = w?.warehouseCode || ''; transferForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item>
<el-form-item label="FBA 目的仓" required><el-select v-model="transferForm.warehouseId" filterable clearable placeholder="请选择头程 FBA 目的仓" style="width:100%" @change="(id: string) => { const w = warehouses.find((item) => String(item.id) === id); transferForm.warehouseCode = w?.warehouseCode || ''; transferForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" maxlength="500" /></el-form-item>
</el-form>
</AppDialog>
@@ -305,8 +305,17 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(detail).not.toContain('ElMessage.success')
})
it('shows the transfer route label on PDA pallet picking rows', () => {
const detail = source('DispatchDetailView.vue')
expect(detail).toContain('label="转仓提示"')
expect(detail).toContain('row.warehouseRouteLabel')
expect(detail).toContain('type="warning"')
})
it('preserves last-mile status tabs and read-only outbound association columns without links', () => {
const value = source('LastMileOrdersView.vue')
expect(value).toContain("listAllPlatformWarehouses({ warehouseType: 'FBA', status: 'ACTIVE' })")
expect(value).toContain('请选择头程 FBA 目的仓')
expect(value).toContain('aria-label="尾程订单状态"')
expect(value).toContain('<el-radio-button value="全部">全部</el-radio-button>')
expect(value).toContain('<el-radio-button value="待分配">待分配</el-radio-button>')
@@ -327,7 +336,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
it('keeps every prototype list column in its frozen order', () => {
expect(columnKeys('LastMileOrdersView.vue')).toEqual([
'actions', 'selection', 'orderNo', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'owner', 'warehouse',
'actions', 'selection', 'orderNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'owner', 'warehouse',
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',