@@ -13,7 +13,7 @@ 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 { changeContainerReceivingWarehouse , convertLastMileContainerToInboundPlans , createManualInboundPlan , downloadLastMileOrders , getContainerPlanProgress , getLastMileOrderManualNodes , listContainerPlanProgress , listLastMileOrders , transferLastMileOrders , updateLastMileOrderHold , updateLastMileOrderManualNodes , type ContainerPlanProgressDetailDto , type ContainerPlanProgressSummaryDto , type ManualNodeCode } from '../api/lastMileOrders'
import { changeContainerReceivingWarehouse , createManualInboundPlan , downloadLastMileOrders , getContainerInboundPlanPreview , getContainerPlanProgress , getLastMileOrderManualNodes , listContainerPlanProgress , listLastMileOrders , transferLastMileOrders , updateLastMileOrderHold , updateLastMileOrderManualNodes , type ContainerInboundPlanPreviewDto , type ContainerInboundPlanPreviewGroupDto , type ContainerInboundPlanPreviewOrderDto , type ContainerPlanProgressDetailDto , type ContainerPlanProgressSummaryDto , type ManualNodeCode } from '../api/lastMileOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
@@ -72,6 +72,9 @@ const containerOptions = ref<ContainerPlanProgressSummaryDto[]>([])
const containerOptionsLoading = ref ( false )
const containerLoadError = ref < ApiErrorDetails | null > ( null )
const selectedContainerBookingId = ref ( '' )
const containerPreview = ref < ContainerInboundPlanPreviewDto | null > ( null )
const containerPreviewLoading = ref ( false )
const containerPreviewError = ref ( '' )
const receivingWarehouseOpen = ref ( false )
const receivingWarehouseLoading = ref ( false )
const receivingWarehouseError = ref < ApiErrorDetails | null > ( null )
@@ -543,6 +546,8 @@ function openContainerConversion() {
selectedContainerBookingId . value = ''
containerOptions . value = [ ]
containerLoadError . value = null
containerPreview . value = null
containerPreviewError . value = ''
actionError . value = null
containerConvertOpen . value = true
void loadContainerOptions ( )
@@ -551,31 +556,69 @@ function openContainerConversionFor(container: ContainerPlanProgressSummaryDto)
selectedContainerBookingId . value = container . sourceContainerBookingId
containerOptions . value = [ container ]
containerLoadError . value = null
containerPreview . value = null
containerPreviewError . value = ''
actionError . value = null
containerConvertOpen . value = true
void loadContainerPreview ( container . sourceContainerBookingId )
}
async function convertContainerInboundPlans ( ) {
const container = selectedContainer . value
if ( ! container ? . sourceContainerBookingId ) { ElMessage . warning ( '请选择一个待分配柜' ) ; return }
if ( ! canGenerateContainerPlans ( container ) ) {
actionError . value = { message : container . blockingReason || '所选柜当前不满足生成资格' }
return
}
actionLoading . value = true
actionError . value = null
async function loadContainerPreview ( sourceContainerBookingId = selectedContainerBookingId . value ) {
containerPreview . value = null
containerPreviewError . value = ''
if ( ! sourceContainerBookingId ) return
containerPreviewLoading . value = true
try {
const plans = await convertLastMileContainerToInboundPlans ( { sourceContainerBookingId : container . sourceContainerBookingId } )
containerConvertOpen . value = false
selectedContainerBookingId . value = ''
ElMessage . success ( ` 已按柜生成 ${ plans . length } 个独立入库计划 ` )
await load ( )
containerPreview . value = await getContainerInboundPlanPreview ( sourceContainerBookingId )
} catch ( error ) {
actionError . value = getApiErrorDetails ( error , '生成 整柜入库计划失败,请重试' )
containerPreviewError . value = getApiErrorDetails ( error , '加载 整柜入库计划预览 失败,请重试' ) . message
} finally {
actionLoading . value = false
containerPreviewLoading . value = false
}
}
function previewGroupTitle ( group : ContainerInboundPlanPreviewGroupDto ) {
if ( group . destinationType === 'FBA' ) return ` ${ group . destinationWarehouseName || group . destinationWarehouseCode || '未配置目的仓' } FBA仓 `
return group . destinationType === 'ONE_CLICK_DROPSHIP' ? '一件代发' : '私人地址'
}
function destinationText ( order : { destinationWarehouseCode ? : string | null ; destinationWarehouseName ? : string | null } ) {
return [ order . destinationWarehouseCode , order . destinationWarehouseName ] . filter ( Boolean ) . join ( ' / ' ) || '—'
}
function orderTypeText ( orderType : string ) {
const labels : Record < string , string > = {
FIRST _LEG : '头程订单' ,
RELABEL _APPLICATION : '换标下单' ,
WAREHOUSE _TRANSFER : '仓储转运' ,
LOCAL _LOOSEBOARD : '本土散板' ,
RETURN _RELABEL : '退货换标' ,
ONE _CLICK _DROPSHIP : '一件代发' ,
}
return labels [ orderType ] || orderType
}
function readinessText ( readiness : ContainerInboundPlanPreviewOrderDto [ 'readiness' ] ) {
return readiness === 'READY' ? '可生成' : readiness === 'PLANNED' ? '已有计划' : '已阻断'
}
function readinessType ( readiness : ContainerInboundPlanPreviewOrderDto [ 'readiness' ] ) {
return readiness === 'READY' ? 'success' : readiness === 'PLANNED' ? 'info' : 'danger'
}
function blockerText ( blockers : string [ ] ) {
const labels : Record < string , string > = {
MISSING _INBOUND _CODE : '缺少入仓码' ,
ORDER _DETAINED : '订单已暂扣' ,
ORDER _STATUS _NOT _READY : '订单状态未就绪' ,
CONTAINER _NO _INCONSISTENT : '货柜号不一致' ,
RECEIVING _WAREHOUSE _INCONSISTENT : '收货仓不一致' ,
DESTINATION _NOT _READY : '目的地未就绪' ,
CARGO _FACT _INVALID : '货物数据无效' ,
DROPSHIP _PALLET _SNAPSHOT _MISSING : '缺少一件代发托盘快照' ,
}
return blockers . map ( ( code ) => labels [ code ] || code ) . join ( '; ' ) || '—'
}
function openDetail ( source : object ) {
const row = source as LastMileOrderListItem
void router . push ( { name : 'overseas-last-mile-order-detail' , params : { orderId : row . id } } )
@@ -660,19 +703,52 @@ onMounted(() => { void load() })
< el-form-item label = "备注" > < el-input v-model = "transferForm.remark" type="textarea" maxlength="500" placeholder="请输入转仓备注(选填)" / > < / el-form-item >
< / el-form >
< / AppDialog >
< AppDialog v-model = "containerConvertOpen" title="按柜批量 生成入库计划" width="640px " confirm-text="确认按柜 生成" :loading="containerOptionsLoading" :confirm-loading="action Loading" :confirm-disabled="!selectedContainer || !canGenerateContainerPlans(selectedContainer)" @confirm="convertContainerInboundPlans " >
< p class = "container-convert-hint" > 将为所选柜内所有待分配尾程订单分别生成独立入库计划 。 < / p >
< AppDialog v-model = "containerConvertOpen" title="按柜生成入库计划预览 " width="min(1240px, calc(100vw - 80px)) " confirm-text="确认生成(接口待就绪) " :loading="containerOptionsLoading || containerPreview Loading" :confirm-disabled="true " >
< p class = "container-convert-hint" > 先读取服务端全柜预览 , 确认分组 、 托数和阻断原因 。 当前仅开放预览 , 确认接口就绪前不会提交生成请求 。 < / p >
< LoadErrorState v-if = "containerLoadError" title="待分配柜加载失败" :message="containerLoadError.message" compact @retry="loadContainerOptions()" >
< template # details > < ApiErrorMeta :details = "containerLoadError" / > < / template >
< / LoadErrorState >
< el-form v-else label -position = " top " >
< el-form-item label = "选择待分配柜" required >
< el-select v-model = "selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" placeholder="输入柜号搜索并选择" >
< el -option v-for = "container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :disabled="!canGenerateContainerPlans(container)" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票${canGenerateContainerPlans(container) ? '' : ` · ${container.blockingReason || '不可生成'}`} `" / >
< el-select v-model = "selectedContainerBookingId" filterable remote clearable :remote-method="loadContainerOptions" :loading="containerOptionsLoading" placeholder="输入柜号搜索并选择" @change="loadContainerPreview" >
< el -option v-for = "container in containerOptions" :key="container.sourceContainerBookingId" :value="container.sourceContainerBookingId" :label="`${container.containerNo || '柜号缺失'} · ${container.totalOrderCount} 票`" / >
< / el-select >
< / el-form-item >
< el-alert v-if = "selectedContainer" :title="canGenerateContainerPlans(selectedContainer) ? `确认后将为该柜剩余 ${pendingInboundCodeCount(selectedContainer)} 个入仓码分别生成独立入库计划` : selectedContainer.blockingReason || '所选柜不可生成'" :type="canGenerateContainerPlans(selectedContainer) ? 'info' : 'error'" :closable="false" show -icon / >
< / el-form >
< el-alert v-if = "containerPreviewError" class="container-convert-alert" type="error" :closable="false" show-icon :title="containerPreviewError" / >
< div v-if = "containerPreview" class="container-preview" >
< div class = "container-preview__context" >
< div class = "container-preview__identity text-ellipsis" : title = "containerPreview.containerNo || containerPreview.sourceContainerBookingId" > { { containerPreview . containerNo || '未录入货柜号' } } < / div >
< div class = "container-preview__warehouse text-ellipsis" : title = "[containerPreview.overseasWarehouseCode, containerPreview.overseasWarehouseName].filter(Boolean).join(' / ') || '未关联海外仓'" > { { [ containerPreview . overseasWarehouseCode , containerPreview . overseasWarehouseName ] . filter ( Boolean ) . join ( ' / ' ) || '未关联海外仓' } } < / div >
< / div >
< div class = "container-preview__metrics" >
< div class = "container-preview__metric container-preview__metric--primary" > < span > 本次新增托盘码 < / span > < strong > { { containerPreview . pendingPalletCount } } < / strong > < / div >
< div class = "container-preview__metric" > < span > 本次新增计划 < / span > < strong > { { containerPreview . pendingOrderCount } } < / strong > < / div >
< div class = "container-preview__metric" > < span > 已有计划托盘 < / span > < strong > { { containerPreview . existingPlannedPalletCount } } < / strong > < / div >
< div class = "container-preview__metric" : class = "{ 'container-preview__metric--danger': containerPreview.blockedOrderCount > 0 }" > < span > 阻断订单 < / span > < strong > { { containerPreview . blockedOrderCount } } < / strong > < / div >
< / div >
< el-alert v-if = "containerPreview.blockedOrderCount > 0" class="container-convert-alert" type="error" :closable="false" show-icon title="存在阻断订单,暂不可确认生成;请按下方原因处理后重新预览。" / >
< el-alert v-else class = "container-convert-alert" type = "warning" :closable = "false" show -icon title = "预览已就绪;确认接口完成接入前,暂不开放提交。" / >
< div class = "container-preview__scroll" >
< section v-for = "group in containerPreview.groups" :key="`${group.destinationType}-${group.destinationWarehouseId || group.destinationWarehouseCode || 'default'}`" class="container-preview__group" >
< header class = "container-preview__group-header" >
< strong class = "text-ellipsis" :title = "previewGroupTitle(group)" > { { previewGroupTitle ( group ) } } : { { group . pendingPalletCount } } 托 < / strong >
< span > 本次 { { group . pendingOrderCount } } 票 / 已有 { { group . existingPlannedPalletCount } } 托 / 共 { { group . orderCount } } 票 < / span >
< / header >
< el-table :data = "group.orders" size = "small" border >
< el-table-column label = "入仓码" min -width = " 148 " show -overflow -tooltip > < template # default = "{ row }" > { { row . inboundCode || '—' } } < / template > < / el-table-column >
< el-table-column prop = "businessOrderNo" label = "订单号" min -width = " 150 " show -overflow -tooltip / >
< el-table-column label = "客户" min -width = " 140 " show -overflow -tooltip > < template # default = "{ row }" > { { row . customerName || '—' } } < / template > < / el-table-column >
< el-table-column label = "业务类型" min -width = " 112 " show -overflow -tooltip > < template # default = "{ row }" > { { orderTypeText ( row . orderType ) } } < / template > < / el-table-column >
< el-table-column label = "目的地" min -width = " 190 " show -overflow -tooltip > < template # default = "{ row }" > { { destinationText ( row ) } } < / template > < / el-table-column >
< el-table-column label = "建议托数" width = "94" align = "right" > < template # default = "{ row }" > { { row . suggestedPalletCount ? ? '—' } } < / template > < / el-table-column >
< el-table-column label = "已有计划托数" width = "118" align = "right" > < template # default = "{ row }" > { { row . plannedPalletCount ? ? '—' } } < / template > < / el-table-column >
< el-table-column label = "可生成性" width = "104" > < template # default = "{ row }" > < el-tag :type = "readinessType(row.readiness)" effect = "light" > { { readinessText ( row . readiness ) } } < / el-tag > < / template > < / el-table-column >
< el-table-column label = "阻断原因" min -width = " 210 " show -overflow -tooltip > < template # default = "{ row }" > { { blockerText ( row . blockers ) } } < / template > < / el-table-column >
< / el-table >
< / section >
< / div >
< / div >
< / AppDialog >
< AppDialog v-model = "containerDetailOpen" :title="`柜子详情 · ${containerDetail?.summary.containerNo || '加载中'}`" width="min(1120px, calc(100vw - 40px))" :loading="containerDetailLoading" :show-footer="false" >
< LoadErrorState v-if = "containerDetailError" title="柜级详情加载失败" :message="containerDetailError.message" compact >
@@ -717,7 +793,7 @@ onMounted(() => { void load() })
< AppDialog v-model = "inboundPlanOpen" title="入库计划" width="min(720px, calc(100vw - 28px))" confirm-text="保存并生成入库单" :confirm-loading="actionLoading" @confirm="submitInboundPlan" >
< el -alert title = "保存后生成入库单;清关完成后入库计划不可更改。" type = "info" :closable = "false" show -icon / >
< el-form class = "command-form" label -position = " top " >
< div class = "command-form__grid" > < el-form-item label = "计划入库仓" > < el-input :model-value = "inboundPlanWarehouseLabel" disabled placeholder = "当前柜接柜仓" / > < small > 接柜仓按柜统一 ; 如需变更 , 请在柜子工作台执行 “ 变更接柜仓 ” 。 < / small > < / el-form-item > < el-form-item label = "预计到仓日期" required > < el-date-picker v-model = "inboundPlanForm.plannedArrivalDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="1" :max="999" controls-position="right" placeholder="请输入托数" /></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="是否 合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" / > < / el-form-item > < / div >
< div class = "command-form__grid" > < el-form-item label = "计划入库仓" > < el-input :model-value = "inboundPlanWarehouseLabel" disabled placeholder = "当前柜接柜仓" / > < small > 接柜仓按柜统一 ; 如需变更 , 请在柜子工作台执行 “ 变更接柜仓 ” 。 < / small > < / el-form-item > < el-form-item label = "预计到仓日期" required > < el-date-picker v-model = "inboundPlanForm.plannedArrivalDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item><el-form-item label="入仓码"><el-input :model-value="inboundPlanForm.inboundCode" disabled placeholder="订单冻结入仓码" /></el-form-item><el-form-item label="托数" required><el-input-number v-model="inboundPlanForm.palletCount" :min="1" :max="999" controls-position="right" placeholder="请输入托数" /></el-form-item><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item><el-form-item label="允许 合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><small>仅作为现场可选提示,不强制工人合托。</small>< /el-form-item><el-form-item label="是否加急"><el-switch v-model="inboundPlanForm.urgent" inline-prompt active-text="是" inactive-text="否" / > < / el-form-item > < / div >
< el-form-item label = "备注" > < el-input v-model = "inboundPlanForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" / > < / el-form-item >
< / el-form >
< / AppDialog >
@@ -747,6 +823,23 @@ onMounted(() => { void load() })
. manual - node - table _ _row : deep ( . el - date - editor ) { width : 100 % ; }
. last - mile - toolbar : deep ( . query - toolbar ) { margin - top : 12 px ; padding : 12 px 0 16 px ; border : 0 ; border - top : 1 px solid var ( -- cc - border ) ; border - radius : 0 ; }
. container - convert - hint { margin : 0 0 16 px ; color : var ( -- cc - muted ) ; line - height : 1.6 ; }
. container - convert - alert { margin - top : 12 px ; }
. container - preview { margin - top : 16 px ; }
. container - preview _ _context , . container - preview _ _group - header { display : flex ; align - items : center ; justify - content : space - between ; gap : 20 px ; }
. container - preview _ _identity { color : var ( -- cc - text ) ; font - size : 18 px ; font - weight : 700 ; }
. container - preview _ _warehouse , . container - preview _ _group - header span { color : var ( -- cc - muted ) ; font - size : 13 px ; }
. container - preview _ _metrics { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; gap : 12 px ; margin : 14 px 0 ; }
. container - preview _ _metric { display : flex ; min - height : 72 px ; padding : 12 px 16 px ; border : 1 px solid var ( -- cc - border ) ; border - radius : 8 px ; background : var ( -- cc - surface ) ; flex - direction : column ; justify - content : center ; }
. container - preview _ _metric span { color : var ( -- cc - muted ) ; font - size : 13 px ; }
. container - preview _ _metric strong { margin - top : 4 px ; color : var ( -- cc - text ) ; font - size : 24 px ; line - height : 1 ; }
. container - preview _ _metric -- primary { border - color : var ( -- cc - primary ) ; background : # f3f6ff ; }
. container - preview _ _metric -- primary strong { color : var ( -- cc - primary ) ; }
. container - preview _ _metric -- danger strong { color : var ( -- el - color - danger ) ; }
. container - preview _ _scroll { max - height : 48 vh ; margin - top : 14 px ; padding - right : 6 px ; overflow - y : auto ; }
. container - preview _ _group + . container - preview _ _group { margin - top : 16 px ; }
. container - preview _ _group - header { min - height : 42 px ; padding : 0 14 px ; border : 1 px solid var ( -- cc - border ) ; border - bottom : 0 ; border - radius : 8 px 8 px 0 0 ; background : var ( -- cc - surface ) ; }
. container - preview _ _group - header strong { min - width : 0 ; color : var ( -- cc - text ) ; }
. text - ellipsis { min - width : 0 ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. container - detail - summary { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) ; gap : 8 px 16 px ; margin - bottom : 12 px ; padding : 12 px 14 px ; border : 1 px solid var ( -- cc - border ) ; border - radius : 6 px ; background : var ( -- cc - surface ) ; font - size : 13 px ; }
. container - detail - summary > span { min - width : 0 ; overflow : hidden ; text - overflow : ellipsis ; white - space : nowrap ; }
. container - workbench { padding : 18 px ; }