Compare commits
9 Commits
63ea2a22a4
...
00781422eb
| Author | SHA1 | Date | |
|---|---|---|---|
| 00781422eb | |||
| ac15ef74db | |||
| 33969b8336 | |||
| e08012d9dd | |||
| 49a66f9be3 | |||
| b19699cfba | |||
| 54ea476206 | |||
| 1e38696e68 | |||
| 79d4a365e0 |
@ -1,14 +1,35 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from flask import Blueprint, jsonify, request, send_file
|
||||
from app.extensions import db
|
||||
# ★★★ 修复点:必须引入 datetime,否则下方更新时间时会报错 500 ★★★
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from app.utils.decorators import permission_required
|
||||
import uuid as uuid_module
|
||||
import io
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
||||
|
||||
# 导入模型
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.stocktake import StocktakeDraft
|
||||
from app.models.transaction import TransBorrow
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
# 尝试导入用户模型
|
||||
try:
|
||||
from app.models.sys.user import User
|
||||
except ImportError:
|
||||
User = None
|
||||
|
||||
# 尝试导入半成品和成品
|
||||
try:
|
||||
from app.models.inbound.semi import StockSemi
|
||||
except ImportError:
|
||||
StockSemi = None
|
||||
|
||||
try:
|
||||
from app.models.inbound.product import StockProduct
|
||||
except ImportError:
|
||||
StockProduct = None
|
||||
|
||||
|
||||
def _normalize_user_id(user_id):
|
||||
@ -144,6 +165,10 @@ def add_draft():
|
||||
"""
|
||||
扫码同步 (支持更新数量)
|
||||
如果 session_id 不存在则创建新的会话
|
||||
|
||||
差异计算逻辑调整:
|
||||
- adjusted_stock_qty = 账面总库存 - 借出未还数量
|
||||
- diff_qty = 实盘数量 - adjusted_stock_qty
|
||||
"""
|
||||
try:
|
||||
data = request.json
|
||||
@ -164,8 +189,22 @@ def add_draft():
|
||||
if not item:
|
||||
return jsonify({"message": "未找到对应的库存记录"}), 404
|
||||
|
||||
# 账面总库存
|
||||
stock_qty = float(item.stock_quantity) if item.stock_quantity else 0
|
||||
|
||||
# 计算借出未还数量 (quantity - returned_quantity)
|
||||
borrowed_result = db.session.query(
|
||||
db.func.sum(TransBorrow.quantity - TransBorrow.returned_quantity)
|
||||
).filter(
|
||||
TransBorrow.source_table == source_table,
|
||||
TransBorrow.stock_id == stock_id,
|
||||
TransBorrow.is_returned == False
|
||||
).scalar()
|
||||
total_borrowed = float(borrowed_result) if borrowed_result else 0
|
||||
|
||||
# 调整后的账面可用库存 = 账面总库存 - 借出未还数量
|
||||
adjusted_stock_qty = stock_qty - total_borrowed
|
||||
|
||||
# 查找是否已存在
|
||||
draft = StocktakeDraft.query.filter_by(user_id=user_id, uuid=uuid, session_id=session_id).first()
|
||||
|
||||
@ -173,8 +212,8 @@ def add_draft():
|
||||
# 如果已存在,更新数量和时间
|
||||
draft.quantity = quantity
|
||||
draft.scan_time = datetime.now()
|
||||
draft.stock_qty = stock_qty
|
||||
draft.diff_qty = quantity - stock_qty
|
||||
draft.stock_qty = adjusted_stock_qty
|
||||
draft.diff_qty = quantity - adjusted_stock_qty
|
||||
draft.source_table = source_table
|
||||
draft.stock_id = stock_id
|
||||
else:
|
||||
@ -184,8 +223,8 @@ def add_draft():
|
||||
uuid=uuid,
|
||||
quantity=quantity,
|
||||
session_id=session_id,
|
||||
stock_qty=stock_qty,
|
||||
diff_qty=quantity - stock_qty,
|
||||
stock_qty=adjusted_stock_qty,
|
||||
diff_qty=quantity - adjusted_stock_qty,
|
||||
source_table=source_table,
|
||||
stock_id=stock_id
|
||||
)
|
||||
@ -195,7 +234,9 @@ def add_draft():
|
||||
return jsonify({
|
||||
"message": "Saved",
|
||||
"session_id": session_id,
|
||||
"draft_id": draft.id
|
||||
"draft_id": draft.id,
|
||||
"adjusted_stock_qty": adjusted_stock_qty,
|
||||
"total_borrowed": total_borrowed
|
||||
}), 200
|
||||
except Exception as e:
|
||||
print(f"Add Draft Error: {e}")
|
||||
@ -329,32 +370,51 @@ def adjust_stock():
|
||||
1. 根据 diff_qty 调整库存 (stock_quantity 和 available_quantity)
|
||||
2. 生成流水账记录 (盘盈入库 / 盘亏出库)
|
||||
3. 标记草稿为 is_processed=True
|
||||
|
||||
支持两种模式:
|
||||
- 有草稿模式:通过 draft_id 或 stock_id+source_table 查找草稿
|
||||
- 无草稿模式:直接传入 stock_id + diff_qty + source_table(未扫码直接盘亏)
|
||||
"""
|
||||
data = request.json
|
||||
draft_id = data.get('draft_id')
|
||||
stock_id = data.get('stock_id')
|
||||
diff_qty = data.get('diff_qty')
|
||||
source_table = data.get('source_table')
|
||||
operator_name = data.get('operator_name', 'System')
|
||||
remark = data.get('remark', '')
|
||||
|
||||
if not draft_id:
|
||||
return jsonify({"message": "draft_id 不能为空"}), 400
|
||||
if not draft_id and not stock_id:
|
||||
return jsonify({"message": "draft_id 或 stock_id 不能同时为空"}), 400
|
||||
|
||||
try:
|
||||
# 1. 获取草稿记录
|
||||
draft = StocktakeDraft.query.get(draft_id)
|
||||
if not draft:
|
||||
return jsonify({"message": "草稿记录不存在"}), 404
|
||||
|
||||
# 2. 获取库存记录
|
||||
if not draft.stock_id or not draft.source_table:
|
||||
return jsonify({"message": "草稿记录缺少库存关联信息"}), 400
|
||||
|
||||
stock = get_stock_record(draft.source_table, draft.stock_id)
|
||||
if not stock:
|
||||
return jsonify({"message": "库存记录不存在"}), 404
|
||||
# 1. 尝试获取草稿
|
||||
draft = StocktakeDraft.query.get(draft_id) if draft_id else None
|
||||
if not draft and stock_id and source_table:
|
||||
draft = StocktakeDraft.query.filter_by(
|
||||
stock_id=stock_id,
|
||||
source_table=source_table
|
||||
).first()
|
||||
elif not draft and stock_id:
|
||||
draft = StocktakeDraft.query.filter_by(stock_id=stock_id).first()
|
||||
|
||||
# 2. 核心逻辑分支
|
||||
if draft:
|
||||
# 有草稿模式
|
||||
stock_id = draft.stock_id
|
||||
source_table = draft.source_table
|
||||
diff_qty = float(draft.diff_qty)
|
||||
else:
|
||||
# 无草稿模式(未扫码直接盘亏)
|
||||
if diff_qty is None or source_table is None or not stock_id:
|
||||
return jsonify({"message": "未扫码物资平账缺失必要参数(需提供 diff_qty 和 source_table)"}), 400
|
||||
diff_qty = float(diff_qty)
|
||||
|
||||
# 3. 计算调整
|
||||
# 3. 获取并校验真实的库存记录
|
||||
stock = get_stock_record(source_table, stock_id)
|
||||
if not stock:
|
||||
return jsonify({"message": "平账失败:物理库存记录已不存在"}), 404
|
||||
|
||||
# 4. 计算调整
|
||||
if diff_qty > 0:
|
||||
# 盘盈:增加库存
|
||||
new_stock_qty = float(stock.stock_quantity or 0) + diff_qty
|
||||
@ -375,19 +435,20 @@ def adjust_stock():
|
||||
else:
|
||||
return jsonify({"message": "差异为0,无需调整"}), 400
|
||||
|
||||
# 4. 执行库存调整
|
||||
# 5. 执行库存调整
|
||||
stock.stock_quantity = new_stock_qty
|
||||
stock.available_quantity = new_avail_qty
|
||||
|
||||
# 5. 生成流水账记录
|
||||
# 导入流水账模型
|
||||
# 6. 生成流水账记录
|
||||
from app.models.outbound import TransOutbound
|
||||
|
||||
# 生成唯一单号
|
||||
adj_no_suffix = draft.id if draft else f"MANUAL-{datetime.now().strftime('%Y%m%d%H%M%S')}"
|
||||
trans_record = TransOutbound(
|
||||
outbound_no=f"STKADJ-{datetime.now().strftime('%Y%m%d%H%M%S')}-{draft.id:04d}",
|
||||
outbound_no=f"STKADJ-{datetime.now().strftime('%Y%m%d%H%M%S')}-{adj_no_suffix}",
|
||||
sku=stock.sku,
|
||||
source_table=draft.source_table,
|
||||
stock_id=draft.stock_id,
|
||||
source_table=source_table,
|
||||
stock_id=stock_id,
|
||||
barcode=getattr(stock, 'barcode', ''),
|
||||
quantity=abs(diff_qty),
|
||||
unit_price=getattr(stock, 'pre_tax_unit_price', 0) or getattr(stock, 'manual_cost', 0) or 0,
|
||||
@ -399,7 +460,8 @@ def adjust_stock():
|
||||
)
|
||||
db.session.add(trans_record)
|
||||
|
||||
# 6. 删除草稿记录(平账后从工作台移除)
|
||||
# 7. 删除草稿记录(仅在有草稿时)
|
||||
if draft:
|
||||
db.session.delete(draft)
|
||||
|
||||
db.session.commit()
|
||||
@ -410,7 +472,7 @@ def adjust_stock():
|
||||
"diff_qty": diff_qty,
|
||||
"new_stock_qty": new_stock_qty,
|
||||
"new_avail_qty": new_avail_qty,
|
||||
"draft_id": draft_id
|
||||
"draft_id": draft_id if draft_id else (draft.id if draft else None)
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
@ -460,3 +522,206 @@ def print_stocktake():
|
||||
return jsonify({"message": "盘点报告已发送" if success else msg}), 200 if success else 500
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/export-stocktake', methods=['GET'])
|
||||
@permission_required('inventory_stocktake:operation')
|
||||
def export_stocktake():
|
||||
"""
|
||||
导出盘点报告 Excel
|
||||
包含3个Sheet:
|
||||
1. 盘点差异明细 (diff_qty != 0)
|
||||
2. 账实相符明细 (diff_qty == 0)
|
||||
3. 外借在用资产明细 (未归还的借出记录)
|
||||
"""
|
||||
try:
|
||||
# 创建工作簿
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active)
|
||||
|
||||
# 定义样式
|
||||
header_font = Font(bold=True, size=11, color="FFFFFF")
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_alignment = Alignment(horizontal="center", vertical="center")
|
||||
thin_border = Border(
|
||||
left=Side(style='thin'),
|
||||
right=Side(style='thin'),
|
||||
top=Side(style='thin'),
|
||||
bottom=Side(style='thin')
|
||||
)
|
||||
|
||||
def get_material_info(source_table, stock_id):
|
||||
"""获取物料基本信息"""
|
||||
if source_table == 'stock_buy':
|
||||
stock = StockBuy.query.get(stock_id)
|
||||
elif source_table == 'stock_semi':
|
||||
stock = StockSemi.query.get(stock_id) if StockSemi else None
|
||||
elif source_table == 'stock_product':
|
||||
stock = StockProduct.query.get(stock_id) if StockProduct else None
|
||||
else:
|
||||
return {'name': '-', 'sku': '-', 'spec': '-', 'unit': '-', 'location': '-'}
|
||||
|
||||
if not stock:
|
||||
return {'name': '-', 'sku': '-', 'spec': '-', 'unit': '-', 'location': '-'}
|
||||
|
||||
# 安全获取 sku
|
||||
stock_sku = getattr(stock, 'sku', None) or getattr(stock, 'SKU', None) or '-'
|
||||
|
||||
# 使用 base_id 或 material_id 关联查询物料基础表(安全方式)
|
||||
material = None
|
||||
base_id = getattr(stock, 'base_id', None) or getattr(stock, 'material_id', None)
|
||||
if base_id:
|
||||
material = MaterialBase.query.get(base_id)
|
||||
else:
|
||||
# 如果没有 base_id,尝试用 code 兜底查询
|
||||
if hasattr(MaterialBase, 'code') and stock_sku != '-':
|
||||
material = MaterialBase.query.filter_by(code=stock_sku).first()
|
||||
|
||||
# 规格型号:优先从 material 取,再 fallback 到 stock
|
||||
spec = (
|
||||
getattr(material, 'specification', None) or
|
||||
getattr(material, 'spec', None) or
|
||||
getattr(stock, 'spec_model', None) or
|
||||
getattr(stock, 'standard', None) or
|
||||
'-'
|
||||
)
|
||||
|
||||
# 库位:获取真实物理库位
|
||||
location = getattr(stock, 'location', None) or getattr(stock, 'position', None) or '-'
|
||||
|
||||
return {
|
||||
'name': material.name if material else stock_sku,
|
||||
'sku': stock_sku,
|
||||
'spec': spec,
|
||||
'unit': getattr(stock, 'unit', None) or '个',
|
||||
'location': location
|
||||
}
|
||||
|
||||
def get_user_name(user_id):
|
||||
"""获取用户真实姓名或昵称"""
|
||||
if not User or not user_id:
|
||||
return str(user_id) if user_id else '-'
|
||||
try:
|
||||
user = None
|
||||
# 尝试通过ID或用户名查找
|
||||
if str(user_id).isdigit():
|
||||
user = User.query.get(int(user_id))
|
||||
if not user:
|
||||
user = User.query.filter_by(username=str(user_id)).first()
|
||||
if not user:
|
||||
user = User.query.filter_by(username=str(user_id).upper()).first()
|
||||
if not user:
|
||||
user = User.query.filter_by(username=str(user_id).lower()).first()
|
||||
return getattr(user, 'real_name', None) or getattr(user, 'nickname', None) or str(user_id)
|
||||
except:
|
||||
return str(user_id)
|
||||
|
||||
def to_beijing_time(dt):
|
||||
"""转换为北京时间(+8小时)"""
|
||||
if not dt:
|
||||
return ''
|
||||
try:
|
||||
if isinstance(dt, str):
|
||||
return dt[:19]
|
||||
return (dt + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
except:
|
||||
return str(dt)[:19]
|
||||
|
||||
def set_header_row(ws, headers):
|
||||
for col, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col, value=header)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = header_alignment
|
||||
cell.border = thin_border
|
||||
|
||||
# ===== Sheet 1: 盘点差异明细 =====
|
||||
ws1 = wb.create_sheet("盘点差异明细")
|
||||
diff_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点人", "盘点时间"]
|
||||
set_header_row(ws1, diff_headers)
|
||||
|
||||
diff_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty != 0).all()
|
||||
for row_idx, draft in enumerate(diff_drafts, 2):
|
||||
mat_info = get_material_info(draft.source_table, draft.stock_id)
|
||||
ws1.cell(row=row_idx, column=1, value=mat_info['name']).border = thin_border
|
||||
ws1.cell(row=row_idx, column=2, value=mat_info['sku']).border = thin_border
|
||||
ws1.cell(row=row_idx, column=3, value=mat_info['spec']).border = thin_border
|
||||
ws1.cell(row=row_idx, column=4, value=mat_info['location']).border = thin_border
|
||||
ws1.cell(row=row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
|
||||
ws1.cell(row=row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
|
||||
ws1.cell(row=row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
|
||||
ws1.cell(row=row_idx, column=8, value=get_user_name(draft.user_id)).border = thin_border
|
||||
ws1.cell(row=row_idx, column=9, value=to_beijing_time(draft.scan_time)).border = thin_border
|
||||
|
||||
# ===== Sheet 2: 账实相符明细 =====
|
||||
ws2 = wb.create_sheet("账实相符明细")
|
||||
normal_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点人", "盘点时间"]
|
||||
set_header_row(ws2, normal_headers)
|
||||
|
||||
normal_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty == 0).all()
|
||||
for row_idx, draft in enumerate(normal_drafts, 2):
|
||||
mat_info = get_material_info(draft.source_table, draft.stock_id)
|
||||
ws2.cell(row=row_idx, column=1, value=mat_info['name']).border = thin_border
|
||||
ws2.cell(row=row_idx, column=2, value=mat_info['sku']).border = thin_border
|
||||
ws2.cell(row=row_idx, column=3, value=mat_info['spec']).border = thin_border
|
||||
ws2.cell(row=row_idx, column=4, value=mat_info['location']).border = thin_border
|
||||
ws2.cell(row=row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
|
||||
ws2.cell(row=row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
|
||||
ws2.cell(row=row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
|
||||
ws2.cell(row=row_idx, column=8, value=get_user_name(draft.user_id)).border = thin_border
|
||||
ws2.cell(row=row_idx, column=9, value=to_beijing_time(draft.scan_time)).border = thin_border
|
||||
|
||||
# ===== Sheet 3: 外借在用资产明细 =====
|
||||
ws3 = wb.create_sheet("外借在用资产明细")
|
||||
borrow_headers = ["借出单号", "借用人", "物料名称", "SKU", "规格型号", "借出总数", "已还数量", "待还数量", "借出时间", "预计归还时间"]
|
||||
set_header_row(ws3, borrow_headers)
|
||||
|
||||
# 查询未归还的借出记录
|
||||
unreturned_borrows = TransBorrow.query.filter(TransBorrow.is_returned == False).all()
|
||||
for row_idx, borrow in enumerate(unreturned_borrows, 2):
|
||||
mat_info = get_material_info(borrow.source_table, borrow.stock_id)
|
||||
total_qty = float(borrow.quantity or 0)
|
||||
returned_qty = float(borrow.returned_quantity or 0)
|
||||
pending_qty = total_qty - returned_qty
|
||||
|
||||
ws3.cell(row=row_idx, column=1, value=borrow.borrow_no or '').border = thin_border
|
||||
ws3.cell(row=row_idx, column=2, value=borrow.borrower_name or '').border = thin_border
|
||||
ws3.cell(row=row_idx, column=3, value=mat_info['name']).border = thin_border
|
||||
ws3.cell(row=row_idx, column=4, value=mat_info['sku']).border = thin_border
|
||||
ws3.cell(row=row_idx, column=5, value=mat_info['spec']).border = thin_border
|
||||
ws3.cell(row=row_idx, column=6, value=total_qty).border = thin_border
|
||||
ws3.cell(row=row_idx, column=7, value=returned_qty).border = thin_border
|
||||
ws3.cell(row=row_idx, column=8, value=pending_qty).border = thin_border
|
||||
ws3.cell(row=row_idx, column=9, value=to_beijing_time(borrow.borrow_time)).border = thin_border
|
||||
ws3.cell(row=row_idx, column=10, value='无限期' if not borrow.expected_return_time else to_beijing_time(borrow.expected_return_time)).border = thin_border
|
||||
|
||||
# 调整列宽
|
||||
for ws in [ws1, ws2, ws3]:
|
||||
for col in ws.columns:
|
||||
max_length = 0
|
||||
col_letter = col[0].column_letter
|
||||
for cell in col:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except:
|
||||
pass
|
||||
ws.column_dimensions[col_letter].width = min(max_length + 2, 30)
|
||||
|
||||
# 生成文件
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
|
||||
filename = f"盘点报告_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
return send_file(
|
||||
output,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Export Stocktake Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"message": f"导出失败: {str(e)}"}), 500
|
||||
|
||||
@ -13,6 +13,7 @@ class TransBorrow(db.Model):
|
||||
stock_id = db.Column(db.Integer)
|
||||
barcode = db.Column(db.String(100))
|
||||
quantity = db.Column(db.Numeric(19, 4))
|
||||
returned_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
borrower_name = db.Column(db.String(100))
|
||||
borrow_time = db.Column(db.DateTime, default=beijing_time)
|
||||
borrow_signature = db.Column(db.Text)
|
||||
@ -26,6 +27,9 @@ class TransBorrow(db.Model):
|
||||
remark = db.Column(db.Text)
|
||||
|
||||
def to_dict(self):
|
||||
returned_qty = float(self.returned_quantity) if self.returned_quantity is not None else 0
|
||||
total_qty = float(self.quantity) if self.quantity is not None else 0
|
||||
pending_qty = total_qty - returned_qty
|
||||
return {
|
||||
'id': self.id,
|
||||
'borrow_no': self.borrow_no,
|
||||
@ -33,7 +37,9 @@ class TransBorrow(db.Model):
|
||||
'source_table': self.source_table,
|
||||
'stock_id': self.stock_id,
|
||||
'barcode': self.barcode,
|
||||
'quantity': float(self.quantity) if self.quantity is not None else None,
|
||||
'quantity': total_qty,
|
||||
'returned_quantity': returned_qty,
|
||||
'pending_quantity': pending_qty,
|
||||
'borrower_name': self.borrower_name,
|
||||
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else None,
|
||||
'borrow_signature': self.borrow_signature,
|
||||
|
||||
@ -113,10 +113,12 @@ class TransService:
|
||||
@staticmethod
|
||||
def process_return(data, operator_name):
|
||||
"""
|
||||
还库逻辑:
|
||||
1. 恢复可用库存
|
||||
2. 更新库位 (如果有变动)
|
||||
3. 记录库管签字
|
||||
还库逻辑(支持部分归还):
|
||||
1. 校验本次归还数量不能大于待还数量
|
||||
2. 恢复可用库存(按本次归还数量)
|
||||
3. 更新库位 (如果有变动)
|
||||
4. 记录库管签字
|
||||
5. 更新归还数量和状态(部分归还/全部归还)
|
||||
"""
|
||||
items = data.get('items', [])
|
||||
signature = data.get('signature_path') # 库管签字
|
||||
@ -129,31 +131,54 @@ class TransService:
|
||||
try:
|
||||
for item in items:
|
||||
borrow_id = item.get('id')
|
||||
# 前端传入的本次归还数量
|
||||
return_qty = float(item.get('return_qty', 0))
|
||||
# 前端如果没有填 return_location,应该在提交前处理好,或者这里做 fallback
|
||||
# 这里假设前端传来的 return_location 就是最终要保存的库位
|
||||
final_location = item.get('return_location')
|
||||
|
||||
record = TransBorrow.query.with_for_update().get(borrow_id)
|
||||
if not record or record.is_returned:
|
||||
if not record:
|
||||
continue
|
||||
|
||||
# 计算待还数量
|
||||
returned_qty = float(record.returned_quantity) if record.returned_quantity else 0
|
||||
total_qty = float(record.quantity) if record.quantity else 0
|
||||
pending_qty = total_qty - returned_qty
|
||||
|
||||
# 校验归还数量
|
||||
if return_qty <= 0:
|
||||
raise ValueError(f"归还数量必须大于0")
|
||||
if return_qty > pending_qty:
|
||||
raise ValueError(f"本次归还数量({return_qty})不能大于待还数量({pending_qty})")
|
||||
|
||||
ModelClass = model_map.get(record.source_table)
|
||||
if ModelClass:
|
||||
stock = ModelClass.query.with_for_update().get(record.stock_id)
|
||||
if stock:
|
||||
# 1. 恢复可用库存
|
||||
stock.available_quantity = float(stock.available_quantity) + float(record.quantity)
|
||||
# 1. 恢复可用库存(按本次归还数量)
|
||||
stock.available_quantity = float(stock.available_quantity) + return_qty
|
||||
|
||||
# 2. 更新库位 (如果提供了有效值)
|
||||
if final_location:
|
||||
stock.warehouse_location = final_location
|
||||
|
||||
# 3. 更新借用单状态
|
||||
# 3. 更新归还数量
|
||||
new_returned_qty = returned_qty + return_qty
|
||||
record.returned_quantity = new_returned_qty
|
||||
|
||||
# 4. 更新状态
|
||||
if new_returned_qty >= total_qty:
|
||||
record.is_returned = True
|
||||
record.status = 'returned'
|
||||
else:
|
||||
record.is_returned = False
|
||||
record.status = 'partial_returned'
|
||||
|
||||
record.return_time = datetime.now()
|
||||
record.return_operator = operator_name
|
||||
record.return_signature = signature
|
||||
if final_location:
|
||||
record.return_location = final_location
|
||||
|
||||
db.session.commit()
|
||||
|
||||
@ -349,14 +349,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { getAllStock } from '@/api/inbound/stock'
|
||||
import QrScanner from '@/components/QrScanner/index.vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close } from '@element-plus/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import * as XLSX from 'xlsx'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const currentUser = userStore.username || 'admin'
|
||||
@ -419,6 +418,51 @@ const currentItem = ref<StockItem | null>(null)
|
||||
const inputQty = ref<number | undefined>(undefined)
|
||||
const qtyInputRef = ref()
|
||||
|
||||
// ★ 新增: 多人协同心跳刷新定时器
|
||||
let syncTimer: any = null
|
||||
|
||||
// ★ 新增: 静默刷新数据(不弹loading)
|
||||
const syncData = async () => {
|
||||
try {
|
||||
// 仅刷新差异列表数据,不显示loading
|
||||
const res = await getAllStock()
|
||||
if (!res) return
|
||||
|
||||
const processItem = (item: any, type: string) => {
|
||||
const stock = parseFloat(item.stock_quantity || item.qty_stock || 0)
|
||||
if (stock <= 0) return
|
||||
const name = item.material_name || item.product_name || item.name || '未知物品'
|
||||
const uuid = item.uuid || item.sku || ''
|
||||
const isScanned = scannedMap.value.has(uuid)
|
||||
return {
|
||||
...item,
|
||||
name: name,
|
||||
standard: item.spec_model || item.standard || item.model || '',
|
||||
sku: item.sku || '',
|
||||
uuid: uuid,
|
||||
bar_code: item.bar_code || item.barcode || '',
|
||||
qty_stock: stock,
|
||||
qty_actual: isScanned ? scannedMap.value.get(uuid)! : 0,
|
||||
scanned: isScanned,
|
||||
uniqueKey: `${type}_${item.id}`,
|
||||
source_table: typeToSourceTable(type),
|
||||
stock_id: item.id
|
||||
}
|
||||
}
|
||||
|
||||
const list: StockItem[] = []
|
||||
if (res.materials) res.materials.forEach((i: any) => { const item = processItem(i, 'material'); if (item) list.push(item) })
|
||||
if (res.semis) res.semis.forEach((i: any) => { const item = processItem(i, 'semi'); if (item) list.push(item) })
|
||||
if (res.products) res.products.forEach((i: any) => { const item = processItem(i, 'product'); if (item) list.push(item) })
|
||||
|
||||
// 静默更新数据(不触发loading)
|
||||
allData.value = list
|
||||
await fetchBorrowedQuantities(list)
|
||||
} catch (e) {
|
||||
// 静默失败,不弹错误
|
||||
}
|
||||
}
|
||||
|
||||
const api = {
|
||||
getDrafts: (sessionId?: string) => request({
|
||||
url: '/v1/inbound/stock/draft/list',
|
||||
@ -449,10 +493,17 @@ const api = {
|
||||
params: {}
|
||||
}),
|
||||
// ★ 新增: 单条库存调整
|
||||
adjustStock: (draftId: number, remark: string) => request({
|
||||
adjustStock: (draftId: number, stockId: number, diffQty: number, sourceTable: string, remark: string) => request({
|
||||
url: '/v1/inbound/stock/adjust',
|
||||
method: 'post',
|
||||
data: { draft_id: draftId, operator_name: currentUser, remark: remark }
|
||||
data: {
|
||||
draft_id: draftId,
|
||||
stock_id: stockId, // 库存项ID
|
||||
diff_qty: diffQty, // 差异数量(支持无草稿模式)
|
||||
source_table: sourceTable, // 必须:stock_buy / stock_semi / stock_product
|
||||
operator_name: currentUser,
|
||||
remark: remark
|
||||
}
|
||||
}),
|
||||
// ★ 保留清除功能(用于兼容性)
|
||||
clearDraft: () => request({
|
||||
@ -492,6 +543,18 @@ async function fetchBorrowedQuantities(items: StockItem[]): Promise<void> {
|
||||
|
||||
onMounted(async () => {
|
||||
await checkServerDraft()
|
||||
// ★ 启动多人协同心跳轮询(每5秒静默刷新)
|
||||
syncTimer = setInterval(() => {
|
||||
syncData()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
// ★ 新增: 组件卸载时清除定时器
|
||||
onUnmounted(() => {
|
||||
if (syncTimer) {
|
||||
clearInterval(syncTimer)
|
||||
syncTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
const checkServerDraft = async () => {
|
||||
@ -748,74 +811,39 @@ const closeOverlays = () => {
|
||||
showQtyDialog.value = false
|
||||
}
|
||||
|
||||
// --- 导出 Excel 逻辑 ---
|
||||
const exportToExcel = () => {
|
||||
// --- 导出 Excel 逻辑 (调用后端API) ---
|
||||
const exportToExcel = async () => {
|
||||
try {
|
||||
// 1. 已盘点 Sheet
|
||||
const scannedData = allData.value.filter(i => i.scanned).map(item => {
|
||||
const key = item.source_table && item.stock_id ? `${item.source_table}_${item.stock_id}` : ''
|
||||
const borrowedQty = borrowedQuantities.value[key] || 0
|
||||
const actualTotal = item.qty_actual + borrowedQty
|
||||
const diff = actualTotal - item.qty_stock
|
||||
const result = diff === 0 ? '正常' : diff < 0 ? '盘亏/差异' : '盘盈'
|
||||
return {
|
||||
'物品名称': item.name,
|
||||
'类型': item.type || item.material_type || '-',
|
||||
'类别': item.category || '-',
|
||||
'规格型号': item.spec_model || item.standard || '-', // ★ 双重保险
|
||||
'SKU': item.sku,
|
||||
'批次/SN': item.serial_number || item.batch_no || '-',
|
||||
'单位': item.unit || '个',
|
||||
'单价': item.price || item.unit_price || 0,
|
||||
'账面库存': parseFloat(item.qty_stock as any),
|
||||
'实盘数量': item.qty_actual,
|
||||
'借出未还数量': borrowedQty,
|
||||
'盘点结果': result,
|
||||
'差异数': diff
|
||||
// ===== 调试代码 =====
|
||||
console.warn('---- 触发了导出 Excel ----');
|
||||
// ===== 调试结束 =====
|
||||
|
||||
ElMessage.info('正在生成盘点报告,请稍候...');
|
||||
// 使用项目封装的 request 发送请求,确保自动携带 JWT Token
|
||||
const res: any = await request({
|
||||
url: '/v1/inbound/stock/export-stocktake',
|
||||
method: 'get',
|
||||
responseType: 'blob' as any, // 核心:接收二进制文件流
|
||||
headers: {
|
||||
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// 2. 未盘点 Sheet
|
||||
const missingData = allData.value.filter(i => !i.scanned).map(item => {
|
||||
const key = item.source_table && item.stock_id ? `${item.source_table}_${item.stock_id}` : ''
|
||||
const borrowedQty = borrowedQuantities.value[key] || 0
|
||||
return {
|
||||
'物品名称': item.name,
|
||||
'类型': item.type || item.material_type || '-',
|
||||
'类别': item.category || '-',
|
||||
'规格型号': item.spec_model || item.standard || '-', // ★ 双重保险
|
||||
'SKU': item.sku,
|
||||
'批次/SN': item.serial_number || item.batch_no || '-',
|
||||
'单位': item.unit || '个',
|
||||
'单价': item.price || item.unit_price || 0,
|
||||
'账面库存': parseFloat(item.qty_stock as any),
|
||||
'借出未还数量': borrowedQty,
|
||||
'状态': '未盘点'
|
||||
}
|
||||
})
|
||||
|
||||
const wb = XLSX.utils.book_new()
|
||||
const ws1 = XLSX.utils.json_to_sheet(scannedData)
|
||||
const ws2 = XLSX.utils.json_to_sheet(missingData)
|
||||
|
||||
const wscols = [
|
||||
{wch: 20}, {wch: 10}, {wch: 10}, {wch: 15},
|
||||
{wch: 15}, {wch: 15}, {wch: 5}, {wch: 8},
|
||||
{wch: 8}, {wch: 8}, {wch: 8}, {wch: 8}, {wch: 8}
|
||||
]
|
||||
ws1['!cols'] = wscols
|
||||
ws2['!cols'] = wscols
|
||||
|
||||
XLSX.utils.book_append_sheet(wb, ws1, "已盘点明细")
|
||||
XLSX.utils.book_append_sheet(wb, ws2, "未盘点明细")
|
||||
|
||||
const fileName = `库存盘点报告_${new Date().toISOString().slice(0,10)}.xlsx`
|
||||
XLSX.writeFile(wb, fileName)
|
||||
|
||||
ElMessage.success('Excel 报表已生成')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('导出失败,请检查 xlsx 插件是否安装')
|
||||
// 触发静默下载
|
||||
const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
link.download = `盘点差异报告_${dateStr}.xlsx`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
ElMessage.success('报告导出成功');
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error);
|
||||
ElMessage.error('导出失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
@ -921,6 +949,12 @@ const openVarianceDialog = async () => {
|
||||
// ★ 新增: 确认平账
|
||||
const handleAdjust = async (row: any) => {
|
||||
try {
|
||||
// ===== 调试代码 =====
|
||||
console.warn('---- 准备平账参数检查 ----');
|
||||
console.warn('当前点击行的完整数据:', row);
|
||||
console.warn(`将要发送的 draftId: ${row.id}, stockId: ${row.stock_id}, sourceTable: ${row.source_table}`);
|
||||
// ===== 调试结束 =====
|
||||
|
||||
await ElMessageBox.confirm(
|
||||
`确定要对 "${row.uuid}" 进行平账调整吗?\n\n差异: ${row.diff_qty > 0 ? '盘盈 +' : '盘亏 '}${row.diff_qty}`,
|
||||
'确认平账',
|
||||
@ -929,11 +963,12 @@ const handleAdjust = async (row: any) => {
|
||||
|
||||
const remark = `盘点差异调整 - ${row.diff_qty > 0 ? '盘盈入库' : '盘亏出库'}`
|
||||
|
||||
const res: any = await api.adjustStock(row.id, remark)
|
||||
const res: any = await api.adjustStock(row.id, row.stock_id, row.diff_qty, row.source_table || 'stock_buy', remark)
|
||||
|
||||
ElMessage.success(res.message || '调整成功')
|
||||
|
||||
// 刷新差异列表
|
||||
// 刷新数据并重新打开差异列表
|
||||
await loadData()
|
||||
await openVarianceDialog()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') ElMessage.error(e?.message || '操作失败')
|
||||
|
||||
@ -97,10 +97,17 @@
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled="isIndefinite"
|
||||
:disabled-date="disabledDate"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="24">
|
||||
<el-checkbox v-model="isIndefinite" @change="handleIndefiniteChange">
|
||||
无限期/长期借用(不设归还期限)
|
||||
</el-checkbox>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="备注说明" prop="remark">
|
||||
@ -192,7 +199,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, nextTick, onUnmounted } from 'vue'
|
||||
import { ref, reactive, nextTick, onUnmounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
|
||||
import QrScanner from '@/components/QrScanner/index.vue'
|
||||
@ -246,13 +253,21 @@ const form = reactive({
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
const rules = computed(() => ({
|
||||
borrower_name: [
|
||||
{ required: true, message: '请输入借用人姓名', trigger: 'blur' }
|
||||
],
|
||||
expected_return_time: [
|
||||
{ required: true, message: '请选择预计归还日期', trigger: 'change' }
|
||||
{ required: !isIndefinite.value, message: '请选择预计归还日期', trigger: 'change' }
|
||||
]
|
||||
}))
|
||||
|
||||
const isIndefinite = ref(false)
|
||||
|
||||
const handleIndefiniteChange = (val: boolean) => {
|
||||
if (val) {
|
||||
form.expected_return_time = ''
|
||||
}
|
||||
}
|
||||
|
||||
const disabledDate = (time: Date) => {
|
||||
@ -352,6 +367,7 @@ const clearAll = () => {
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
barcodeInput.value = ''
|
||||
isIndefinite.value = false
|
||||
})
|
||||
}
|
||||
|
||||
@ -362,7 +378,8 @@ const submitForm = async () => {
|
||||
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) {
|
||||
ElMessage.error('请填写完整的必填项(姓名、归还日期)')
|
||||
const requiredMsg = isIndefinite.value ? '请填写完整的必填项(姓名)' : '请填写完整的必填项(姓名、归还日期)'
|
||||
ElMessage.error(requiredMsg)
|
||||
return
|
||||
}
|
||||
|
||||
@ -378,12 +395,18 @@ const submitForm = async () => {
|
||||
const uploadRes = await uploadFile(signatureFile.value)
|
||||
const signatureUrl = uploadRes.data.url
|
||||
|
||||
// 处理无限期借用:如果选择了无限期,将预计归还时间置为空
|
||||
const submitData = {
|
||||
...form,
|
||||
expected_return_time: isIndefinite.value ? null : form.expected_return_time
|
||||
}
|
||||
|
||||
await request({
|
||||
url: '/v1/transactions/borrow',
|
||||
method: 'post',
|
||||
data: {
|
||||
items: cartItems.value,
|
||||
...form,
|
||||
...submitData,
|
||||
signature_path: signatureUrl
|
||||
}
|
||||
})
|
||||
@ -396,6 +419,7 @@ const submitForm = async () => {
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
showCamera.value = false
|
||||
isIndefinite.value = false
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
|
||||
@ -22,6 +22,22 @@
|
||||
<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('sku')" prop="sku" label="SKU" width="120" show-overflow-tooltip />
|
||||
<el-table-column label="借出数量" width="90" align="center">
|
||||
<template #default="{row}">
|
||||
<el-tag type="info">{{ row.quantity }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已还数量" width="90" align="center">
|
||||
<template #default="{row}">
|
||||
<el-tag type="success">{{ row.returned_quantity || 0 }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="待还数量" width="90" 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 v-if="hasColumnPermission('borrow_time')" prop="borrow_time" label="借出时间" width="160" sortable />
|
||||
|
||||
<el-table-column v-if="hasColumnPermission('expected_return_time') || hasColumnPermission('return_time')" label="归还时间 / 预计" min-width="200">
|
||||
@ -30,6 +46,9 @@
|
||||
<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>
|
||||
<el-tag type="info" size="small">预计</el-tag>
|
||||
{{ formatExpectedTime(row.expected_return_time).text }}
|
||||
@ -42,9 +61,9 @@
|
||||
|
||||
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.status==='returned'?'success':'warning'">
|
||||
{{ row.status==='returned'?'已还':'借出中' }}
|
||||
</el-tag>
|
||||
<el-tag v-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>
|
||||
|
||||
@ -147,7 +166,7 @@ const handlePage = (val: number) => {
|
||||
|
||||
// ★ 新增:格式化预计归还时间及倒计时逻辑
|
||||
const formatExpectedTime = (timeStr: string) => {
|
||||
if (!timeStr) return { text: '-', diffText: '', cssClass: '' }
|
||||
if (!timeStr) return { text: '无限期', diffText: '', cssClass: '' }
|
||||
|
||||
// 后端返回的可能是 YYYY-MM-DD HH:mm:ss,我们只取日期部分比较
|
||||
const expected = dayjs(timeStr).startOf('day')
|
||||
|
||||
@ -47,6 +47,33 @@
|
||||
<el-table :data="returnList" border stripe style="width: 100%">
|
||||
<el-table-column v-if="hasColumnPermission('borrower_name')" prop="borrower_name" label="借用人" width="90" show-overflow-tooltip />
|
||||
<el-table-column v-if="hasColumnPermission('sku')" 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 type="warning">{{ row.pending_quantity }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="本次归还数" width="120" align="center">
|
||||
<template #default="{row}">
|
||||
<el-input-number
|
||||
v-model="row.return_qty"
|
||||
:min="1"
|
||||
:max="row.pending_quantity"
|
||||
size="small"
|
||||
:disabled="!userStore.hasPermission('op_return:operation')"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="hasColumnPermission('return_location')" label="归还库位(可改)" min-width="160">
|
||||
<template #default="{row}">
|
||||
@ -253,6 +280,8 @@ const scanItem = async () => {
|
||||
const item = res.data
|
||||
// 默认将归还库位填为当前库位
|
||||
item.return_location = item.current_location || ''
|
||||
// 默认归还数量为待还数量
|
||||
item.return_qty = item.pending_quantity
|
||||
|
||||
returnList.value.push(item)
|
||||
barcode.value = ''
|
||||
|
||||
Reference in New Issue
Block a user