127 lines
14 KiB
Vue
127 lines
14 KiB
Vue
<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 { uploadProcurementFile } from '../api/procurementCommands'
|
||
import { editProcurementOutbound, submitProcurementWorkflow, type ProcurementWorkflow, type ProcurementOutbound, type ProcurementWorkflowReceipt } from '../api/procurementWorkflow'
|
||
import { dateTime, statusLabel } from '../procurementPresentation'
|
||
import type { PlatformProcurement } from '../procurementTypes'
|
||
|
||
const props = defineProps<{ detail: PlatformProcurement; workflow: ProcurementWorkflow; customerId: string; downloading?: string; beforeOutbound: () => Promise<boolean | null> }>()
|
||
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('')
|
||
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[] })
|
||
const uploadedNames = ref<string[]>([])
|
||
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'}`)
|
||
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 | ProcurementWorkflowReceipt) {
|
||
if (saving.value || uploading.value || checkingFees.value) return
|
||
const required = ['receipts', 'receipt-photos', 'request-confirmation'].includes(name) ? 'receive-overseas' : 'ship-customer'
|
||
if (!can(required)) return
|
||
const current = ++dialogGeneration
|
||
if (name === 'outbounds') {
|
||
checkingFees.value = true
|
||
let noFeeDetails = false
|
||
try { noFeeDetails = await props.beforeOutbound() === true }
|
||
catch { /* 费用查询仅用于提示,不能阻断采购出库。 */ }
|
||
finally { checkingFees.value = false }
|
||
if (current !== dialogGeneration) return
|
||
if (noFeeDetails) ElMessage.info('没有新增费用明细,仍可继续创建出库单')
|
||
}
|
||
action.value = name; batchId.value = batch?.id || ''; error.value = null
|
||
Object.assign(form, { quantity: 1, trackingNo: '', fileIds: [] }); uploadedNames.value = []
|
||
if (name === 'edit-outbound' && batch && 'trackingNo' in batch) Object.assign(form, { quantity: batch.quantity, trackingNo: batch.trackingNo })
|
||
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) {
|
||
if (['receipts', 'receipt-photos'].includes(action.value) && !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 (['receipts', 'outbounds', 'edit-outbound'].includes(action.value) && (!Number.isSafeInteger(form.quantity) || form.quantity < 1)) { ElMessage.warning('请输入有效数量'); return }
|
||
if (action.value === 'receipts' && form.quantity > remainingReceiptQuantity.value) { ElMessage.warning('收货数量不能超过剩余采购数量'); return }
|
||
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 }
|
||
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] } : {}) }
|
||
const next = JSON.stringify([action.value, batchId.value, data])
|
||
if (fingerprint !== next || !requestKey) { fingerprint = next; requestKey = crypto.randomUUID() }
|
||
const current = dialogGeneration
|
||
saving.value = true; error.value = null
|
||
try {
|
||
if (action.value === 'edit-outbound') await editProcurementOutbound(props.workflow.orderNo, props.customerId, batchId.value, { ...data, idempotencyKey: requestKey })
|
||
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 })
|
||
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[] = [
|
||
{ key: 'actions', label: '操作', width: 250, slotName: 'actions', fixed: 'left' }, { prop: 'outboundNo', label: '出库单号', minWidth: 190 },
|
||
{ key: 'status', label: '状态', width: 120, formatter: (row: ProcurementOutbound) => statusLabel(row.status), afterColumn: 'outboundNo' },
|
||
{ 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">
|
||
<el-alert v-if="workflow.status === 'RECEIVING' && workflow.rejectionReason" title="客户驳回原因" :description="workflow.rejectionReason" type="warning" :closable="false" show-icon />
|
||
<div class="density-actions">
|
||
<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-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>
|
||
<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>
|
||
<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">
|
||
<el-form :model="form" class="density-form density-form--outlined" label-position="top" :disabled="saving">
|
||
<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>
|
||
<el-form-item v-if="['outbounds', 'edit-outbound'].includes(action)" label="物流跟踪号" required><el-input v-model="form.trackingNo" maxlength="100" /></el-form-item>
|
||
<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>
|
||
<p v-if="action === 'request-confirmation'">确认采购完成并提交客户确认?</p><p v-if="action === 'dispatch'">确认本批次开始派送?</p><p v-if="action === 'cancel'">确认取消本批出库单?取消后库存占用将释放。</p>
|
||
</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>
|