2026-08-25 20:57:51 +08:00
< script setup lang = "ts" >
2026-09-01 14:23:53 +08:00
import { computed , onMounted , ref } from 'vue'
import { Eye , Plus , XCircle } from '@lucide/vue'
2026-08-26 15:48:34 +08:00
import { ElMessage , ElMessageBox } from 'element-plus'
2026-09-01 14:23:53 +08:00
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-25 20:57:51 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
2026-08-26 15:48:34 +08:00
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
2026-08-25 20:57:51 +08:00
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
2026-09-01 14:23:53 +08:00
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
2026-08-26 15:48:34 +08:00
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { cancelOutbound , createOneClickDropshipOutbound , listOutbounds , type OutboundRow } from '../api/outbounds'
2026-08-25 20:57:51 +08:00
import { listInventoryBalances , listInventoryPositions , type InventoryBalanceDto , type InventoryPositionDto } from '../api/inventory'
2026-08-28 14:33:57 +08:00
import { formatOverseasDateTime } from '../lastMilePresentation'
2026-09-01 14:23:53 +08:00
import { overseasWarehousePermissions as permission } from '../permissions'
2026-08-25 20:57:51 +08:00
2026-09-01 14:23:53 +08:00
const router = useRouter (); const access = useAccessPreviewStore ()
const rows = ref < OutboundRow [] >([]); const page = ref ( 1 ); const pageSize = ref ( 20 ); const total = ref ( 0 ); const loading = ref ( false ); const error = ref < ApiErrorDetails | null >( null ); const mutationError = ref < ApiErrorDetails | null >( null )
2026-09-08 20:27:47 +08:00
const filters = ref < QueryModel >({ documentNo : '' , lxCode : '' , warehouseId : '' , status : '' }); const createOpen = ref ( false ); const saving = ref ( false ); const recipient = ref ( '' ); const remark = ref ( '' ); const quantity = ref < number | undefined >(); const cartonMarks = ref ( '' ); const selectedCustomerId = ref ( '' ); const selectedPositionId = ref ( '' ); const positionOptions = ref < Array < { balance : InventoryBalanceDto ; position : InventoryPositionDto }> > ([]); const inventoryLoading = ref ( false )
2026-09-01 14:23:53 +08:00
const customerOptions = computed (() => {
const customers = new Map < string , string >()
positionOptions . value . forEach (({ balance }) => customers . set ( balance . customerId , balance . customerName ? . trim () || '客户名称待补充' ))
return [... customers ]. map (([ value , label ]) => ({ value , label }))
})
const availablePositionOptions = computed (() => positionOptions . value . filter (({ balance }) => balance . customerId === selectedCustomerId . value ))
2026-09-07 19:04:00 +08:00
const columns : DataTableColumn [] = [{ key : 'actions' , label : '操作' , width : 180 , fixed : 'left' , slotName : 'actions' }, { key : 'outboundNo' , prop : 'outboundNo' , label : '出库单号' , minWidth : 180 }, { key : 'lxCodes' , prop : 'lxCodes' , label : 'LX代码' , minWidth : 180 }, { key : 'dispatchNo' , prop : 'dispatchNo' , label : '关联排车单' , minWidth : 160 }, { key : 'status' , prop : 'status' , label : '状态' , width : 130 , slotName : 'status' }, { key : 'palletCount' , prop : 'palletCount' , label : '托盘数' , width : 100 }, { key : 'packageCount' , prop : 'packageCount' , label : '箱数' , width : 100 }, { key : 'createdAt' , prop : 'createdAt' , label : '创建时间' , minWidth : 180 }]
2026-09-06 15:53:48 +08:00
const fields : QueryField [] = [{ key : 'documentNo' , type : 'input' , label : '出库单号' , placeholder : '请输入出库单号' , width : 220 }, { key : 'lxCode' , type : 'input' , label : 'LX代码' , placeholder : '请输入领星入库单代码' , width : 220 }, { key : 'warehouseId' , type : 'input' , label : '目的仓' , placeholder : '请输入目的仓' , width : 220 }, { key : 'status' , type : 'select' , label : '状态' , placeholder : '全部状态' , width : 160 , options : [{ label : '全部' , value : '' }, { label : '待出库' , value : 'WAIT_OUTBOUND' }, { label : '已出库' , value : 'OUTBOUNDED' }] }]
2026-08-26 15:48:34 +08:00
2026-09-06 15:53:48 +08:00
async function load ( model : QueryModel = filters . value ) { Object . assign ( filters . value , model ); loading . value = true ; error . value = null ; try { const result = await listOutbounds ({ page : page . value , size : pageSize . value , documentNo : String ( filters . value . documentNo || '' ). trim () || undefined , lxCode : String ( filters . value . lxCode || '' ). trim () || undefined , warehouseId : String ( filters . value . warehouseId || '' ). trim () || undefined , status : String ( filters . value . status || '' ). trim () || undefined }); rows . value = result . records . map (( row ) => ({ ... row , createdAt : formatOverseasDateTime ( row . createdAt ) })); total . value = result . total } catch ( cause ) { error . value = getApiErrorDetails ( cause , '出库单列表加载失败' ) } finally { loading . value = false } }
2026-09-01 14:23:53 +08:00
async function openCreate () { createOpen . value = true ; selectedCustomerId . value = '' ; selectedPositionId . value = '' ; quantity . value = undefined ; inventoryLoading . value = true ; try { const balances = await listInventoryBalances ({ page : 1 , size : 200 , inventoryStatus : 'AVAILABLE' }); const groups = await Promise . all ( balances . records . map ( async balance => ( await listInventoryPositions ( balance . id , { inventoryStatus : 'AVAILABLE' })). map ( position => ({ balance , position })))); positionOptions . value = groups . flat (). filter ( item => item . position . availablePackages > 0 ) } catch ( cause ) { ElMessage . error ( getApiErrorDetails ( cause , '可用库存加载失败' ). message ) } finally { inventoryLoading . value = false } }
function selectCustomer () { selectedPositionId . value = '' ; quantity . value = undefined }
2026-09-08 20:27:47 +08:00
async function submit () { const selected = availablePositionOptions . value . find ( item => item . position . id === selectedPositionId . value ); const marks = cartonMarks . value . split ( /[,\n, ]+/ ). map ( value => value . trim ()). filter ( Boolean ); if ( ! selectedCustomerId . value || ! recipient . value . trim () || ! selected || ! quantity . value || quantity . value < 1 ) { ElMessage . warning ( '请选择客户,填写收件信息并选择托盘和出库数量' ); return }; if ( quantity . value > selected . position . availablePackages ) { ElMessage . warning ( '出库数量不能超过可用库存' ); return }; if ( marks . length && marks . length !== quantity . value ) { ElMessage . warning ( '指定箱唛数量必须等于出库数量' ); return }; saving . value = true ; try { await createOneClickDropshipOutbound ( selectedCustomerId . value , { recipient : recipient . value . trim (), items : [{ inventoryPositionId : selected . position . id , packages : quantity . value , sku : selected . balance . skuCode || undefined , cartonMarks : marks . length ? marks : undefined }], remark : remark . value . trim () || undefined , idempotencyKey : `web-dropship- ${ Date . now () } ` }); ElMessage . success ( '出库单已创建并推送PDA拣货任务' ); createOpen . value = false ; await load () } catch ( cause ) { ElMessage . error ( getApiErrorDetails ( cause , '一件代发出库失败' ). message ) } finally { saving . value = false } }
2026-09-01 14:23:53 +08:00
function openDetail ( row : OutboundRow ) { void router . push ({ name : 'overseas-outbound-detail' , params : { outboundNo : row . outboundNo } }) }
function canCancel ( row : OutboundRow ) { return access . can ( permission . outboundCancel ) && row . status === 'PICKED' }
async function cancel ( row : OutboundRow ) { if ( ! canCancel ( row )) return ; let reason = '' ; try { const result = await ElMessageBox . prompt ( `出库单 ${ row . outboundNo } · 出库数量 ${ row . packageCount ?? 0 } ` , '取消出库单' , { inputType : 'textarea' , inputPlaceholder : '请输入取消原因' , inputPattern : /\S+/ , inputErrorMessage : '请输入取消原因' , confirmButtonText : '确认取消' , cancelButtonText : '返回' , type : 'warning' }); reason = result . value . trim () } catch { return }; mutationError . value = null ; try { await cancelOutbound ( row . outboundNo , reason ); ElMessage . success ( '出库单已取消,库存及关联单据已回滚' ); await load () } catch ( cause ) { mutationError . value = getApiErrorDetails ( cause , '取消出库单失败' ) } }
2026-08-26 15:48:34 +08:00
function search ( model : QueryModel ) { page . value = 1 ; void load ( model ) }
function reset ( model : QueryModel ) { page . value = 1 ; void load ( model ) }
onMounted ( load )
2026-08-25 20:57:51 +08:00
</ script >
2026-08-26 15:48:34 +08:00
< template >
< AdminPage >< ListPageLayout aria -label =" 出库单 " >
2026-09-01 14:23:53 +08:00
< OperationErrorToast :details = "mutationError" />
2026-09-07 11:15:37 +08:00
< template # toolbar >< QueryToolbar v-model = "filters" :fields="fields" :loading="loading" ariaLabel="出库单筛选" preference-key="overseas-outbounds-query" :search-on-reset="false" @search="search" @reset="reset" /></ template >
2026-09-01 14:23:53 +08:00
< template v-if = "error" #state><LoadErrorState title="出库单列表加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></ template ></ LoadErrorState ></ template >
2026-08-26 15:48:34 +08:00
< section v-if = "!error" 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-outbounds" row-key="id" empty-text="暂无出库单" @update:page="load()" @update:page-size="page = 1; load()" >
2026-09-01 14:23:53 +08:00
< template # toolbar -actions >< el-button v-if = "access.can(permission.outboundCreate)" type="primary" @click="openCreate"><Plus :size="16" /> 一件代发出库 </ el-button ></ template >
< template # actions = "{ row }" >< el-button size = "small" type = "primary" plain @click ="openDetail(row as OutboundRow)" >< Eye :size = "15" /> 详情 </ el-button >< el-button v-if = "canCancel(row as OutboundRow)" size="small" type="danger" plain @click="cancel(row as OutboundRow)"><XCircle :size="15" /> 取消 </ el-button ></ template >
2026-08-26 15:48:34 +08:00
< template # status = "{ row }" >< StatusBadge : status = "row.status === 'CANCELLED' ? '已取消' : row.status === 'OUTBOUNDED' ? '已出库' : row.status === 'PICKED' ? '已拣货' : '拣货中'" /></ template >
</ DataTable ></ section >
</ ListPageLayout ></ AdminPage >
2026-09-08 20:27:47 +08:00
< AppDialog v-model = "createOpen" title="一件代发出库" confirm-text="生成出库单并推送PDA" :confirm-loading="saving" @confirm="submit"><el-form class="create-outbound-form" label-position="top"><el-form-item label="客户" required><el-select v-model="selectedCustomerId" :loading="inventoryLoading" filterable placeholder="请选择库存所属客户" @change="selectCustomer"><el-option v-for="item in customerOptions" :key="item.value" :value="item.value" :label="item.label" /></el-select></el-form-item><el-form-item label="收件信息" required><el-input v-model="recipient" placeholder="请输入收件人、电话和地址" /></el-form-item><el-form-item label="出库托盘" required><el-select v-model="selectedPositionId" :loading="inventoryLoading" :disabled="!selectedCustomerId" filterable placeholder="请先选择客户,再选择有可用库存的托盘"><el-option v-for="item in availablePositionOptions" :key="item.position.id" :value="item.position.id" :label="`${item.position.palletNo || '托盘号待补充'} · ${item.balance.skuCode || '无SKU'} · 可用${item.position.availablePackages}`" /></el-select></el-form-item><el-form-item label="出库数量" required><el-input-number v-model="quantity" :min="1" placeholder="请输入出库数量" /></el-form-item><el-form-item label="指定箱唛(可选)"><el-input v-model="cartonMarks" type="textarea" :rows="3" placeholder="请输入箱唛,多个箱唛用逗号或换行分隔" /><div class="form-hint">填写后按指定箱唛出库,数量必须与出库数量一致。</div></el-form-item><el-alert title="提交仅生成出库单并推送PDA任务,PDA完成托盘拣货后才扣减库存" type="info" :closable="false" /><el-form-item label="备注"><el-input v-model="remark" type="textarea" placeholder="请输入备注(选填)" /></ el-form-item ></ el-form ></ AppDialog >
2026-08-26 15:48:34 +08:00
</ template >
2026-09-02 15:43:11 +08:00
< style scoped >
2026-09-08 20:27:47 +08:00
. create - outbound - form : deep (. el - input ), . create - outbound - form : deep (. el - select ), . create - outbound - form : deep (. el - input - number ) { width : 100 % ; }. form - hint { margin - top : 6 px ; color : var ( -- cc - muted ); font - size : 12 px ; }
2026-09-02 15:43:11 +08:00
</ style >