2026-10-27 17:39:19 +08:00
< script setup lang = "ts" >
import { computed , reactive , ref , watch } from 'vue'
import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable , { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { listOverseasPurchaseWarehouseOptions } from '../api/platformOverseasBusiness'
import { submitProcurementCommand , uploadProcurementFile } from '../api/procurementCommands'
2026-10-28 14:45:10 +08:00
import { editProcurementOutbound , submitProcurementWorkflow , type ProcurementWorkflow , type ProcurementOutbound , type ProcurementWorkflowReceipt } from '../api/procurementWorkflow'
2026-10-27 17:39:19 +08:00
import { dateTime , statusLabel } from '../procurementPresentation'
import type { PlatformProcurement } from '../procurementTypes'
import type { OverseasPurchaseWarehouseOption } from '../types'
2026-10-28 13:59:39 +08:00
const props = defineProps < { detail : PlatformProcurement ; workflow : ProcurementWorkflow ; customerId : string ; downloading ?: string ; beforeOutbound : () => Promise < boolean | null > } > ()
2026-10-27 17:39:19 +08:00
const emit = defineEmits < { changed : []; download : [ id : string ] } > ()
const access = useAccessPreviewStore ()
const open = ref ( false ), saving = ref ( false ), uploading = ref ( false ), checkingFees = ref ( false ), error = ref < ApiErrorDetails | null >( null )
const action = ref ( '' ), batchId = ref ( '' ), warehouses = ref < OverseasPurchaseWarehouseOption [] >([]), warehouseLoading = ref ( false )
const form = reactive ({ quantity : 1 , trackingNo : '' , fileIds : [] as string [], warehouseId : '' , decision : 'APPROVE' as 'APPROVE' | 'REJECT' , reason : '' })
const uploadedNames = ref < string [] >([])
2026-10-28 14:45:10 +08:00
const title = computed (() => ({ review : '审核采购单' , receipts : '登记收货' , 'request-confirmation' : '采购完成' , outbounds : '创建出库单' , 'edit-outbound' : '编辑出库单' , dispatch : '开始派送' , cancel : '取消出库单' , complete : '上传签收凭证' })[ action . value ] || '采购操作' )
2026-10-27 17:39:19 +08:00
const permission = computed (() => `platform:overseas-business:purchase: ${ action . value === 'review' ? 'review' : [ 'receipts' , 'request-confirmation' ]. includes ( action . value ) ? 'receive-overseas' : 'ship-customer' } ` )
const can = ( name : string ) => access . can ( `platform:overseas-business:purchase: ${ name } ` )
const permissionHint = computed (() => props . workflow . status === 'RECEIVING' && ! can ( 'receive-overseas' )
? '当前账号未开通采购收货权限,请联系管理员开通。'
: [ 'CONFIRMED' , 'IN_TRANSIT' , 'DELIVERING' ]. includes ( props . workflow . status ) && ! can ( 'ship-customer' )
? '当前账号未开通采购出库及签收权限,请联系管理员开通。' : '' )
const { captureBaseline } = useUnsavedWorkRegistration ({ key : 'procurement-workflow' , label : title , isOpen : open , model : form , requiredPermission : permission })
let fingerprint = '' , requestKey = '' , openedScope = '' , dialogGeneration = 0
const scope = () => ` ${ props . customerId } : ${ props . workflow . orderNo } `
watch ( scope , () => { dialogGeneration ++ ; open . value = false ; error . value = null })
watch ( open , value => { if ( ! value ) dialogGeneration ++ })
async function begin ( name : string , batch ?: ProcurementOutbound ) {
if ( saving . value || uploading . value || checkingFees . value ) return
const required = name === 'review' ? 'review' : [ 'receipts' , 'request-confirmation' ]. includes ( name ) ? 'receive-overseas' : 'ship-customer'
if ( ! can ( required )) return
const current = ++ dialogGeneration
if ( name === 'outbounds' ) {
checkingFees . value = true
2026-10-28 13:59:39 +08:00
let noFeeDetails = false
try { noFeeDetails = await props . beforeOutbound () === true }
catch { /* 费用查询仅用于提示,不能阻断采购出库。 */ }
2026-10-27 17:39:19 +08:00
finally { checkingFees . value = false }
if ( current !== dialogGeneration ) return
2026-10-28 13:59:39 +08:00
if ( noFeeDetails ) ElMessage . info ( '没有新增费用明细,仍可继续创建出库单' )
2026-10-27 17:39:19 +08:00
}
action . value = name ; batchId . value = batch ? . id || '' ; error . value = null ; warehouseLoading . value = false
Object . assign ( form , { quantity : 1 , trackingNo : '' , fileIds : [], warehouseId : '' , decision : 'APPROVE' , reason : '' }); uploadedNames . value = []
2026-10-28 14:45:10 +08:00
if ( name === 'edit-outbound' && batch ) Object . assign ( form , { quantity : batch . quantity , trackingNo : batch . trackingNo })
2026-10-27 17:39:19 +08:00
fingerprint = '' ; requestKey = '' ; openedScope = scope (); open . value = true ; captureBaseline ()
if ( name === 'review' ) {
warehouseLoading . value = true
try { const result = await listOverseasPurchaseWarehouseOptions ( props . detail . customerView . order . procurementDirection ); if ( current === dialogGeneration ) warehouses . value = result }
catch ( failure ) { if ( current === dialogGeneration ) error . value = getApiErrorDetails ( failure , '仓库加载失败' ) }
finally { if ( current === dialogGeneration ) warehouseLoading . value = false }
}
}
async function upload ( event : Event ) {
const input = event . target as HTMLInputElement ; const files = Array . from ( input . files || []); input . value = ''
if ( ! files . length || uploading . value ) return
const current = dialogGeneration ; const orderNo = props . workflow . orderNo ; const customer = props . customerId ; uploading . value = true ; error . value = null
try {
for ( const file of files ) {
if ( action . value === 'receipts' && ! file . type . startsWith ( 'image/' )) throw new Error ( '收货凭证请选择图片' )
const result = await uploadProcurementFile ( orderNo , customer , 'RECEIPT' , file )
if ( current !== dialogGeneration ) return
form . fileIds . push ( result . fileId ); uploadedNames . value . push ( file . name )
}
} catch ( failure ) { if ( current === dialogGeneration ) error . value = getApiErrorDetails ( failure , '上传失败' ) }
finally { uploading . value = false }
}
async function save () {
if ( saving . value || uploading . value || openedScope !== scope () || ! access . can ( permission . value )) return
if ( action . value === 'review' && form . decision === 'APPROVE' && ! form . warehouseId ) { ElMessage . warning ( '请选择收货仓' ); return }
if ( action . value === 'review' && form . decision === 'REJECT' && ! form . reason . trim ()) { ElMessage . warning ( '请填写拒绝原因' ); return }
2026-10-28 14:45:10 +08:00
if ([ 'receipts' , 'outbounds' , 'edit-outbound' ]. includes ( action . value ) && ( ! Number . isSafeInteger ( form . quantity ) || form . quantity < 1 )) { ElMessage . warning ( '请输入有效数量' ); return }
2026-10-27 17:39:19 +08:00
if ( action . value === 'receipts' && form . quantity > props . detail . customerView . order . purchaseQuantity - props . workflow . receivedQuantity ) { ElMessage . warning ( '收货数量不能超过剩余采购数量' ); return }
2026-10-28 14:45:10 +08:00
if ([ 'outbounds' , 'edit-outbound' ]. includes ( action . value ) && ( ! form . trackingNo . trim () || form . quantity > props . workflow . availableQuantity + ( action . value === 'edit-outbound' ? ( props . workflow . outbounds . find ( row => row . id === batchId . value ) ? . quantity || 0 ) : 0 ))) { ElMessage . warning ( '请填写物流跟踪号,数量不能超过可出库库存' ); return }
2026-10-27 17:39:19 +08:00
if ([ 'receipts' , 'complete' ]. includes ( action . value ) && ! form . fileIds . length ) { ElMessage . warning ( action . value === 'receipts' ? '请上传收货照片' : '请上传签收文件或图片' ); return }
2026-10-28 14:45:10 +08:00
const data = { version : props . workflow . version , ...( action . value === 'receipts' ? { quantity : form . quantity , fileIds : [... form . fileIds ] } : [ 'outbounds' , 'edit-outbound' ]. includes ( action . value ) ? { quantity : form . quantity , trackingNo : form . trackingNo . trim () } : action . value === 'complete' ? { fileIds : [... form . fileIds ] } : {}) }
2026-10-27 17:39:19 +08:00
const next = JSON . stringify ([ action . value , batchId . value , form . warehouseId , form . decision , form . reason , data ])
if ( fingerprint !== next || ! requestKey ) { fingerprint = next ; requestKey = crypto . randomUUID () }
const current = dialogGeneration
saving . value = true ; error . value = null
try {
if ( action . value === 'review' ) await submitProcurementCommand ( props . workflow . orderNo , props . customerId , 'review' , { version : props . workflow . version , idempotencyKey : requestKey , decision : form . decision , purchaseWarehouseId : form . decision === 'APPROVE' ? form . warehouseId : undefined , reason : form . reason . trim () || undefined })
2026-10-28 14:45:10 +08:00
else if ( action . value === 'edit-outbound' ) await editProcurementOutbound ( props . workflow . orderNo , props . customerId , batchId . value , { ... data , idempotencyKey : requestKey })
2026-10-27 17:39:19 +08:00
else await submitProcurementWorkflow ( props . workflow . orderNo , props . customerId , batchId . value ? `outbounds/ ${ encodeURIComponent ( batchId . value ) } / ${ action . value } ` : action . value , { ... data , idempotencyKey : requestKey })
if ( current !== dialogGeneration ) return
captureBaseline (); open . value = false ; ElMessage . success ( '操作成功' ); emit ( 'changed' )
} catch ( failure ) { if ( current === dialogGeneration ) error . value = getApiErrorDetails ( failure , '操作失败' ) }
finally { saving . value = false }
}
const receiptColumns : DataTableColumn [] = [{ prop : 'quantity' , label : '收货数量' , width : 140 }, { key : 'receivedAt' , label : '收货时间' , minWidth : 170 , formatter : ( row : ProcurementWorkflowReceipt ) => dateTime ( row . receivedAt ) }, { key : 'files' , label : '照片' , minWidth : 180 , slotName : 'files' }]
const outboundColumns : DataTableColumn [] = [
2026-10-28 14:45:10 +08:00
{ key : 'actions' , label : '操作' , width : 250 , slotName : 'actions' , fixed : 'left' }, { prop : 'outboundNo' , label : '出库单号' , minWidth : 190 },
2026-10-28 10:32:33 +08:00
{ key : 'status' , label : '状态' , width : 120 , formatter : ( row : ProcurementOutbound ) => statusLabel ( row . status ), afterColumn : 'outboundNo' },
2026-10-27 17:39:19 +08:00
{ prop : 'quantity' , label : '出库数量' , width : 110 }, { prop : 'trackingNo' , label : '物流跟踪号' , minWidth : 190 },
{ key : 'createdAt' , label : '创建时间' , minWidth : 170 , formatter : ( row : ProcurementOutbound ) => dateTime ( row . createdAt ) },
{ key : 'completedAt' , label : '签收时间' , minWidth : 170 , formatter : ( row : ProcurementOutbound ) => dateTime ( row . completedAt ) },
{ key : 'files' , label : '签收凭证' , minWidth : 170 , slotName : 'files' },
]
const fileName = ( id : string ) => props . detail . files . find ( file => file . fileId === id ) ? . fileName || id
</ script >
< template >
< section class = "procurement-workflow" >
< div class = "density-actions" >
< el-button v-if = "workflow.status === 'PENDING_REVIEW' && can('review')" type="primary" @click="begin('review')" > 审核 </ el -button >
< template v-if = "workflow.status === 'RECEIVING'"><el-button type="primary" :disabled="!can('receive-overseas') || workflow.receivedQuantity >= detail.customerView.order.purchaseQuantity" @click="begin('receipts')">登记收货</el-button><el-button :disabled="!can('receive-overseas') || !workflow.receivedQuantity" @click="begin('request-confirmation')" > 采购完成 </ el -button ></ template >
< el-button v-if = "['CONFIRMED', 'IN_TRANSIT', 'DELIVERING'].includes(workflow.status)" type="primary" :loading="checkingFees" :disabled="!can('ship-customer') || workflow.availableQuantity < 1" @click="begin('outbounds')" > 创建出库单 </ el -button >
</ div >
< el-text v-if = "permissionHint" type="warning" role="status" >{{ permissionHint }} </ el -text >
< dl class = "density-fields inventory" >< div >< dt > 累计收货 </ dt >< dd >{{ workflow . receivedQuantity }}</ dd ></ div >< div >< dt > 采购库存 </ dt >< dd >{{ workflow . stockQuantity }}</ dd ></ div >< div >< dt > 出库处理中 </ dt >< dd >{{ workflow . reservedQuantity }}</ dd ></ div >< div >< dt > 可出库 </ dt >< dd >{{ workflow . availableQuantity }}</ dd ></ div ></ dl >
< h3 > 收货记录 </ h3 >< DataTable :data = "workflow.receipts" :columns = "receiptColumns" row -key =" id " empty -text =" 暂无收货记录 " >< template # files = "{ row }" >< el-button v-for = "id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)" >{{ fileName ( id ) }} </ el -button ></ template ></ DataTable >
2026-10-28 14:45:10 +08:00
< h3 > 出库单 </ h3 >< DataTable :data = "workflow.outbounds" :columns = "outboundColumns" row -key =" id " empty -text =" 暂无出库单 " >< template # actions = "{ row }" >< template v-if = "row.status === 'IN_TRANSIT'"><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('edit-outbound', row as ProcurementOutbound)">编辑</el-button><el-button :disabled="!can('ship-customer')" link type="danger" @click="begin('cancel', row as ProcurementOutbound)">取消</el-button><el-button :disabled="!can('ship-customer')" link type="primary" @click="begin('dispatch', row as ProcurementOutbound)">开始派送</el-button></template><el-button v-if="row.status === 'DELIVERING'" :disabled="!can('ship-customer')" link type="primary" @click="begin('complete', row as ProcurementOutbound)">上传签收凭证</el-button></template><template #files="{ row }"><el-button v-for="id in row.fileIds" :key="id" :disabled="!!downloading" link type="primary" @click="emit('download', id)" >{{ fileName ( id ) }} </ el -button ></ template ></ DataTable >
2026-10-28 10:32:33 +08:00
< AppDialog v-model = "open" class="admin-density-dialog admin-workbench is-dialog" :title="title" width="580px" confirm-text="确认" :confirm-loading="saving" :confirm-disabled="uploading || warehouseLoading" @confirm="save" >
< el-form :model = "form" class = "density-form density-form--outlined" label -position =" top " :disabled = "saving" >
2026-10-27 17:39:19 +08:00
< template v-if = "action === 'review'"><el-form-item label="审核结果"><el-radio-group v-model="form.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">拒绝</el-radio></el-radio-group></el-form-item><el-form-item v-if="form.decision === 'APPROVE'" label="收货仓" required><el-select v-model="form.warehouseId" filterable :loading="warehouseLoading"><el-option v-for="warehouse in warehouses" :key="warehouse.warehouseId" :value="warehouse.warehouseId" :label="warehouse.warehouseName" /></el-select></el-form-item><el-form-item v-else label="拒绝原因" required><el-input v-model="form.reason" type="textarea" :rows="2" maxlength="1000" /></ el-form-item ></ template >
2026-10-28 14:45:10 +08:00
< el-form-item v-if = "['receipts', 'outbounds', 'edit-outbound'].includes(action)" :label="action === 'receipts' ? '本批收货数量' : '本批出库数量'" required><el-input-number v-model="form.quantity" :min="1" :max="action === 'outbounds' ? workflow.availableQuantity : workflow.availableQuantity + (batchId ? (workflow.outbounds.find(row => row.id === batchId)?.quantity || 0) : 0)" :precision="0" /></ el-form-item >
< el-form-item v-if = "['outbounds', 'edit-outbound'].includes(action)" label="物流跟踪号" required><el-input v-model="form.trackingNo" maxlength="100" /></ el-form-item >
2026-10-27 17:39:19 +08:00
< el-form-item v-if = "['receipts', 'complete'].includes(action)" :label="action === 'receipts' ? '收货照片' : '签收文件或图片'" required><input type="file" multiple :accept="action === 'receipts' ? 'image/*' : 'image/*,application/pdf'" :disabled="saving || uploading" @change="upload" /><div v-for="name in uploadedNames" :key="name" >{{ name }}</ div > </ el -form-item >
2026-10-28 14:45:10 +08:00
< p v-if = "action === 'request-confirmation'">确认采购完成并提交客户确认?</p><p v-if="action === 'dispatch'">确认本批次开始派送?</p><p v-if="action === 'cancel'" > 确认取消本批出库单 ? 取消后库存占用将释放 。 </ p >
2026-10-27 17:39:19 +08:00
</ el -form >< el-alert v-if = "error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /></ el-alert >
</ AppDialog >
</ section >
</ template >
< style scoped >
. inventory { margin : var ( -- admin - space - 4 ) 0 ; }
h3 { margin : var ( -- admin - space - 4 ) 0 var ( -- admin - space - 3 ); font - size : var ( -- admin - font - section ); }
</ style >