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:
@ -356,7 +356,30 @@ class ScrapService:
|
||||
).join(MaterialBase, TransRepair.base_id == MaterialBase.id).filter(
|
||||
MaterialBase.company_name == company_limit
|
||||
)
|
||||
all_matches = buy_subq.union(semi_subq, product_subq, repair_subq).subquery()
|
||||
# ★ 借库转报废:stock_id 指向 trans_borrow.id,通过 borrow 关联源库存表再关联 MaterialBase
|
||||
from app.models.transaction import TransBorrow
|
||||
borrow_stock_buy = db.session.query(TransScrap.id).join(
|
||||
TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id,
|
||||
TransScrap.source_table == 'trans_borrow')
|
||||
).join(StockBuy, TransBorrow.stock_id == StockBuy.id).join(
|
||||
MaterialBase, StockBuy.base_id == MaterialBase.id
|
||||
).filter(MaterialBase.company_name == company_limit)
|
||||
borrow_stock_semi = db.session.query(TransScrap.id).join(
|
||||
TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id,
|
||||
TransScrap.source_table == 'trans_borrow')
|
||||
).join(StockSemi, TransBorrow.stock_id == StockSemi.id).join(
|
||||
MaterialBase, StockSemi.base_id == MaterialBase.id
|
||||
).filter(MaterialBase.company_name == company_limit)
|
||||
borrow_stock_product = db.session.query(TransScrap.id).join(
|
||||
TransBorrow, db.and_(TransScrap.stock_id == TransBorrow.id,
|
||||
TransScrap.source_table == 'trans_borrow')
|
||||
).join(StockProduct, TransBorrow.stock_id == StockProduct.id).join(
|
||||
MaterialBase, StockProduct.base_id == MaterialBase.id
|
||||
).filter(MaterialBase.company_name == company_limit)
|
||||
all_matches = buy_subq.union(
|
||||
semi_subq, product_subq, repair_subq,
|
||||
borrow_stock_buy, borrow_stock_semi, borrow_stock_product
|
||||
).subquery()
|
||||
query = query.filter(TransScrap.id.in_(all_matches))
|
||||
|
||||
# 按时间倒序
|
||||
@ -390,7 +413,24 @@ class ScrapService:
|
||||
material_name = ''
|
||||
spec_model = ''
|
||||
|
||||
if r.source_table == 'trans_repair':
|
||||
if r.source_table == 'trans_borrow':
|
||||
# 借库转报废:stock_id 指向 trans_borrow.id,通过源库存表取物料
|
||||
from app.models.transaction import TransBorrow
|
||||
borrow = TransBorrow.query.get(r.stock_id)
|
||||
if borrow:
|
||||
bstock_model = None
|
||||
if borrow.source_table == 'stock_buy':
|
||||
bstock_model = StockBuy.query.get(borrow.stock_id)
|
||||
elif borrow.source_table == 'stock_semi':
|
||||
bstock_model = StockSemi.query.get(borrow.stock_id)
|
||||
elif borrow.source_table == 'stock_product':
|
||||
bstock_model = StockProduct.query.get(borrow.stock_id)
|
||||
if bstock_model and hasattr(bstock_model, 'base_id') and bstock_model.base_id:
|
||||
base = MaterialBase.query.get(bstock_model.base_id)
|
||||
if base:
|
||||
material_name = base.name or ''
|
||||
spec_model = base.spec_model or ''
|
||||
elif r.source_table == 'trans_repair':
|
||||
# 维修单
|
||||
repair = TransRepair.query.get(r.stock_id)
|
||||
if repair:
|
||||
|
||||
@ -110,6 +110,38 @@ def submit_return():
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
|
||||
|
||||
# --- 借库报废(未归还直接报废,关联报废单流程)---
|
||||
@trans_bp.route('/borrow/scrap', methods=['POST'])
|
||||
@jwt_required()
|
||||
@permission_required('op_return:operation') # 复用归还权限:能归还的库管即可报废
|
||||
@audit_log(
|
||||
module='借库管理',
|
||||
action='借库报废',
|
||||
get_target_name_fn=lambda: request.get_json().get('reason') if request.get_json() else None
|
||||
)
|
||||
def scrap_borrow():
|
||||
"""
|
||||
借库未归还直接报废(库管/主管操作)
|
||||
请求体: { "record_ids": [1, 2, 3], "reason": "物品丢失" }
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
record_ids = data.get('record_ids', [])
|
||||
reason = data.get('reason', '')
|
||||
|
||||
if not record_ids:
|
||||
return jsonify({'code': 400, 'msg': '请选择要报废的借出记录'}), 400
|
||||
|
||||
user = get_jwt_identity() or 'Unknown'
|
||||
try:
|
||||
result = TransService.scrap_borrow(record_ids, operator_name=user, reason=reason)
|
||||
return jsonify({'code': 200, 'msg': f'已报废 {result["count"]} 条借出记录', 'data': result})
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'报废失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
# --- 记录列表 ---
|
||||
@trans_bp.route('/records', methods=['GET'])
|
||||
@jwt_required()
|
||||
|
||||
@ -327,6 +327,86 @@ class TransService:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def scrap_borrow(record_ids, operator_name='System', reason=''):
|
||||
"""
|
||||
借库未归还直接报废(关联报废单流程)
|
||||
|
||||
适用场景:借出的物品确认丢失/损坏/无法归还,库管/主管手动报废。
|
||||
- 该笔借用不再追讨归还,从借还记录中标记为已报废
|
||||
- 生成一条 TransScrap 报废记录(关联 trans_borrow)
|
||||
- 总库存 stock_quantity 扣减(该物品确认损失);可用库存已在借出时冻结,无需重复扣
|
||||
|
||||
Args:
|
||||
record_ids: TransBorrow 记录ID列表
|
||||
operator_name: 操作人
|
||||
reason: 报废原因
|
||||
|
||||
Returns:
|
||||
{count: 报废条数}
|
||||
"""
|
||||
from app.models.transaction import TransScrap
|
||||
|
||||
if not record_ids:
|
||||
raise ValueError('请选择要报废的借出记录')
|
||||
if not reason:
|
||||
raise ValueError('请填写报废原因')
|
||||
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||
created_records = []
|
||||
scraped_count = 0
|
||||
|
||||
try:
|
||||
for rid in record_ids:
|
||||
record = TransBorrow.query.with_for_update().get(rid)
|
||||
if not record:
|
||||
continue
|
||||
# 仅未归还的借用可报废
|
||||
if record.is_returned:
|
||||
continue
|
||||
pending_qty = float(record.quantity) - float(record.returned_quantity or 0)
|
||||
if pending_qty <= 0:
|
||||
continue
|
||||
|
||||
# 1. 标记借用记录为已报废(不再追讨归还)
|
||||
record.is_returned = True
|
||||
record.status = 'scrapped'
|
||||
record.return_time = datetime.now()
|
||||
record.return_operator = operator_name
|
||||
|
||||
# 2. 扣减总库存(该物品确认损失);可用库存已在借出时冻结
|
||||
ModelClass = model_map.get(record.source_table)
|
||||
if ModelClass:
|
||||
stock = ModelClass.query.with_for_update().get(record.stock_id)
|
||||
if stock:
|
||||
stock.stock_quantity = float(stock.stock_quantity or 0) - pending_qty
|
||||
# 防止负库存
|
||||
if stock.stock_quantity < 0:
|
||||
stock.stock_quantity = 0
|
||||
|
||||
# 3. 创建报废记录
|
||||
scrap_record = TransScrap(
|
||||
sku=record.sku,
|
||||
source_table='trans_borrow', # 标识来源为借出转报废
|
||||
stock_id=record.id,
|
||||
quantity=pending_qty,
|
||||
reason=f"[借库报废] {reason}",
|
||||
operator_name=operator_name,
|
||||
approval_status='approved',
|
||||
cost_at_scrap=0,
|
||||
total_loss=0
|
||||
)
|
||||
db.session.add(scrap_record)
|
||||
created_records.append(scrap_record)
|
||||
scraped_count += 1
|
||||
|
||||
db.session.commit()
|
||||
return {'count': scraped_count}
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all'):
|
||||
"""
|
||||
|
||||
@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user