Files
KCGL/inventory-web/src/views/transaction/records.vue
yueli 36e9f500aa 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
2026-08-31 10:21:28 +08:00

417 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div v-if="userStore.hasPermission('op_records')" class="app-container">
<div class="filter-container">
<el-radio-group v-model="status" @change="fetchData" style="margin-right: 20px">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="borrowed">未归还</el-radio-button>
<el-radio-button label="returned">已归还</el-radio-button>
</el-radio-group>
<el-input
v-model="keyword"
placeholder="请输入关键词"
style="width: 250px"
clearable
@input="debouncedSearch"
@clear="handleClearSearch"
>
<template #prepend>
<el-select v-model="searchType" placeholder="类型" style="width: 110px" @change="debouncedSearch">
<el-option label="全部" value="all" />
<el-option label="单号" value="no" />
<el-option label="借用人" value="name" />
<el-option label="SKU" value="sku" />
<el-option label="物料名称" value="material_name" />
<el-option label="规格型号" value="spec_model" />
</el-select>
</template>
</el-input>
<el-button type="primary" @click="fetchData">查询</el-button>
</div>
<el-table
:data="list"
border
stripe
style="margin-top:20px"
v-loading="loading"
type="expand"
>
<el-table-column type="expand">
<template #default="props">
<div style="padding: 10px 40px;">
<h4 style="margin: 0 0 10px; font-size: 14px; color: #606266;">借出明细</h4>
<el-table :data="props.row.children" border size="small">
<el-table-column prop="material_name" label="物料名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="借出数量" width="80" align="center">
<template #default="{row}">
<el-tag type="info">{{ row.quantity }}</el-tag>
</template>
</el-table-column>
<el-table-column label="已还数量" width="80" align="center">
<template #default="{row}">
<el-tag type="success">{{ row.returned_quantity || 0 }}</el-tag>
</template>
</el-table-column>
<el-table-column label="待还数量" width="80" align="center">
<template #default="{row}">
<el-tag v-if="row.pending_quantity > 0" type="warning">{{ row.pending_quantity }}</el-tag>
<el-tag v-else type="success">0</el-tag>
</template>
</el-table-column>
<el-table-column prop="return_location" label="归还库位" min-width="120">
<template #default="{row}">
<span v-if="row.return_location">{{ row.return_location }}</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('borrow_no')" prop="borrow_no" label="单号" width="180" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="100" />
<el-table-column v-if="hasColumnPermission('borrow_time')" prop="borrow_time" label="借出时间" width="160" sortable />
<el-table-column label="借出物品" width="90" align="center">
<template #default="{row}">
<el-tag type="info">{{ row.children ? row.children.length : 0 }} 项</el-tag>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('return_operator')" prop="return_operator" label="归还人" width="100" />
<el-table-column v-if="hasColumnPermission('expected_return_time') || hasColumnPermission('return_time')" label="归还时间 / 预计" min-width="200">
<template #default="{row}">
<div v-if="row.status === 'returned'">
<el-tag type="success" size="small">实际</el-tag>
{{ row.return_time || '-' }}
</div>
<div v-else-if="!row.expected_return_time">
<el-tag type="primary" size="small">无限期</el-tag>
</div>
<div v-else>
<span>预计 {{ formatExpectedTime(row.expected_return_time).text }} </span>
<span :style="{
color: formatExpectedTime(row.expected_return_time).style.color || 'inherit',
fontWeight: formatExpectedTime(row.expected_return_time).style.fontWeight || 'normal',
marginLeft: '4px'
}">
{{ formatExpectedTime(row.expected_return_time).diffText }}
</span>
</div>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
<template #default="{row}">
<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>
</el-table-column>
<el-table-column v-if="hasColumnPermission('borrow_signature') || hasColumnPermission('return_signature')" label="电子签名" width="140" align="center">
<template #default="{row}">
<div style="display:flex; justify-content: center; gap:10px">
<el-popover trigger="hover" placement="top" v-if="row.borrow_signature && hasColumnPermission('borrow_signature')" width="220">
<template #reference><el-tag size="small">借</el-tag></template>
<img :src="row.borrow_signature" style="width:200px; border:1px solid #eee" />
</el-popover>
<el-popover trigger="hover" placement="top" v-if="row.return_signature && hasColumnPermission('return_signature')" width="220">
<template #reference><el-tag type="success" size="small">还</el-tag></template>
<img :src="row.return_signature" style="width:200px; border:1px solid #eee" />
</el-popover>
</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
background
layout="prev, pager, next"
:total="total"
@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, 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'
dayjs.locale('zh-cn')
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
// 防抖定时器
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// 防抖搜索函数
const debouncedSearch = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => {
page.value = 1
fetchData()
}, 500)
}
// 清空搜索时立即触发查询
const handleClearSearch = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
page.value = 1
fetchData()
}
// 列与权限Code的映射关系(数据库中的code)
const permissionMap: Record<string, string | null> = {
// 基础显示列 — 始终可见
borrow_no: null,
borrower_name: null,
sku: null,
borrow_time: null,
return_time: null,
return_operator: null,
status: null,
expected_return_time: null,
return_location: null,
// 签名列 — 始终可见(sys_element 补齐后再改为显式权限码)
borrow_signature: null,
return_signature: null,
}
// 检查列权限
const hasColumnPermission = (prop: string) => {
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
if (!(prop in permissionMap)) return false
const code = permissionMap[prop]
if (code === null || code === undefined) return true
return userStore.hasPermission(code)
}
const list = ref<any[]>([])
const total = ref(0)
// ★ 修改点:默认状态改为 'borrowed' (未归还)
const status = ref('borrowed')
const keyword = ref('')
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 {
const res = await request({
url: '/v1/transactions/records',
method: 'get',
params: {
page: page.value,
status: status.value,
keyword: keyword.value,
search_type: searchType.value
}
})
// ★ 按 borrow_no 分组聚合为主子表结构
const groupMap = new Map()
;(res.data.items || []).forEach(item => {
if (!groupMap.has(item.borrow_no)) {
groupMap.set(item.borrow_no, {
...item,
children: []
})
}
groupMap.get(item.borrow_no).children.push(item)
})
// ★ 整单状态重算:所有明细都已报废 → 整单标记 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 }
}
const handlePage = (val: number) => {
page.value = val
fetchData()
}
// ★ 新增:格式化预计归还时间及倒计时逻辑(交通灯预警系统)
const formatExpectedTime = (timeStr: string) => {
if (!timeStr) return { text: '无限期', diffText: '', style: {} }
// 后端返回的可能是 YYYY-MM-DD HH:mm:ss,我们只取日期部分比较
const expected = dayjs(timeStr).startOf('day')
const today = dayjs().startOf('day')
const diffDays = expected.diff(today, 'day')
let diffText = ''
let style: Record<string, string> = {}
// 这里的 timeStr 只展示前10位 (日期),或者展示完整
const displayTime = timeStr.substring(0, 10)
if (diffDays < 0) {
// 已逾期 - 红色加粗
diffText = ` (已逾期 ${Math.abs(diffDays)} 天)`
style = { color: '#F56C6C', fontWeight: 'bold' }
} else if (diffDays <= 1) {
// 极其紧急(剩 0-1 天)- 警告黄加粗
diffText = diffDays === 0 ? ` (今天到期)` : ` (剩 ${diffDays} 天)`
style = { color: '#E6A23C', fontWeight: 'bold' }
} else if (diffDays <= 7) {
// 即将到期(剩 2-7 天)- 安全绿
diffText = ` (剩 ${diffDays} 天)`
style = { color: '#67C23A' }
} else {
// 安全/无期限 - 默认颜色
diffText = ` (剩 ${diffDays} 天)`
style = {}
}
return { text: displayTime, diffText, style }
}
onMounted(fetchData)
// 组件销毁前清理定时器,防止内存泄漏
onBeforeUnmount(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
})
</script>
<style>
</style>