后端: - 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
417 lines
15 KiB
Vue
417 lines
15 KiB
Vue
<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>
|