feat(借库审批流): 完整前后端实现

This commit is contained in:
DXC
2026-06-12 14:08:19 +08:00
parent 941bd20fbd
commit 7ef22a3830
7 changed files with 994 additions and 115 deletions

View File

@ -12,6 +12,57 @@
</div>
</template>
<!-- ★ 审批单选择下拉框 -->
<div class="approval-request-select">
<el-select
v-model="selectedApprovalId"
placeholder="请选择已通过审批的借库申请单"
filterable
clearable
style="width: 100%"
:loading="requestsLoading"
@change="handleApprovalChange"
>
<el-option
v-for="req in approvalRequests"
:key="req.id"
:value="req.id"
:label="req.request_no"
>
<span>{{ req.request_no }}</span>
<el-divider direction="vertical" />
<span>{{ req.borrower_name || '未知借库人' }}</span>
<el-divider direction="vertical" />
<span style="color: #909399; font-size: 13px">{{ req.remark || '无备注' }}</span>
</el-option>
</el-select>
<p class="select-tip">仅显示已通过(status=1)的审批单</p>
</div>
<!-- ★ 审批计划清单预览 -->
<div v-if="selectedApproval" class="planned-items-section">
<div class="planned-header">
<span class="planned-title">审批计划清单</span>
<el-tag type="success" size="small">{{ plannedItems.length }} 种</el-tag>
</div>
<el-table :data="plannedItems" border size="small" style="width: 100%;">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.material_type || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="100" show-overflow-tooltip />
<el-table-column label="审批数量" width="90" align="center">
<template #default="{ row }">
<span style="color: #E6A23C; font-weight: bold;">{{ row.quantity ?? '-' }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="scan-section">
<div v-if="userStore.hasPermission('op_borrow:operation')" class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
@ -204,26 +255,22 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode } from '@/api/outbound'
import request from '@/utils/request'
import { dispatchBorrow, getBorrowApprovalList } from '@/api/transaction'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 列与权限Code的映射关系(数据库中的code)
// 列与权限Code的映射关系
const permissionMap: Record<string, string> = {
borrower_name: 'op_borrow:borrower_name',
sku: 'op_borrow:sku',
available_quantity: 'op_borrow:available_quantity',
out_quantity: 'op_borrow:out_quantity',
// 其他字段可根据需要添加
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') {
return true
}
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
const code = permissionMap[prop]
return code ? userStore.hasPermission(code) : false
}
@ -236,6 +283,76 @@ const showCamera = ref(false)
const barcodeRef = ref()
const formRef = ref()
// ★ 审批单选择
const approvalRequests = ref<any[]>([])
const selectedApprovalId = ref<number | null>(null)
const requestsLoading = ref(false)
const selectedApproval = computed(() =>
selectedApprovalId.value
? approvalRequests.value.find(r => r.id === selectedApprovalId.value) ?? null
: null
)
const plannedItems = computed(() => selectedApproval.value?.items ?? [])
// ★ 加载已通过审批的借库申请单列表
const loadApprovalRequests = async () => {
requestsLoading.value = true
try {
const res: any = await getBorrowApprovalList({ status: 1, page: 1, limit: 100 })
approvalRequests.value = res.data?.items || []
} catch (e) {
console.error('加载借库审批单列表失败', e)
} finally {
requestsLoading.value = false
}
}
// ★ 切换审批单时:清空购物车和签名,防止跨单据污染
const handleApprovalChange = (val: number | null) => {
if (!val) {
selectedApprovalId.value = null
}
cartItems.value = []
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
}
// ★ 扫码校验:比对扫描物料是否在审批计划清单内,且累计数量不超过审批上限
const validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQty: number): string | null => {
const normalizedName = scannedName.trim()
const normalizedSpec = (scannedSpec || '').trim()
const matchedPlan = plannedItems.value.find(plan => {
const planName = (plan.name || '').trim()
const planSpec = (plan.spec_model || '').trim()
return planName === normalizedName && planSpec === normalizedSpec
})
if (!matchedPlan) {
return `该物料【${normalizedName} × ${normalizedSpec}】不在审批计划清单中,请检查`
}
const planQty = matchedPlan.quantity ?? 0
// 购物车中已扫的同名同规格物料累计数量
const alreadyScanned = cartItems.value
.filter(ci => {
const ciName = (ci.name || '').trim()
const ciSpec = (ci.spec_model || '').trim()
return ciName === normalizedName && ciSpec === normalizedSpec
})
.reduce((sum, ci) => sum + (ci.out_quantity || 0), 0)
if (alreadyScanned + scannedQty > planQty) {
return `【${normalizedName} × ${normalizedSpec}】超出审批数量(审批: ${planQty},已扫: ${alreadyScanned},本次: ${scannedQty})`
}
return null
}
// 签名相关
const showSignatureDialog = ref(false)
const signaturePreviewUrl = ref('')
@ -254,9 +371,7 @@ const form = reactive({
})
const rules = computed(() => ({
borrower_name: [
{ required: true, message: '请输入借用人姓名', trigger: 'blur' }
],
borrower_name: [{ required: true, message: '请输入借用人姓名', trigger: 'blur' }],
expected_return_time: [
{ required: !isIndefinite.value, message: '请选择预计归还日期', trigger: 'change' }
]
@ -265,9 +380,7 @@ const rules = computed(() => ({
const isIndefinite = ref(false)
const handleIndefiniteChange = (val: boolean) => {
if (val) {
form.expected_return_time = ''
}
if (val) form.expected_return_time = ''
}
const disabledDate = (time: Date) => {
@ -278,35 +391,51 @@ const disabledDate = (time: Date) => {
const onScanSuccess = (code: string) => {
if (!code) return
const trimCode = code.trim()
const validPattern = /^[A-Za-z0-9\-\.]+$/
if (!validPattern.test(trimCode)) {
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
return
}
if (trimCode.length < 3) {
ElMessage.warning('扫描结果过短,请对准重试')
return
}
if (loading.value) return
barcodeInput.value = trimCode
handleManualInput()
}
const handleManualInput = async () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
const code = barcodeInput.value.trim()
if (!code) return
// ★ 必须先选择审批单
if (!selectedApproval.value) {
ElMessage.warning('请先选择要执行借库的审批申请单')
return
}
try {
loading.value = true
// 查重
// 查重:条码或 SKU 匹配已扫记录
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
if (existIndex > -1) {
const item = cartItems.value[existIndex]
// ★ 追加前仍需校验审批数量上限
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
const maxQty = parseFloat(item.available_quantity)
if (item.out_quantity < maxQty) {
item.out_quantity++
@ -314,6 +443,7 @@ const handleManualInput = async () => {
if (navigator.vibrate) navigator.vibrate(50)
} else {
ElMessage.warning(`库存不足 (余: ${maxQty})`)
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
}
barcodeInput.value = ''
return
@ -326,16 +456,28 @@ const handleManualInput = async () => {
const availQty = parseFloat(item.available_quantity || 0)
if (availQty <= 0) {
ElMessage.warning(`库存不足 (余: ${availQty})`)
} else {
cartItems.value.push({
...item,
out_quantity: 1,
price: 0
})
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
ElMessage.warning(`库存不足或已借出 (余: ${availQty})`)
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
barcodeInput.value = ''
return
}
// ★ 扫码加入前强校验:不在清单内或超量直接阻断
const err = validateAgainstPlan(item.name, item.spec_model, 1)
if (err) {
ElMessage.error(err)
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
barcodeInput.value = ''
return
}
cartItems.value.push({
...item,
out_quantity: 1,
price: 0
})
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
barcodeInput.value = ''
}
} catch (error: any) {
@ -344,9 +486,9 @@ const handleManualInput = async () => {
} else {
ElMessage.error('查询出错')
}
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
} finally {
loading.value = false
// ★ 核心修改:只有当非全屏模式时,才自动聚焦输入框
if (!showCamera.value) {
nextTick(() => { barcodeRef.value?.focus() })
}
@ -354,10 +496,18 @@ const handleManualInput = async () => {
}
const removeFromCart = (index: number) => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
cartItems.value.splice(index, 1)
}
const clearAll = () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' })
.then(() => {
cartItems.value = []
@ -368,13 +518,19 @@ const clearAll = () => {
signaturePreviewUrl.value = ''
barcodeInput.value = ''
isIndefinite.value = false
// 仅清空购物车,保留审批单选择
})
}
// --- 提交逻辑 ---
const submitForm = async () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无操作权限')
return
}
if (!formRef.value) return
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
if (!selectedApprovalId.value) return ElMessage.warning('请选择关联的审批申请单')
await formRef.value.validate(async (valid: boolean) => {
if (!valid) {
@ -382,7 +538,6 @@ const submitForm = async () => {
ElMessage.error(requiredMsg)
return
}
if (!signatureFile.value) {
ElMessage.error('请领用人进行电子签名')
return
@ -395,20 +550,32 @@ const submitForm = async () => {
const uploadRes = await uploadFile(signatureFile.value)
const signatureUrl = uploadRes.data.url
// 处理无限期借用:如果选择了无限期,将预计归还时间置为空
const submitData = {
...form,
expected_return_time: isIndefinite.value ? null : form.expected_return_time
// ★ 规范 Payload:只包含后端需要的最小字段
const itemsPayload = cartItems.value.map(item => {
let safeQty = Number(item.out_quantity)
if (isNaN(safeQty) || safeQty <= 0) safeQty = 1
return {
stock_id: item.id || 0,
source_table: item.source_table || '',
sku: item.sku ? String(item.sku) : (item.barcode ? String(item.barcode) : 'NO_SKU'),
barcode: item.barcode ? String(item.barcode) : '',
out_quantity: safeQty
}
})
if (itemsPayload.length === 0) {
ElMessage.warning('请至少扫描一件物料后再提交')
return
}
await request({
url: '/v1/transactions/borrow',
method: 'post',
data: {
items: cartItems.value,
...submitData,
signature_path: signatureUrl
}
await dispatchBorrow({
approval_id: selectedApprovalId.value,
items: itemsPayload,
borrower_name: form.borrower_name,
signature_path: signatureUrl,
remark: form.remark,
expected_return_time: isIndefinite.value ? null : form.expected_return_time
})
ElMessage.success('借用成功')
@ -431,13 +598,18 @@ const submitForm = async () => {
}
// --- 签名逻辑 ---
const openSignatureDialog = () => { showSignatureDialog.value = true }
const openSignatureDialog = () => {
if (!userStore.hasPermission('op_borrow:operation')) {
ElMessage.warning('无签名权限')
return
}
showSignatureDialog.value = true
}
const initCanvas = async () => {
await nextTick()
const canvas = nativeCanvasRef.value
const container = canvasContainerRef.value
if (canvas && container) {
canvas.width = container.clientWidth
canvas.height = container.clientHeight
@ -500,6 +672,12 @@ const handleSignConfirm = () => {
const handleSignCancel = () => { showSignatureDialog.value = false }
// --- 初始化 ---
import { onMounted } from 'vue'
onMounted(() => {
loadApprovalRequests()
})
onUnmounted(() => {
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
})
@ -514,7 +692,16 @@ onUnmounted(() => {
.card-header { display: flex; justify-content: space-between; align-items: center; }
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
/* 扫码区(卡片内触发器) */
/* 审批单选择 */
.approval-request-select { margin-bottom: 16px; }
.select-tip { color: #909399; font-size: 12px; margin: 4px 0 0 0; }
/* 计划清单 */
.planned-items-section { margin-bottom: 16px; background: #f5f7fa; border-radius: 6px; padding: 12px; }
.planned-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.planned-title { font-size: 13px; font-weight: bold; color: #606266; }
/* 扫码区 */
.scan-section { margin-bottom: 20px; }
.camera-placeholder {
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
@ -525,59 +712,26 @@ onUnmounted(() => {
.camera-placeholder:active { background: #e6e8eb; }
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
/* ★ 全屏扫码层样式 */
/* 全屏扫码层 */
.fullscreen-scanner-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #000;
z-index: 9999;
display: flex;
flex-direction: column;
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: #000; z-index: 9999; display: flex; flex-direction: column;
}
.scanner-header {
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
background: rgba(0,0,0,0.6);
color: #fff;
position: absolute;
top: 0;
width: 100%;
z-index: 10;
height: 60px; display: flex; align-items: center; justify-content: space-between;
padding: 0 15px; background: rgba(0,0,0,0.6); color: #fff;
position: absolute; top: 0; width: 100%; z-index: 10;
}
.scanner-title { font-size: 16px; font-weight: bold; }
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
.scanner-body {
flex: 1;
width: 100%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex: 1; width: 100%; position: relative; display: flex;
align-items: center; justify-content: center;
}
/* 强制子组件(QrScanner)填满容器 */
:deep(.qr-scanner-container) {
width: 100% !important;
height: 100% !important;
border-radius: 0 !important;
}
:deep(.qr-scanner-container) { width: 100% !important; height: 100% !important; border-radius: 0 !important; }
.scanner-footer {
position: absolute;
bottom: 0;
width: 100%;
padding: 20px;
background: rgba(0,0,0,0.6);
color: #fff;
text-align: center;
z-index: 10;
position: absolute; bottom: 0; width: 100%; padding: 20px;
background: rgba(0,0,0,0.6); color: #fff; text-align: center; z-index: 10;
}
.current-count { color: #67c23a; font-weight: bold; margin-top: 5px; font-size: 16px; }
@ -591,7 +745,6 @@ onUnmounted(() => {
.unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; }
.signed-img img { max-height: 90px; }
.re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; }
.bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; }
.bottom-actions .el-button { width: 48%; }
@ -621,4 +774,4 @@ onUnmounted(() => {
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
.sidebar-actions .el-button { flex: 1; height: 40px; }
}
</style>
</style>