diff --git a/inventory-backend/app/api/v1/scrap.py b/inventory-backend/app/api/v1/scrap.py
index 5971239..6d8ba3f 100644
--- a/inventory-backend/app/api/v1/scrap.py
+++ b/inventory-backend/app/api/v1/scrap.py
@@ -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:
diff --git a/inventory-backend/app/api/v1/transactions.py b/inventory-backend/app/api/v1/transactions.py
index ae3f0d5..20b0166 100644
--- a/inventory-backend/app/api/v1/transactions.py
+++ b/inventory-backend/app/api/v1/transactions.py
@@ -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()
diff --git a/inventory-backend/app/services/trans_service.py b/inventory-backend/app/services/trans_service.py
index f0b0c80..94725f4 100644
--- a/inventory-backend/app/services/trans_service.py
+++ b/inventory-backend/app/services/trans_service.py
@@ -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'):
"""
diff --git a/inventory-web/src/views/transaction/records.vue b/inventory-web/src/views/transaction/records.vue
index 238cb8f..ad11712 100644
--- a/inventory-web/src/views/transaction/records.vue
+++ b/inventory-web/src/views/transaction/records.vue
@@ -105,7 +105,8 @@
- 已还
+ 已报废
+ 已还
部分归还
未还
@@ -126,6 +127,17 @@
+
+
+
+
+ 报废
+
+
+
+
+
+
+
+
单号: {{ scrapBorrowNo }}
+
请勾选要报废的未归还明细:
+
+
+
+
+
+
+
+ {{ row.pending_quantity }}
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 确认报废
+
+