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'
2026-10-30 11:19:57 +08:00
import { 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'
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 )
2026-10-30 11:19:57 +08:00
const action = ref ( '' ), batchId = ref ( '' )
const remainingReceiptQuantity = computed (() => props . detail . customerView . order . purchaseQuantity - props . workflow . receivedQuantity )
const quantityLimit = computed (() => Math . max ( 1 , action . value === 'receipts'
? remainingReceiptQuantity . value
: props . workflow . availableQuantity + ( action . value === 'edit-outbound' ? ( props . workflow . outbounds . find ( row => row . id === batchId . value ) ? . quantity || 0 ) : 0 )))
const form = reactive ({ quantity : 1 , trackingNo : '' , fileIds : [] as string [] })
2026-10-27 17:39:19 +08:00
const uploadedNames = ref < string [] >([])
2026-10-30 11:19:57 +08:00
const title = computed (() => ({ receipts : '登记收货' , 'receipt-photos' : '重新上传收货照片' , 'request-confirmation' : '采购完成' , outbounds : '创建出库单' , 'edit-outbound' : '编辑出库单' , dispatch : '开始派送' , cancel : '取消出库单' , complete : '上传签收凭证' })[ action . value ] || '采购操作' )
const permission = computed (() => `platform:overseas-business:purchase: ${ [ 'receipts' , 'receipt-photos' , 'request-confirmation' ]. includes ( action . value ) ? 'receive-overseas' : 'ship-customer' } ` )
2026-10-27 17:39:19 +08:00
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 ++ })
2026-10-29 13:37:22 +08:00
async function begin ( name : string , batch ?: ProcurementOutbound | ProcurementWorkflowReceipt ) {
2026-10-27 17:39:19 +08:00
if ( saving . value || uploading . value || checkingFees . value ) return
2026-10-30 11:19:57 +08:00
const required = [ 'receipts' , 'receipt-photos' , 'request-confirmation' ]. includes ( name ) ? 'receive-overseas' : 'ship-customer'
2026-10-27 17:39:19 +08:00
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
}
2026-10-30 11:19:57 +08:00
action . value = name ; batchId . value = batch ? . id || '' ; error . value = null
Object . assign ( form , { quantity : 1 , trackingNo : '' , fileIds : [] }); uploadedNames . value = []
2026-10-29 13:37:22 +08:00
if ( name === 'edit-outbound' && batch && 'trackingNo' in 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 ()
}
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 ) {
2026-10-29 13:37:22 +08:00
if ([ 'receipts' , 'receipt-photos' ]. includes ( action . value ) && ! file . type . startsWith ( 'image/' )) throw new Error ( '收货凭证请选择图片' )
2026-10-27 17:39:19 +08:00
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
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-30 11:19:57 +08:00
if ( action . value === 'receipts' && form . quantity > remainingReceiptQuantity . value ) { 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-29 13:37:22 +08:00
if ([ 'receipts' , 'receipt-photos' , 'complete' ]. includes ( action . value ) && ! form . fileIds . length ) { ElMessage . warning ( action . value === 'receipts' ? '请上传收货照片' : '请上传签收文件或图片' ); return }
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 === 'receipt-photos' ? { receiptId : batchId . value , fileIds : [... form . fileIds ] } : action . value === 'complete' ? { fileIds : [... form . fileIds ] } : {}) }
2026-10-30 11:19:57 +08:00
const next = JSON . stringify ([ action . value , batchId . value , data ])
2026-10-27 17:39:19 +08:00
if ( fingerprint !== next || ! requestKey ) { fingerprint = next ; requestKey = crypto . randomUUID () }
const current = dialogGeneration
saving . value = true ; error . value = null
try {
2026-10-30 11:19:57 +08:00
if ( action . value === 'edit-outbound' ) await editProcurementOutbound ( props . workflow . orderNo , props . customerId , batchId . value , { ... data , idempotencyKey : requestKey })
2026-10-29 13:37:22 +08:00
else await submitProcurementWorkflow ( props . workflow . orderNo , props . customerId , batchId . value && action . value !== 'receipt-photos' ? `outbounds/ ${ encodeURIComponent ( batchId . value ) } / ${ action . value } ` : action . value , { ... data , idempotencyKey : requestKey })
2026-10-27 17:39:19 +08:00
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" >
2026-10-30 18:50:13 +08:00
< el-alert v-if = "workflow.status === 'RECEIVING' && workflow.rejectionReason" title="客户驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" show -icon />
2026-10-27 17:39:19 +08:00
< div class = "density-actions" >
2026-10-30 11:19:57 +08:00
< 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 >
2026-10-27 17:39:19 +08:00
< 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 >
2026-10-30 18:50:13 +08:00
< h3 > 收货记录 </ h3 >< DataTable :data = "workflow.receipts" :columns = "receiptColumns" row -key =" id " empty -text =" 暂无收货记录 " >< template # files = "{ row }" >< el-button v-if = "row.id && workflow.status === 'RECEIVING' && (workflow.rejectionReason || workflow.photosNeedUpdate) && can('receive-overseas')" link type="primary" @click="begin('receipt-photos', row as ProcurementWorkflowReceipt)">重新上传照片</el-button><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-30 11:19:57 +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" @confirm="save" >
2026-10-28 10:32:33 +08:00
< el-form :model = "form" class = "density-form density-form--outlined" label -position =" top " :disabled = "saving" >
2026-10-30 11:19:57 +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="quantityLimit" :precision="0" /></ el-form-item >
2026-10-28 14:45:10 +08:00
< 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-29 13:37:22 +08:00
< el-form-item v-if = "['receipts', 'receipt-photos', 'complete'].includes(action)" :label="action !== 'complete' ? '收货照片' : '签收文件或图片'" required><input type="file" multiple :accept="action !== 'complete' ? '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 >