feat(借库审批流): 完整前后端实现
This commit is contained in:
@ -0,0 +1,194 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 购物车商品项接口
|
||||
export interface CartItem {
|
||||
id: number
|
||||
sku: string
|
||||
name: string
|
||||
spec_model: string
|
||||
source_table: string
|
||||
stock_quantity: number
|
||||
available_quantity: number
|
||||
barcode: string
|
||||
price: number // 单价
|
||||
out_quantity: number // 本次出库数量
|
||||
}
|
||||
|
||||
// 提交出库单的数据结构
|
||||
export interface OutboundSubmitData {
|
||||
items: Array<{
|
||||
sku: string
|
||||
source_table: string
|
||||
stock_id: number
|
||||
barcode: string
|
||||
quantity: number
|
||||
price: number
|
||||
}>
|
||||
outbound_type: string
|
||||
consumer_name: string
|
||||
operator_name: string
|
||||
signature_path: string // 上传后返回的图片路径
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface ScanResult {
|
||||
id: number
|
||||
sku: string
|
||||
name: string
|
||||
spec_model: string
|
||||
source_table: string // 'stock_buy' | 'stock_product' ...
|
||||
stock_quantity: number
|
||||
available_quantity: number
|
||||
batch_number?: string
|
||||
warehouse_location?: string
|
||||
barcode?: string
|
||||
price?: number // 扫描返回的价格
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据条码获取库存物品详情
|
||||
* @param barcode 扫描到的条码
|
||||
*/
|
||||
export function getStockByBarcode(barcode: string) {
|
||||
return request<any, ScanResult>({
|
||||
url: '/v1/outbound/scan',
|
||||
method: 'get',
|
||||
params: { barcode }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交出库单 (批量)
|
||||
*/
|
||||
export function submitOutbound(data: OutboundSubmitData) {
|
||||
return request({
|
||||
url: '/v1/outbound',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取出库记录列表
|
||||
*/
|
||||
export function getOutboundList(params: any) {
|
||||
return request({
|
||||
url: '/v1/outbound',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交出库申请单(申请人 → 审批流)
|
||||
*/
|
||||
export function submitOutboundRequest(data: {
|
||||
items: Array<{
|
||||
material_type?: string
|
||||
name: string
|
||||
spec_model: string
|
||||
warehouse_location?: string
|
||||
quantity: number
|
||||
}>
|
||||
remark: string
|
||||
}) {
|
||||
return request({
|
||||
url: '/v1/outbound/request',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取出库审批申请单列表
|
||||
* @param params 支持 status, page, limit
|
||||
*/
|
||||
export function getApprovalRequestList(params: { status?: number | ''; page?: number; limit?: number }) {
|
||||
return request({
|
||||
url: '/v1/outbound/request',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批(通过 / 驳回)出库申请单
|
||||
* @param id 审批单ID
|
||||
* @param data action: 'approve' | 'reject',reject 时需传 reject_reason
|
||||
*/
|
||||
export function approveRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) {
|
||||
return request({
|
||||
url: `/v1/outbound/request/${id}/approve`,
|
||||
method: 'patch',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// 借库审批流 API
|
||||
// ==============================================================================
|
||||
|
||||
/**
|
||||
* 提交借库申请单(申请人 → 审批流)
|
||||
*/
|
||||
export function submitBorrowRequest(data: {
|
||||
items: Array<{
|
||||
name: string
|
||||
spec_model: string
|
||||
warehouse_location?: string
|
||||
quantity: number
|
||||
}>
|
||||
remark?: string
|
||||
allowed_approvers?: Array<{ type: string; value: string }>
|
||||
approver_id?: number
|
||||
}) {
|
||||
return request({
|
||||
url: '/v1/transactions/borrow/request',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取借库审批申请单列表
|
||||
* @param params 支持 status, page, limit
|
||||
*/
|
||||
export function getBorrowApprovalList(params: { status?: number | ''; page?: number; limit?: number }) {
|
||||
return request({
|
||||
url: '/v1/transactions/borrow/request',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 审批(通过 / 驳回)借库申请单
|
||||
* @param id 审批单ID
|
||||
* @param data action: 'approve' | 'reject',reject 时需传 reject_reason
|
||||
*/
|
||||
export function approveBorrowRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) {
|
||||
return request({
|
||||
url: `/v1/transactions/borrow/request/${id}/approve`,
|
||||
method: 'patch',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行借库扣减(审批通过后调用)
|
||||
* @param data approval_id + 扫码选中的物品 + 借用人信息 + 签名
|
||||
*/
|
||||
export function dispatchBorrow(data: {
|
||||
approval_id: number
|
||||
items: Array<any>
|
||||
borrower_name: string
|
||||
signature_path: string
|
||||
remark?: string
|
||||
expected_return_time?: string | null
|
||||
}) {
|
||||
return request({
|
||||
url: '/v1/transactions/borrow/dispatch',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
@ -215,6 +215,22 @@ const routes: Array<RouteRecordRaw> = [
|
||||
component: () => import('@/views/transaction/borrow.vue'),
|
||||
meta: { title: '借库' }
|
||||
},
|
||||
{
|
||||
path: 'borrow_approval',
|
||||
name: 'BorrowApproval',
|
||||
component: () => import('@/views/borrow/approval/index.vue'),
|
||||
meta: {
|
||||
title: '借库审批',
|
||||
icon: 'Stamp',
|
||||
roles: ['SUPER_ADMIN', 'SUPERVISOR']
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'borrow_apply',
|
||||
name: 'BorrowApply',
|
||||
component: () => import('@/views/borrow/apply/index.vue'),
|
||||
meta: { title: '借库申请' }
|
||||
},
|
||||
{
|
||||
path: 'repair',
|
||||
name: 'OpRepair',
|
||||
|
||||
@ -301,7 +301,7 @@
|
||||
class="no-print-content"
|
||||
>
|
||||
<el-alert
|
||||
title="请确认以下物料申请清单,填写借库人姓名及申请原因后提交"
|
||||
title="请确认以下物料申请清单,确认无误后提交"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-bottom: 16px;"
|
||||
@ -325,14 +325,6 @@
|
||||
</el-table>
|
||||
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="* 借库人姓名" required>
|
||||
<el-input
|
||||
v-model="borrowerName"
|
||||
placeholder="请填写借库人姓名"
|
||||
maxlength="50"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="* 指定审批人" required>
|
||||
<el-select
|
||||
v-model="requestApproverId"
|
||||
@ -885,12 +877,6 @@ const loadApprovers = async () => {
|
||||
|
||||
// ★ 确认提交借库申请
|
||||
const confirmSubmitRequest = async () => {
|
||||
const borrower = borrowerName.value.trim()
|
||||
if (!borrower) {
|
||||
ElMessage.warning('请填写借库人姓名')
|
||||
return
|
||||
}
|
||||
|
||||
requestSubmitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
@ -900,7 +886,6 @@ const confirmSubmitRequest = async () => {
|
||||
warehouse_location: item.warehouse_location || '',
|
||||
quantity: item.export_quantity || 0
|
||||
})),
|
||||
borrower_name: borrower,
|
||||
remark: requestRemark.value.trim(),
|
||||
approver_id: requestApproverId.value
|
||||
}
|
||||
|
||||
365
inventory-web/src/views/borrow/approval/index.vue
Normal file
365
inventory-web/src/views/borrow/approval/index.vue
Normal file
@ -0,0 +1,365 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="filter-container">
|
||||
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
|
||||
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
|
||||
<el-radio-button label="">全部</el-radio-button>
|
||||
<el-radio-button :label="0">待审批</el-radio-button>
|
||||
<el-radio-button :label="1">已通过</el-radio-button>
|
||||
<el-radio-button :label="2">已驳回</el-radio-button>
|
||||
<el-radio-button :label="3">已完成</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
border
|
||||
stripe
|
||||
style="margin-top: 16px;"
|
||||
row-key="id"
|
||||
:expand-row-keys="expandedRows"
|
||||
@expand-change="handleExpandChange"
|
||||
>
|
||||
<!-- 展开行 -->
|
||||
<el-table-column type="expand" width="60" align="center">
|
||||
<template #default="{ row }">
|
||||
<div style="padding: 12px 24px; background: #f5f7fa;">
|
||||
<p style="margin: 0 0 10px 0; font-weight: bold; font-size: 13px; color: #606266;">
|
||||
物料明细(共 {{ row.items?.length || 0 }} 项)
|
||||
</p>
|
||||
<el-table
|
||||
v-if="row.items?.length"
|
||||
:data="row.items"
|
||||
border
|
||||
size="small"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="warehouse_location" label="库位" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="quantity" label="计划数量" width="100" align="center">
|
||||
<template #default="{ row: item }">
|
||||
<span style="color: #F56C6C; font-weight: bold;">{{ item.quantity ?? '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="暂无物料明细" :image-size="60" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="request_no" label="申请单号" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="toggleExpand(row)">
|
||||
{{ row.request_no }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="申请人" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ getApplicantName(row.applicant_id) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="物料种类" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ row.items?.length || 0 }} 种</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="created_at" label="申请时间" width="170" />
|
||||
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">
|
||||
{{ statusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="审批信息" width="180">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 1">
|
||||
<span style="color: #67C23A;">{{ getApproverName(row.actual_approver_id) }}</span>
|
||||
<br />
|
||||
<span style="font-size: 12px; color: #909399;">{{ row.approved_at || '' }}</span>
|
||||
</template>
|
||||
<template v-else-if="row.status === 2">
|
||||
<span style="color: #F56C6C;">已驳回</span>
|
||||
<el-tooltip v-if="row.reject_reason" :content="row.reject_reason" placement="top">
|
||||
<el-icon style="margin-left: 4px; cursor: pointer;"><Warning /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-else-if="row.status === 3">
|
||||
<span style="color: #909399;">{{ getApproverName(row.actual_approver_id) }}</span>
|
||||
</template>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 0">
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('borrow_approval:operation')"
|
||||
type="success"
|
||||
size="small"
|
||||
:loading="row._approving"
|
||||
@click="handleApprove(row)"
|
||||
>
|
||||
通过
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('borrow_approval:operation')"
|
||||
type="danger"
|
||||
size="small"
|
||||
:loading="row._approving"
|
||||
@click="openRejectDialog(row)"
|
||||
>
|
||||
驳回
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
background
|
||||
style="margin-top: 16px; justify-content: flex-end; display: flex;"
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, prev, pager, next, sizes"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
|
||||
<!-- 驳回原因 Dialog -->
|
||||
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="申请单号">
|
||||
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="驳回原因" required>
|
||||
<el-input
|
||||
v-model="rejectReason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请填写驳回原因(必填)"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rejectDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="rejectLoading" @click="confirmReject">确认驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Refresh, Warning } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getBorrowApprovalList, approveBorrowRequest } from '@/api/transaction'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// --- 状态 ---
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
|
||||
const expandedRows = ref<string[]>([])
|
||||
|
||||
// 驳回 Dialog
|
||||
const rejectDialogVisible = ref(false)
|
||||
const currentRejectRow = ref<any>(null)
|
||||
const rejectReason = ref('')
|
||||
const rejectLoading = ref(false)
|
||||
|
||||
// 申请人 / 审批人名称缓存
|
||||
const userNameCache = ref<Record<number, string>>({})
|
||||
|
||||
// --- 工具函数 ---
|
||||
const statusText = (status: number) => {
|
||||
const map: Record<number, string> = {
|
||||
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'
|
||||
}
|
||||
return map[status] ?? '-'
|
||||
}
|
||||
|
||||
const statusTagType = (status: number) => {
|
||||
const map: Record<number, string> = {
|
||||
0: 'warning', 1: 'success', 2: 'danger', 3: 'info'
|
||||
}
|
||||
return map[status] ?? 'info'
|
||||
}
|
||||
|
||||
const getApplicantName = (id: number | null) => {
|
||||
if (!id) return '-'
|
||||
return userNameCache.value[id] ?? `用户 #${id}`
|
||||
}
|
||||
|
||||
const getApproverName = (id: number | null) => {
|
||||
if (!id) return '-'
|
||||
return userNameCache.value[id] ?? `用户 #${id}`
|
||||
}
|
||||
|
||||
// --- 展开行 ---
|
||||
const toggleExpand = (row: any) => {
|
||||
const idx = expandedRows.value.indexOf(row.id)
|
||||
if (idx > -1) {
|
||||
expandedRows.value.splice(idx, 1)
|
||||
} else {
|
||||
expandedRows.value.push(row.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExpandChange = () => {}
|
||||
|
||||
// --- 数据获取 ---
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: page.value,
|
||||
limit: pageSize.value
|
||||
}
|
||||
if (filterStatus.value !== '') {
|
||||
params.status = filterStatus.value
|
||||
}
|
||||
|
||||
const res: any = await getBorrowApprovalList(params)
|
||||
|
||||
const records = res.data?.items || []
|
||||
records.forEach((r: any) => {
|
||||
if (r.applicant_id && !userNameCache.value[r.applicant_id]) {
|
||||
if (r.applicant_name) {
|
||||
userNameCache.value[r.applicant_id] = r.applicant_name
|
||||
}
|
||||
}
|
||||
if (r.actual_approver_id && !userNameCache.value[r.actual_approver_id]) {
|
||||
if (r.approver_name) {
|
||||
userNameCache.value[r.actual_approver_id] = r.approver_name
|
||||
}
|
||||
}
|
||||
r._approving = false
|
||||
})
|
||||
|
||||
list.value = records
|
||||
total.value = res.data?.total || records.length || 0
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.msg || '加载审批列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 筛选 ---
|
||||
const handleStatusChange = () => {
|
||||
page.value = 1
|
||||
expandedRows.value = []
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// --- 分页 ---
|
||||
const handlePageChange = (p: number) => {
|
||||
page.value = p
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (s: number) => {
|
||||
pageSize.value = s
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// --- 审批操作 ---
|
||||
const handleApprove = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要通过借库申请单 【${row.request_no}】 吗?`,
|
||||
'审批确认',
|
||||
{ confirmButtonText: '确定通过', cancelButtonText: '取消', type: 'info' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
row._approving = true
|
||||
try {
|
||||
await approveBorrowRequest(row.id, { action: 'approve' })
|
||||
ElMessage.success(`申请单 ${row.request_no} 已通过`)
|
||||
await fetchData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.msg || '审批操作失败')
|
||||
} finally {
|
||||
row._approving = false
|
||||
}
|
||||
}
|
||||
|
||||
const openRejectDialog = (row: any) => {
|
||||
currentRejectRow.value = row
|
||||
rejectReason.value = ''
|
||||
rejectDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmReject = async () => {
|
||||
const reason = rejectReason.value.trim()
|
||||
if (!reason) {
|
||||
ElMessage.warning('请填写驳回原因')
|
||||
return
|
||||
}
|
||||
|
||||
rejectLoading.value = true
|
||||
try {
|
||||
await approveBorrowRequest(currentRejectRow.value.id, {
|
||||
action: 'reject',
|
||||
reject_reason: reason
|
||||
})
|
||||
ElMessage.success(`申请单 ${currentRejectRow.value.request_no} 已驳回`)
|
||||
rejectDialogVisible.value = false
|
||||
await fetchData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.msg || '驳回操作失败')
|
||||
} finally {
|
||||
rejectLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 初始化 ---
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
padding: 20px;
|
||||
}
|
||||
.filter-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@ -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>
|
||||
Reference in New Issue
Block a user