feat(borrow): 借库未归还直接报废功能(关联报废单流程)

后端:
- trans_service 新增 scrap_borrow 方法:未归还借用标记为 scrapped,
  扣减总库存 stock_quantity(可用已在借出时冻结),生成 TransScrap 报废记录
- transactions.py 新增 POST /borrow/scrap 接口(复用 op_return:operation 权限,库管/主管可操作)
- scrap.py query_records 支持 trans_borrow 来源的物料名解析 + 行级隔离

前端:
- records.vue 未归还记录新增「报废」按钮(库管/主管可见)
- 报废弹窗:勾选明细 + 填报废原因 + 二次确认
- 状态显示新增「已报废」标签;整单所有明细报废时标记为 scrapped
This commit is contained in:
yueli
2026-08-31 10:21:28 +08:00
parent 39f3f8af45
commit 36e9f500aa
4 changed files with 280 additions and 5 deletions

View File

@ -105,7 +105,8 @@
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<template #default="{row}">
<el-tag v-if="row.status === 'returned'" type="success"></el-tag>
<el-tag v-if="row.status === 'scrapped'" type="info">报废</el-tag>
<el-tag v-else-if="row.status === 'returned'" type="success">已还</el-tag>
<el-tag v-else-if="row.status === 'partial_returned'" type="warning">部分归还</el-tag>
<el-tag v-else type="danger">未还</el-tag>
</template>
@ -126,6 +127,17 @@
</div>
</template>
</el-table-column>
<!-- 报废操作列仅未归还记录 + 库管/主管可见 -->
<el-table-column v-if="canScrap" label="操作" width="90" align="center" fixed="right">
<template #default="{row}">
<el-button
v-if="row.status === 'borrowed' || row.status === 'partial_returned'"
type="danger" link size="small"
@click="openScrapDialog(row)"
>报废</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@ -135,11 +147,52 @@
@current-change="handlePage"
style="margin-top:10px; text-align:right"
/>
<!-- 借库报废弹窗 -->
<el-dialog v-model="scrapDialogVisible" title="借库报废(未归还)" width="650px" destroy-on-close>
<el-alert
title="报废后该笔借出不再追讨归还,并从总库存中扣除。此操作不可恢复,请谨慎确认。"
type="warning"
:closable="false"
show-icon
style="margin-bottom: 12px"
/>
<div style="margin-bottom: 12px;">
<div style="font-weight: 600; margin-bottom: 6px;">单号: {{ scrapBorrowNo }}</div>
<div style="font-size: 13px; color: #909399;">请勾选要报废的未归还明细</div>
</div>
<el-table :data="scrapCandidates" border size="small" max-height="300" @selection-change="handleScrapSelection">
<el-table-column type="selection" width="50" align="center" />
<el-table-column prop="material_name" label="物料名称" min-width="130" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="100" show-overflow-tooltip />
<el-table-column label="待还数量" width="80" align="center">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold;">{{ row.pending_quantity }}</span>
</template>
</el-table-column>
<el-table-column prop="borrow_time" label="借出时间" width="130" />
</el-table>
<el-form label-width="80px" style="margin-top: 14px;">
<el-form-item label="报废原因" required>
<el-input
v-model="scrapReason"
type="textarea"
:rows="2"
placeholder="请填写报废原因,如:物品丢失 / 损坏无法归还"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="scrapDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="scrapSubmitting" @click="confirmScrap">确认报废</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/request'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
@ -206,6 +259,67 @@ const searchType = ref('all')
const page = ref(1)
const loading = ref(false)
// ★ 借库报废相关
const canScrap = computed(() =>
userStore.role === 'SUPER_ADMIN' ||
userStore.hasPermission('op_return:operation') ||
userStore.username === 'IRIS'
)
const scrapDialogVisible = ref(false)
const scrapSubmitting = ref(false)
const scrapBorrowNo = ref('')
const scrapCandidates = ref<any[]>([])
const scrapSelected = ref<any[]>([])
const scrapReason = ref('')
// 打开报废弹窗:收集该单号下所有未归还明细
const openScrapDialog = (row: any) => {
const unreturned = (row.children || []).filter((c: any) => (c.pending_quantity || 0) > 0)
scrapBorrowNo.value = row.borrow_no
scrapCandidates.value = unreturned
scrapSelected.value = []
scrapReason.value = ''
scrapDialogVisible.value = true
}
// 勾选变更
const handleScrapSelection = (rows: any[]) => {
scrapSelected.value = rows
}
// 确认报废
const confirmScrap = async () => {
if (scrapSelected.value.length === 0) return ElMessage.warning('请至少选择一条要报废的借出记录')
if (!scrapReason.value.trim()) return ElMessage.warning('请填写报废原因')
try {
await ElMessageBox.confirm(
`确定报废 ${scrapSelected.value.length} 条借出记录吗?\n\n报废后不再追讨归还并从总库存中扣除。此操作不可恢复`,
'⚠️ 报废确认',
{ confirmButtonText: '确认报废', cancelButtonText: '取消', type: 'warning' }
)
} catch (e) {
return // 用户取消
}
scrapSubmitting.value = true
try {
const recordIds = scrapSelected.value.map((c: any) => c.id)
await request({
url: '/v1/transactions/borrow/scrap',
method: 'post',
data: { record_ids: recordIds, reason: scrapReason.value }
})
ElMessage.success(`已报废 ${recordIds.length} 条借出记录`)
scrapDialogVisible.value = false
fetchData()
} catch (err: any) {
ElMessage.error(err?.msg || err?.message || '报废失败')
} finally {
scrapSubmitting.value = false
}
}
const fetchData = async () => {
loading.value = true
try {
@ -231,7 +345,16 @@ const fetchData = async () => {
}
groupMap.get(item.borrow_no).children.push(item)
})
list.value = Array.from(groupMap.values())
// ★ 整单状态重算:所有明细都已报废 → 整单标记 scrapped
list.value = Array.from(groupMap.values()).map(group => {
const allScrapped = group.children.length > 0 &&
group.children.every((c: any) => c.status === 'scrapped')
if (allScrapped) {
group.status = 'scrapped'
}
return group
})
total.value = res.data.total
} finally { loading.value = false }