1393 lines
58 KiB
Python
1393 lines
58 KiB
Python
from flask import Blueprint, jsonify, request, send_file, current_app
|
||
from app.extensions import db, beijing_time
|
||
from datetime import datetime, timedelta
|
||
from flask_jwt_extended import jwt_required, get_jwt, get_jwt_identity
|
||
from app.utils.decorators import permission_required
|
||
from sqlalchemy.orm import joinedload
|
||
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.system import SysUser
|
||
except ImportError:
|
||
SysUser = 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=None):
|
||
"""规范化 user_id,确保是有效字符串"""
|
||
# 优先使用传入的 user_id,否则从 JWT 获取
|
||
if user_id and isinstance(user_id, str) and len(user_id) <= 100:
|
||
return user_id.strip()
|
||
# 从 JWT 获取当前用户
|
||
try:
|
||
return get_jwt().get('display_name') or get_jwt_identity()
|
||
except:
|
||
return 'unknown'
|
||
|
||
|
||
def get_stock_model(source_table):
|
||
"""根据source_table获取对应的库存模型"""
|
||
if source_table == 'stock_buy':
|
||
return StockBuy
|
||
elif source_table == 'stock_semi':
|
||
return StockSemi
|
||
elif source_table == 'stock_product':
|
||
return StockProduct
|
||
return None
|
||
|
||
from app.services.print.network_print_service import NetworkPrintService
|
||
|
||
bp = Blueprint('stock_ops', __name__)
|
||
|
||
|
||
# ============================================================
|
||
# 辅助函数:获取库存记录
|
||
# ============================================================
|
||
def get_stock_record(source_table, stock_id):
|
||
"""根据库存类型和ID获取库存记录"""
|
||
if source_table == 'stock_buy' and StockBuy:
|
||
return StockBuy.query.get(stock_id)
|
||
elif source_table == 'stock_semi' and StockSemi:
|
||
return StockSemi.query.get(stock_id)
|
||
elif source_table == 'stock_product' and StockProduct:
|
||
return StockProduct.query.get(stock_id)
|
||
return None
|
||
|
||
|
||
def get_stock_info(uuid_or_barcode):
|
||
"""
|
||
根据 uuid 或 barcode 查询库存信息
|
||
返回: (item, source_table, stock_id)
|
||
"""
|
||
# 1. 成品
|
||
if StockProduct:
|
||
item = StockProduct.query.filter(
|
||
db.or_(StockProduct.barcode == uuid_or_barcode, StockProduct.sku == uuid_or_barcode)
|
||
).first()
|
||
if item:
|
||
return (item, 'stock_product', item.id)
|
||
|
||
# 2. 半成品
|
||
if StockSemi:
|
||
item = StockSemi.query.filter(
|
||
db.or_(StockSemi.barcode == uuid_or_barcode, StockSemi.sku == uuid_or_barcode)
|
||
).first()
|
||
if item:
|
||
return (item, 'stock_semi', item.id)
|
||
|
||
# 3. 采购件
|
||
if StockBuy:
|
||
item = StockBuy.query.filter(
|
||
db.or_(StockBuy.barcode == uuid_or_barcode, StockBuy.sku == uuid_or_barcode)
|
||
).first()
|
||
if item:
|
||
return (item, 'stock_buy', item.id)
|
||
|
||
return (None, None, None)
|
||
|
||
|
||
@bp.route('/all', methods=['GET'])
|
||
@jwt_required()
|
||
def get_all_stock():
|
||
"""
|
||
获取所有库存 > 0 的物品
|
||
"""
|
||
try:
|
||
# 1. 采购件
|
||
materials = []
|
||
if StockBuy:
|
||
materials = StockBuy.query.filter(StockBuy.stock_quantity > 0).all()
|
||
|
||
# 2. 半成品
|
||
semis = []
|
||
if StockSemi:
|
||
try:
|
||
semis = StockSemi.query.filter(StockSemi.stock_quantity > 0).all()
|
||
except Exception:
|
||
semis = []
|
||
|
||
# 3. 成品
|
||
products = []
|
||
if StockProduct:
|
||
try:
|
||
products = StockProduct.query.filter(StockProduct.stock_quantity > 0).all()
|
||
except Exception:
|
||
products = []
|
||
|
||
return jsonify({
|
||
"materials": [item.to_dict() for item in materials],
|
||
"semis": [item.to_dict() for item in semis],
|
||
"products": [item.to_dict() for item in products]
|
||
}), 200
|
||
except Exception as e:
|
||
print(f"Error: {e}")
|
||
return jsonify({"message": f"查询库存失败: {str(e)}"}), 500
|
||
|
||
|
||
# ==============================================================================
|
||
# 分页库存查询接口(服务端分页,出库/盘点/借用模块共用)
|
||
# ==============================================================================
|
||
@bp.route('/list', methods=['GET'])
|
||
@jwt_required()
|
||
def get_stock_list():
|
||
"""
|
||
分页获取库存列表(stock_quantity > 0)
|
||
参数:
|
||
page - 页码(默认 1)
|
||
pageSize - 每页条数(默认 20)
|
||
keyword - 搜索关键字(模糊匹配名称/规格/SKU)
|
||
"""
|
||
try:
|
||
page = request.args.get('page', 1, type=int)
|
||
pageSize = request.args.get('pageSize', 20, type=int)
|
||
keyword = request.args.get('keyword', '', type=str).strip()
|
||
|
||
if page < 1:
|
||
page = 1
|
||
if pageSize < 1 or pageSize > 200:
|
||
pageSize = 20
|
||
|
||
all_items = []
|
||
|
||
# 1. 采购件
|
||
if StockBuy:
|
||
q = StockBuy.query.filter(StockBuy.stock_quantity > 0)
|
||
if keyword:
|
||
q = q.filter(
|
||
db.or_(
|
||
StockBuy.base.has(MaterialBase.name.ilike(f'%{keyword}%')),
|
||
StockBuy.base.has(MaterialBase.spec_model.ilike(f'%{keyword}%')),
|
||
StockBuy.sku.ilike(f'%{keyword}%')
|
||
)
|
||
)
|
||
rows = q.all()
|
||
for item in rows:
|
||
d = item.to_dict()
|
||
d['stock_type'] = 'material'
|
||
d['type'] = 'material'
|
||
d['typeLabel'] = '采购件'
|
||
d['name'] = d.get('material_name', d.get('name', ''))
|
||
d['standard'] = d.get('spec_model', d.get('standard', ''))
|
||
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
|
||
all_items.append(d)
|
||
|
||
# 2. 半成品
|
||
if StockSemi:
|
||
try:
|
||
q = StockSemi.query.filter(StockSemi.stock_quantity > 0)
|
||
if keyword:
|
||
q = q.filter(
|
||
db.or_(
|
||
StockSemi.base.has(MaterialBase.name.ilike(f'%{keyword}%')),
|
||
StockSemi.base.has(MaterialBase.spec_model.ilike(f'%{keyword}%')),
|
||
StockSemi.sku.ilike(f'%{keyword}%')
|
||
)
|
||
)
|
||
rows = q.all()
|
||
for item in rows:
|
||
d = item.to_dict()
|
||
d['stock_type'] = 'semi'
|
||
d['type'] = 'semi'
|
||
d['typeLabel'] = '半成品'
|
||
d['name'] = d.get('material_name', d.get('name', ''))
|
||
d['standard'] = d.get('spec_model', d.get('standard', ''))
|
||
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
|
||
all_items.append(d)
|
||
except Exception:
|
||
pass
|
||
|
||
# 3. 成品
|
||
if StockProduct:
|
||
try:
|
||
q = StockProduct.query.filter(StockProduct.stock_quantity > 0)
|
||
if keyword:
|
||
q = q.filter(
|
||
db.or_(
|
||
StockProduct.product_name.ilike(f'%{keyword}%'),
|
||
StockProduct.spec_model.ilike(f'%{keyword}%'),
|
||
StockProduct.sku.ilike(f'%{keyword}%')
|
||
)
|
||
)
|
||
rows = q.all()
|
||
for item in rows:
|
||
d = item.to_dict()
|
||
d['stock_type'] = 'product'
|
||
d['type'] = 'product'
|
||
d['typeLabel'] = '成品'
|
||
d['name'] = d.get('product_name', d.get('name', ''))
|
||
d['standard'] = d.get('spec_model', d.get('standard', ''))
|
||
d['available_quantity'] = d.get('qty_available', d.get('available_quantity', 0))
|
||
all_items.append(d)
|
||
except Exception:
|
||
pass
|
||
|
||
total = len(all_items)
|
||
start = (page - 1) * pageSize
|
||
end = start + pageSize
|
||
paged = all_items[start:end]
|
||
|
||
return jsonify({
|
||
'msg': '获取成功',
|
||
'data': {
|
||
'list': paged,
|
||
'total': total,
|
||
'page': page,
|
||
'pageSize': pageSize
|
||
}
|
||
}), 200
|
||
|
||
except Exception as e:
|
||
current_app.logger.error(f"Get Stock List Failed: {str(e)}")
|
||
return jsonify({'msg': f'获取库存列表失败: {str(e)}'}), 500
|
||
|
||
|
||
# --- 草稿箱接口 ---
|
||
|
||
@bp.route('/draft/list', methods=['GET'])
|
||
@permission_required('inventory_stocktake')
|
||
def get_drafts():
|
||
"""
|
||
获取盘点草稿列表
|
||
支持分页、搜索(SKU)和排序
|
||
"""
|
||
# 获取分页参数
|
||
page = request.args.get('page', 1, type=int)
|
||
limit = request.args.get('limit', 20, type=int)
|
||
keyword = request.args.get('keyword', '', type=str)
|
||
session_id = request.args.get('session_id')
|
||
|
||
query = StocktakeDraft.query
|
||
|
||
if session_id:
|
||
query = query.filter_by(session_id=session_id)
|
||
|
||
# 先执行查询获取所有记录
|
||
drafts = query.all()
|
||
|
||
items = []
|
||
|
||
for draft in drafts:
|
||
# 获取 SKU 信息
|
||
sku = ''
|
||
material_name = ''
|
||
spec_model = ''
|
||
|
||
# 根据source_table获取对应的库存记录
|
||
stock_model = get_stock_model(draft.source_table)
|
||
if stock_model and draft.stock_id:
|
||
stock = stock_model.query.get(draft.stock_id)
|
||
if stock:
|
||
sku = getattr(stock, 'sku', None) or getattr(stock, 'SKU', '')
|
||
|
||
# 如果有关键词,进行 SKU 模糊匹配
|
||
if keyword and sku:
|
||
if keyword.lower() not in sku.lower():
|
||
continue
|
||
|
||
# 获取物料基础信息
|
||
base_id = getattr(stock, 'base_id', None)
|
||
if base_id:
|
||
material = MaterialBase.query.get(base_id)
|
||
if material:
|
||
material_name = material.name
|
||
spec_model = material.spec_model
|
||
|
||
item = draft.to_dict()
|
||
item['sku'] = sku
|
||
item['material_name'] = material_name
|
||
item['spec_model'] = spec_model
|
||
items.append(item)
|
||
|
||
# 按 SKU 升序排序
|
||
items.sort(key=lambda x: (x['sku'] or '').lower())
|
||
|
||
# 手动分页
|
||
total = len(items)
|
||
start = (page - 1) * limit
|
||
end = start + limit
|
||
paginated_items = items[start:end]
|
||
|
||
return jsonify({
|
||
'items': paginated_items,
|
||
'total': total,
|
||
'page': page,
|
||
'limit': limit
|
||
}), 200
|
||
|
||
|
||
@bp.route('/draft/add', methods=['POST'])
|
||
@permission_required('inventory_stocktake:operation')
|
||
def add_draft():
|
||
"""
|
||
扫码同步 (支持更新数量)
|
||
如果 session_id 不存在则创建新的会话
|
||
|
||
差异计算逻辑调整:
|
||
- adjusted_stock_qty = 账面总库存 - 借出未还数量
|
||
- diff_qty = 实盘数量 - adjusted_stock_qty
|
||
"""
|
||
try:
|
||
data = request.json
|
||
user_id = _normalize_user_id()
|
||
uuid = data.get('uuid')
|
||
quantity = float(data.get('quantity', 1))
|
||
session_id = data.get('session_id')
|
||
# ★ 新增: 提取备注字段
|
||
remark = data.get('remark')
|
||
|
||
if not uuid:
|
||
return jsonify({"message": "UUID不能为空"}), 400
|
||
|
||
# 如果没有 session_id,创建新的
|
||
if not session_id:
|
||
session_id = f"STK-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid_module.uuid4().hex[:6]}"
|
||
|
||
# 获取库存信息
|
||
item, source_table, stock_id = get_stock_info(uuid)
|
||
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(db.func.coalesce(TransBorrow.quantity, 0) - db.func.coalesce(TransBorrow.returned_quantity, 0))
|
||
).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()
|
||
|
||
if draft:
|
||
# 如果已存在,更新数量和时间
|
||
draft.quantity = quantity
|
||
draft.scan_time = datetime.now()
|
||
draft.stock_qty = adjusted_stock_qty
|
||
draft.diff_qty = quantity - adjusted_stock_qty
|
||
draft.source_table = source_table
|
||
draft.stock_id = stock_id
|
||
# ★ 新增: 保存备注
|
||
if remark is not None:
|
||
draft.remark = remark.strip() if isinstance(remark, str) else remark
|
||
else:
|
||
# 如果不存在,创建新的
|
||
draft = StocktakeDraft(
|
||
user_id=user_id,
|
||
uuid=uuid,
|
||
quantity=quantity,
|
||
session_id=session_id,
|
||
stock_qty=adjusted_stock_qty,
|
||
diff_qty=quantity - adjusted_stock_qty,
|
||
source_table=source_table,
|
||
stock_id=stock_id,
|
||
# ★ 新增: 保存备注
|
||
remark=remark.strip() if isinstance(remark, str) and remark else (remark if remark else None)
|
||
)
|
||
db.session.add(draft)
|
||
|
||
db.session.commit()
|
||
return jsonify({
|
||
"message": "Saved",
|
||
"session_id": session_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}")
|
||
db.session.rollback()
|
||
return jsonify({"message": str(e)}), 500
|
||
|
||
|
||
@bp.route('/draft/clear', methods=['POST'])
|
||
@permission_required('inventory_stocktake:operation')
|
||
def clear_draft():
|
||
"""
|
||
清除盘点草稿
|
||
支持清除指定 session_id 的记录,或清除所有记录
|
||
"""
|
||
data = request.json
|
||
session_id = data.get('session_id')
|
||
|
||
try:
|
||
query = StocktakeDraft.query
|
||
|
||
if session_id:
|
||
# 清除指定会话
|
||
query = query.filter_by(session_id=session_id)
|
||
|
||
count = query.delete()
|
||
db.session.commit()
|
||
|
||
return jsonify({"message": f"已清除 {count} 条记录", "count": count}), 200
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
return jsonify({"message": str(e)}), 500
|
||
|
||
|
||
@bp.route('/draft/start-new', methods=['POST'])
|
||
@permission_required('inventory_stocktake:operation')
|
||
def start_new_session():
|
||
"""
|
||
开始新一轮盘点
|
||
清空整张草稿表,返回新的 session_id
|
||
"""
|
||
try:
|
||
# 清空整张草稿表
|
||
deleted_count = StocktakeDraft.query.delete()
|
||
db.session.commit()
|
||
|
||
# 生成新的 session_id
|
||
new_session_id = f"STK-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid_module.uuid4().hex[:6]}"
|
||
|
||
return jsonify({
|
||
"message": f"已清除 {deleted_count} 条旧记录",
|
||
"session_id": new_session_id,
|
||
"cleared_count": deleted_count
|
||
}), 200
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
return jsonify({"message": str(e)}), 500
|
||
|
||
|
||
# --- 盘点结束与差异报告 ---
|
||
|
||
@bp.route('/finish', methods=['POST'])
|
||
@permission_required('inventory_stocktake:operation')
|
||
def finish_stocktake():
|
||
"""
|
||
结束盘点
|
||
直接返回成功,前端会跳转到差异列表
|
||
草稿数据保留在表中
|
||
"""
|
||
return jsonify({
|
||
"message": "盘点已结束",
|
||
"code": 200
|
||
}), 200
|
||
|
||
|
||
@bp.route('/variance-report', methods=['GET'])
|
||
@permission_required('inventory_stocktake')
|
||
def get_variance_report():
|
||
"""
|
||
获取盘点差异报告
|
||
返回所有有差异的记录(diff_qty != 0)
|
||
"""
|
||
session_id = request.args.get('session_id')
|
||
|
||
try:
|
||
query = StocktakeDraft.query
|
||
|
||
if session_id:
|
||
query = query.filter_by(session_id=session_id)
|
||
|
||
# 只返回有差异的记录
|
||
drafts = query.filter(StocktakeDraft.diff_qty != 0).order_by(
|
||
StocktakeDraft.scan_time.desc(),
|
||
StocktakeDraft.diff_qty.desc()
|
||
).all()
|
||
|
||
# 补充库存详情
|
||
result = []
|
||
for draft in drafts:
|
||
draft_dict = draft.to_dict()
|
||
|
||
# 获取库存详情
|
||
if draft.stock_id and draft.source_table:
|
||
stock = get_stock_record(draft.source_table, draft.stock_id)
|
||
if stock:
|
||
draft_dict['stock_name'] = getattr(stock, 'material_name', None) or \
|
||
getattr(stock, 'product_name', None) or ''
|
||
draft_dict['stock_spec'] = getattr(stock, 'spec_model', '') or \
|
||
getattr(stock, 'standard', '') or ''
|
||
draft_dict['stock_location'] = getattr(stock, 'warehouse_location', '') or ''
|
||
draft_dict['stock_unit'] = getattr(stock, 'unit', '个')
|
||
|
||
result.append(draft_dict)
|
||
|
||
return jsonify({
|
||
"list": result,
|
||
"total": len(result)
|
||
}), 200
|
||
except Exception as e:
|
||
print(f"Error: {e}")
|
||
return jsonify({"message": str(e)}), 500
|
||
|
||
|
||
# --- 单条库存调整 (手动平账) ---
|
||
|
||
@bp.route('/adjust', methods=['POST'])
|
||
@permission_required('inventory_stocktake:operation')
|
||
def adjust_stock():
|
||
"""
|
||
单条库存调整接口
|
||
接收指定的草稿 ID,执行以下操作:
|
||
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 and not stock_id:
|
||
return jsonify({"message": "draft_id 或 stock_id 不能同时为空"}), 400
|
||
|
||
try:
|
||
# 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. 获取并校验真实的库存记录
|
||
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
|
||
new_avail_qty = float(stock.available_quantity or 0) + diff_qty
|
||
action_type = '盘盈入库'
|
||
elif diff_qty < 0:
|
||
# 盘亏:减少库存
|
||
abs_diff = abs(diff_qty)
|
||
current_avail = float(stock.available_quantity or 0)
|
||
if current_avail < abs_diff:
|
||
return jsonify({
|
||
"message": f"可用库存不足,当前可用: {current_avail},需要减少: {abs_diff}"
|
||
}), 400
|
||
|
||
new_stock_qty = float(stock.stock_quantity or 0) - abs_diff
|
||
new_avail_qty = current_avail - abs_diff
|
||
action_type = '盘亏出库'
|
||
else:
|
||
return jsonify({"message": "差异为0,无需调整"}), 400
|
||
|
||
# 5. 执行库存调整
|
||
stock.stock_quantity = new_stock_qty
|
||
stock.available_quantity = new_avail_qty
|
||
|
||
# 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')}-{adj_no_suffix}",
|
||
sku=stock.sku,
|
||
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,
|
||
outbound_type=action_type, # 盘盈入库 / 盘亏出库
|
||
consumer_name='盘点调整',
|
||
operator_name=operator_name,
|
||
remark=f"{action_type} - 盘点差异调整,备注: {remark}",
|
||
outbound_time=datetime.now()
|
||
)
|
||
db.session.add(trans_record)
|
||
|
||
# 7. 删除草稿记录(仅在有草稿时)
|
||
if draft:
|
||
db.session.delete(draft)
|
||
|
||
db.session.commit()
|
||
|
||
return jsonify({
|
||
"message": f"{action_type}成功",
|
||
"action_type": action_type,
|
||
"diff_qty": diff_qty,
|
||
"new_stock_qty": new_stock_qty,
|
||
"new_avail_qty": new_avail_qty,
|
||
"draft_id": draft_id if draft_id else (draft.id if draft else None)
|
||
}), 200
|
||
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
print(f"Adjust Stock Error: {e}")
|
||
return jsonify({"message": str(e)}), 500
|
||
|
||
|
||
@bp.route('/borrowed-quantities', methods=['POST'])
|
||
@permission_required('inventory_stocktake')
|
||
def get_borrowed_quantities():
|
||
"""批量获取借出未还数量"""
|
||
data = request.json.get('items', [])
|
||
result = {}
|
||
for item in data:
|
||
source = item.get('source_table')
|
||
stock_id = item.get('stock_id')
|
||
if source and stock_id is not None:
|
||
qty = TransBorrow.get_borrowed_quantity(source, stock_id)
|
||
result[f"{source}_{stock_id}"] = qty
|
||
return jsonify(result), 200
|
||
|
||
|
||
# --- 打印接口 ---
|
||
|
||
@bp.route('/print/selection', methods=['POST'])
|
||
@permission_required('inventory_stocktake:operation')
|
||
def print_selection():
|
||
try:
|
||
data = request.json
|
||
items = data.get('items', [])
|
||
if not items: return jsonify({"message": "未选择任何物品"}), 400
|
||
printer = NetworkPrintService()
|
||
success, msg = printer.print_outbound_selection(items)
|
||
return jsonify({"message": "打印指令已发送" if success else msg}), 200 if success else 500
|
||
except Exception as e:
|
||
return jsonify({"message": str(e)}), 500
|
||
|
||
|
||
@bp.route('/print/stocktake', methods=['POST'])
|
||
@permission_required('inventory_stocktake:operation')
|
||
def print_stocktake():
|
||
try:
|
||
data = request.json
|
||
printer = NetworkPrintService()
|
||
success, msg = printer.print_stocktake_report(data)
|
||
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. 外借在用资产明细 (未归还的借出记录)
|
||
4. 未盘点明细(疑似漏盘)
|
||
"""
|
||
try:
|
||
# ★ 获取 session_id 参数,用于过滤当前会话的扫描记录
|
||
session_id = request.args.get('session_id', '', type=str)
|
||
|
||
# 创建工作簿
|
||
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 = None
|
||
base_id = getattr(stock, 'base_id', None)
|
||
if base_id:
|
||
material = MaterialBase.query.get(base_id)
|
||
|
||
# 规格型号:从 MaterialBase 的 spec_model 字段获取
|
||
spec = getattr(material, 'spec_model', None) if material else '-'
|
||
if not spec or spec == '-':
|
||
spec = getattr(stock, 'spec_model', None) or getattr(stock, 'standard', None) or '-'
|
||
|
||
# 库位:从库存表的 warehouse_location 字段获取
|
||
location = getattr(stock, 'warehouse_location', 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):
|
||
"""获取用户真实姓名
|
||
SysUser.username 存储格式为 "真实姓名/登录账号" (例如: 张三/zhangsan01)
|
||
"""
|
||
if not SysUser or not user_id:
|
||
return str(user_id) if user_id else '-'
|
||
try:
|
||
user = None
|
||
# 尝试通过ID或用户名查找
|
||
if str(user_id).isdigit():
|
||
user = SysUser.query.get(int(user_id))
|
||
if not user:
|
||
user = SysUser.query.filter(SysUser.username.like(f"%/{user_id}")).first()
|
||
if not user:
|
||
user = SysUser.query.filter_by(username=str(user_id)).first()
|
||
|
||
if not user:
|
||
return str(user_id)
|
||
|
||
# 解析 username 格式: "张三/zhangsan01" -> 取前面的真实姓名
|
||
raw_username = getattr(user, 'username', None) or str(user_id)
|
||
if '/' in raw_username:
|
||
return raw_username.split('/')[0]
|
||
return raw_username
|
||
except:
|
||
return str(user_id)
|
||
|
||
def to_beijing_time(dt):
|
||
"""直接使用数据库中存储的标准时间(服务器时区已正确)"""
|
||
if not dt:
|
||
return ''
|
||
try:
|
||
if isinstance(dt, str):
|
||
return dt[:19]
|
||
return dt.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("盘点全景汇总表", 0)
|
||
summary_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点状态", "盘点人", "盘点时间", "备注"]
|
||
set_header_row(ws1, summary_headers)
|
||
master_row_idx = 2 # 汇总表行计数器
|
||
|
||
# ===== Sheet 2: 盘点差异明细 =====
|
||
ws2 = wb.create_sheet("盘点差异明细")
|
||
diff_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点人", "盘点时间", "备注"]
|
||
set_header_row(ws2, diff_headers)
|
||
|
||
# 按 SKU 排序:先获取全部数据,再在 Python 中按 SKU 排序
|
||
diff_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty != 0).all()
|
||
diff_drafts_with_sku = []
|
||
for draft in diff_drafts:
|
||
mat_info = get_material_info(draft.source_table, draft.stock_id)
|
||
diff_drafts_with_sku.append((mat_info.get('sku', ''), draft))
|
||
diff_drafts_with_sku.sort(key=lambda x: x[0] if x[0] else '')
|
||
diff_drafts = [d[1] for d in diff_drafts_with_sku]
|
||
|
||
for row_idx, draft in enumerate(diff_drafts, 2):
|
||
mat_info = get_material_info(draft.source_table, draft.stock_id)
|
||
# 写入 Sheet 2 (差异明细)
|
||
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
|
||
ws2.cell(row=row_idx, column=10, value=draft.remark or '').border = thin_border
|
||
# 同时写入 Sheet 1 (汇总表)
|
||
ws1.cell(row=master_row_idx, column=1, value=mat_info['name']).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=2, value=mat_info['sku']).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=3, value=mat_info['spec']).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=4, value=mat_info['location']).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=8, value="有差异").border = thin_border
|
||
ws1.cell(row=master_row_idx, column=9, value=get_user_name(draft.user_id)).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=10, value=to_beijing_time(draft.scan_time)).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=11, value=draft.remark or '').border = thin_border
|
||
master_row_idx += 1
|
||
|
||
# ===== Sheet 3: 账实相符明细 =====
|
||
ws3 = wb.create_sheet("账实相符明细")
|
||
normal_headers = ["物料名称", "SKU", "规格型号", "库位", "调整后账面数", "实盘数", "差异数", "盘点人", "盘点时间", "备注"]
|
||
set_header_row(ws3, normal_headers)
|
||
|
||
# 按 SKU 排序
|
||
normal_drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty == 0).all()
|
||
normal_drafts_with_sku = []
|
||
for draft in normal_drafts:
|
||
mat_info = get_material_info(draft.source_table, draft.stock_id)
|
||
normal_drafts_with_sku.append((mat_info.get('sku', ''), draft))
|
||
normal_drafts_with_sku.sort(key=lambda x: x[0] if x[0] else '')
|
||
normal_drafts = [d[1] for d in normal_drafts_with_sku]
|
||
|
||
for row_idx, draft in enumerate(normal_drafts, 2):
|
||
mat_info = get_material_info(draft.source_table, draft.stock_id)
|
||
# 写入 Sheet 3 (账实相符)
|
||
ws3.cell(row=row_idx, column=1, value=mat_info['name']).border = thin_border
|
||
ws3.cell(row=row_idx, column=2, value=mat_info['sku']).border = thin_border
|
||
ws3.cell(row=row_idx, column=3, value=mat_info['spec']).border = thin_border
|
||
ws3.cell(row=row_idx, column=4, value=mat_info['location']).border = thin_border
|
||
ws3.cell(row=row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
|
||
ws3.cell(row=row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
|
||
ws3.cell(row=row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
|
||
ws3.cell(row=row_idx, column=8, value=get_user_name(draft.user_id)).border = thin_border
|
||
ws3.cell(row=row_idx, column=9, value=to_beijing_time(draft.scan_time)).border = thin_border
|
||
ws3.cell(row=row_idx, column=10, value=draft.remark or '').border = thin_border
|
||
# 同时写入 Sheet 1 (汇总表)
|
||
ws1.cell(row=master_row_idx, column=1, value=mat_info['name']).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=2, value=mat_info['sku']).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=3, value=mat_info['spec']).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=4, value=mat_info['location']).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=5, value=float(draft.stock_qty or 0)).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=6, value=float(draft.quantity or 0)).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=7, value=float(draft.diff_qty or 0)).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=8, value="正常").border = thin_border
|
||
ws1.cell(row=master_row_idx, column=9, value=get_user_name(draft.user_id)).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=10, value=to_beijing_time(draft.scan_time)).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=11, value=draft.remark or '').border = thin_border
|
||
master_row_idx += 1
|
||
|
||
# ===== Sheet 4: 外借在用资产明细 =====
|
||
ws4 = wb.create_sheet("外借在用资产明细")
|
||
borrow_headers = ["借出单号", "借用人", "物料名称", "SKU", "规格型号", "借出总数", "已还数量", "待还数量", "借出时间", "预计归还时间"]
|
||
set_header_row(ws4, 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
|
||
|
||
ws4.cell(row=row_idx, column=1, value=borrow.borrow_no or '').border = thin_border
|
||
ws4.cell(row=row_idx, column=2, value=borrow.borrower_name or '').border = thin_border
|
||
ws4.cell(row=row_idx, column=3, value=mat_info['name']).border = thin_border
|
||
ws4.cell(row=row_idx, column=4, value=mat_info['sku']).border = thin_border
|
||
ws4.cell(row=row_idx, column=5, value=mat_info['spec']).border = thin_border
|
||
ws4.cell(row=row_idx, column=6, value=total_qty).border = thin_border
|
||
ws4.cell(row=row_idx, column=7, value=returned_qty).border = thin_border
|
||
ws4.cell(row=row_idx, column=8, value=pending_qty).border = thin_border
|
||
ws4.cell(row=row_idx, column=9, value=to_beijing_time(borrow.borrow_time)).border = thin_border
|
||
ws4.cell(row=row_idx, column=10, value='无限期' if not borrow.expected_return_time else to_beijing_time(borrow.expected_return_time)).border = thin_border
|
||
|
||
# ===== Sheet 5: 未盘点明细(疑似漏盘) =====
|
||
# 逻辑:获取已盘点的集合,遍历库存表,找出未盘点且有库存的物资
|
||
ws5 = wb.create_sheet("未盘点明细(疑似漏盘)")
|
||
unscanned_headers = ["物料名称", "SKU", "规格型号", "库位", "批号", "调整后账面数", "实盘数", "差异数", "状态"]
|
||
set_header_row(ws5, unscanned_headers)
|
||
|
||
# 获取已盘点的 (source_table, stock_id) 集合
|
||
# ★ 修复:只查询当前 session_id 的扫描记录,避免历史记录干扰
|
||
if session_id:
|
||
session_drafts = StocktakeDraft.query.filter_by(session_id=session_id).all()
|
||
else:
|
||
# 如果没有传 session_id,使用所有记录(兼容旧行为)
|
||
session_drafts = StocktakeDraft.query.all()
|
||
scanned_set = {(d.source_table, d.stock_id) for d in session_drafts}
|
||
|
||
def get_borrowed_qty(source_table, stock_id):
|
||
"""获取某库存的借出未还数量"""
|
||
try:
|
||
borrowed = TransBorrow.query.filter(
|
||
TransBorrow.source_table == source_table,
|
||
TransBorrow.stock_id == stock_id,
|
||
TransBorrow.is_returned == False
|
||
).all()
|
||
total = sum(float(b.quantity or 0) - float(b.returned_quantity or 0) for b in borrowed)
|
||
return total
|
||
except:
|
||
return 0
|
||
|
||
unscanned_items = []
|
||
|
||
# ★ 修复 N+1 查询:使用 joinedload 预加载 base 关系,同时过滤 stock_quantity > 0
|
||
for stock in StockBuy.query.filter(StockBuy.stock_quantity > 0).options(joinedload(StockBuy.base)).all():
|
||
key = ('stock_buy', stock.id)
|
||
if key in scanned_set:
|
||
continue
|
||
# 扣除外借数量
|
||
borrowed_qty = get_borrowed_qty('stock_buy', stock.id)
|
||
stock_qty = float(stock.stock_quantity or 0)
|
||
expected_qty = stock_qty - borrowed_qty
|
||
if expected_qty > 0:
|
||
# ★ 直接使用预加载的 base 关系,避免额外查询
|
||
material = stock.base
|
||
# ★ 安全提取批号/序列号:使用 getattr 降级链
|
||
batch_sn = getattr(stock, 'batch_number', None) or getattr(stock, 'sn', None) or getattr(stock, 'serial_number', None) or '-'
|
||
mat_info = {
|
||
'name': material.name if material else '-',
|
||
'sku': getattr(stock, 'sku', None) or '-',
|
||
'spec': getattr(material, 'spec_model', None) if material else '-',
|
||
'location': getattr(stock, 'warehouse_location', None) or '-',
|
||
'batch_no': batch_sn
|
||
}
|
||
unscanned_items.append({
|
||
'name': mat_info['name'],
|
||
'sku': mat_info['sku'],
|
||
'spec': mat_info['spec'],
|
||
'location': mat_info['location'],
|
||
'batch_no': mat_info['batch_no'],
|
||
'stock_qty': expected_qty,
|
||
'actual_qty': 0,
|
||
'diff_qty': -expected_qty,
|
||
'status': '未盘点'
|
||
})
|
||
|
||
# 遍历 StockSemi
|
||
if StockSemi:
|
||
for stock in StockSemi.query.filter(StockSemi.stock_quantity > 0).options(joinedload(StockSemi.base)).all():
|
||
key = ('stock_semi', stock.id)
|
||
if key in scanned_set:
|
||
continue
|
||
borrowed_qty = get_borrowed_qty('stock_semi', stock.id)
|
||
stock_qty = float(stock.stock_quantity or 0)
|
||
expected_qty = stock_qty - borrowed_qty
|
||
if expected_qty > 0:
|
||
# ★ 直接使用预加载的 base 关系,避免额外查询
|
||
material = stock.base
|
||
# ★ 安全提取批号/序列号:使用 getattr 降级链
|
||
batch_sn = getattr(stock, 'batch_number', None) or getattr(stock, 'sn', None) or getattr(stock, 'serial_number', None) or '-'
|
||
mat_info = {
|
||
'name': material.name if material else '-',
|
||
'sku': getattr(stock, 'sku', None) or '-',
|
||
'spec': getattr(material, 'spec_model', None) if material else '-',
|
||
'location': getattr(stock, 'warehouse_location', None) or '-',
|
||
'batch_no': batch_sn
|
||
}
|
||
unscanned_items.append({
|
||
'name': mat_info['name'],
|
||
'sku': mat_info['sku'],
|
||
'spec': mat_info['spec'],
|
||
'location': mat_info['location'],
|
||
'batch_no': mat_info['batch_no'],
|
||
'stock_qty': expected_qty,
|
||
'actual_qty': 0,
|
||
'diff_qty': -expected_qty,
|
||
'status': '未盘点'
|
||
})
|
||
|
||
# 遍历 StockProduct
|
||
if StockProduct:
|
||
for stock in StockProduct.query.filter(StockProduct.stock_quantity > 0).options(joinedload(StockProduct.base)).all():
|
||
key = ('stock_product', stock.id)
|
||
if key in scanned_set:
|
||
continue
|
||
stock_qty = float(stock.stock_quantity or 0)
|
||
if stock_qty <= 0:
|
||
continue
|
||
borrowed_qty = get_borrowed_qty('stock_product', stock.id)
|
||
expected_qty = stock_qty - borrowed_qty
|
||
if expected_qty > 0:
|
||
# ★ 直接使用预加载的 base 关系,避免额外查询
|
||
material = stock.base
|
||
# ★ 安全提取批号/序列号:使用 getattr 降级链 (成品可能无此字段)
|
||
batch_sn = getattr(stock, 'batch_number', None) or getattr(stock, 'sn', None) or getattr(stock, 'serial_number', None) or '-'
|
||
mat_info = {
|
||
'name': material.name if material else '-',
|
||
'sku': getattr(stock, 'sku', None) or '-',
|
||
'spec': getattr(material, 'spec_model', None) if material else '-',
|
||
'location': getattr(stock, 'warehouse_location', None) or '-',
|
||
'batch_no': batch_sn
|
||
}
|
||
unscanned_items.append({
|
||
'name': mat_info['name'],
|
||
'sku': mat_info['sku'],
|
||
'spec': mat_info['spec'],
|
||
'location': mat_info['location'],
|
||
'batch_no': mat_info['batch_no'],
|
||
'stock_qty': expected_qty,
|
||
'actual_qty': 0,
|
||
'diff_qty': -expected_qty,
|
||
'status': '未盘点'
|
||
})
|
||
|
||
# 写入未盘点明细
|
||
for row_idx, item in enumerate(unscanned_items, 2):
|
||
# 写入 Sheet 5 (未盘点明细)
|
||
ws5.cell(row=row_idx, column=1, value=item['name']).border = thin_border
|
||
ws5.cell(row=row_idx, column=2, value=item['sku']).border = thin_border
|
||
ws5.cell(row=row_idx, column=3, value=item['spec']).border = thin_border
|
||
ws5.cell(row=row_idx, column=4, value=item['location']).border = thin_border
|
||
ws5.cell(row=row_idx, column=5, value=item.get('batch_no', '-')).border = thin_border # ★ 批号
|
||
ws5.cell(row=row_idx, column=6, value=float(item['stock_qty'])).border = thin_border
|
||
ws5.cell(row=row_idx, column=7, value=float(item['actual_qty'])).border = thin_border
|
||
ws5.cell(row=row_idx, column=8, value=float(item['diff_qty'])).border = thin_border
|
||
ws5.cell(row=row_idx, column=9, value=item['status']).border = thin_border
|
||
# 同时写入 Sheet 1 (汇总表) - 盘点人和时间留空
|
||
ws1.cell(row=master_row_idx, column=1, value=item['name']).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=2, value=item['sku']).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=3, value=item['spec']).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=4, value=item['location']).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=5, value=float(item['stock_qty'])).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=6, value=float(item['actual_qty'])).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=7, value=float(item['diff_qty'])).border = thin_border
|
||
ws1.cell(row=master_row_idx, column=8, value="未盘点").border = thin_border
|
||
ws1.cell(row=master_row_idx, column=9, value="").border = thin_border
|
||
ws1.cell(row=master_row_idx, column=10, value="").border = thin_border
|
||
master_row_idx += 1
|
||
|
||
# 调整列宽
|
||
for ws in [ws1, ws2, ws3, ws4, ws5]:
|
||
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
|
||
|
||
|
||
# --------------------------------------------------------
|
||
# 生成漏盘数据 - 将未扫描的库存标记为全额盘亏
|
||
# POST /api/v1/inbound/stocktake/generate-missing
|
||
# --------------------------------------------------------
|
||
@bp.route('/stocktake/generate-missing', methods=['POST'])
|
||
@permission_required('inventory_stocktake:operation')
|
||
def generate_missing_stocktake():
|
||
"""
|
||
生成漏盘数据(幂等性):
|
||
找出所有真实库存 > 0,但未被当前会话盘点扫描到的物料,
|
||
自动生成盘点草稿,标记为盘亏(实盘=0,差异=-库存数)
|
||
|
||
幂等性保护:在重新计算差集之前,先删除当前 session 下所有
|
||
由系统自动生成的漏盘记录(quantity==0, user_id=='system'),
|
||
保证该接口多次调用结果一致。
|
||
"""
|
||
try:
|
||
# ★ 获取 session_id 参数,用于隔离当前会话
|
||
data = request.get_json() or {}
|
||
session_id = data.get('session_id', '')
|
||
|
||
if not session_id:
|
||
return jsonify({'code': 400, 'msg': '缺少 session_id 参数'}), 400
|
||
|
||
# ★ 幂等性保护:先删除当前 session 下系统自动生成的漏盘记录
|
||
# 特征:user_id == 'system' (表示由系统自动生成)
|
||
deleted_count = StocktakeDraft.query.filter(
|
||
StocktakeDraft.session_id == session_id,
|
||
StocktakeDraft.user_id == 'system'
|
||
).delete()
|
||
if deleted_count > 0:
|
||
db.session.commit()
|
||
print(f"[generate_missing] 已清理 {deleted_count} 条历史漏盘记录")
|
||
|
||
# 1. 获取当前会话已有盘点记录的 (source_table, stock_id) 集合
|
||
existing_records = db.session.query(
|
||
StocktakeDraft.source_table,
|
||
StocktakeDraft.stock_id
|
||
).filter(StocktakeDraft.session_id == session_id).distinct().all()
|
||
|
||
scanned_keys = set()
|
||
for src_table, stock_id in existing_records:
|
||
if stock_id:
|
||
scanned_keys.add((src_table, stock_id))
|
||
|
||
# 2. 获取所有真实库存 > 0 的记录
|
||
all_stock = []
|
||
|
||
# 采购库存
|
||
for item in StockBuy.query.filter(StockBuy.stock_quantity > 0).all():
|
||
all_stock.append({
|
||
'source_table': 'stock_buy',
|
||
'stock_id': item.id,
|
||
'base_id': item.base_id,
|
||
'stock_qty': float(item.stock_quantity or 0)
|
||
})
|
||
|
||
# 半成品库存
|
||
if StockSemi:
|
||
for item in StockSemi.query.filter(StockSemi.stock_quantity > 0).all():
|
||
all_stock.append({
|
||
'source_table': 'stock_semi',
|
||
'stock_id': item.id,
|
||
'base_id': item.base_id,
|
||
'stock_qty': float(item.stock_quantity or 0)
|
||
})
|
||
|
||
# 成品库存
|
||
if StockProduct:
|
||
for item in StockProduct.query.filter(StockProduct.stock_quantity > 0).all():
|
||
all_stock.append({
|
||
'source_table': 'stock_product',
|
||
'stock_id': item.id,
|
||
'base_id': item.base_id,
|
||
'stock_qty': float(item.stock_quantity or 0)
|
||
})
|
||
|
||
# 3. 找出漏盘记录(库存中有但盘点中没有的)
|
||
missing_count = 0
|
||
for stock in all_stock:
|
||
key = (stock['source_table'], stock['stock_id'])
|
||
if key not in scanned_keys:
|
||
# 生成漏盘草稿
|
||
draft = StocktakeDraft(
|
||
user_id='system', # ★ 标记为系统自动生成,用于幂等性清理
|
||
uuid=f'MISSING-{stock["source_table"]}-{stock["stock_id"]}',
|
||
quantity=0, # 实盘数为0
|
||
scan_time=beijing_time(),
|
||
session_id=session_id, # ★ 使用传入的 session_id
|
||
source_table=stock['source_table'],
|
||
stock_id=stock['stock_id'],
|
||
stock_qty=stock['stock_qty'],
|
||
diff_qty=-stock['stock_qty'], # 差异 = 0 - 库存数 = 负数
|
||
remark='未盘点到,系统自动标记为盘亏'
|
||
)
|
||
db.session.add(draft)
|
||
missing_count += 1
|
||
|
||
db.session.commit()
|
||
|
||
return jsonify({
|
||
'code': 200,
|
||
'msg': f'成功生成 {missing_count} 条漏盘记录',
|
||
'data': {'count': missing_count}
|
||
})
|
||
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
import traceback
|
||
traceback.print_exc()
|
||
return jsonify({'code': 500, 'msg': f'生成漏盘数据失败: {str(e)}'}), 500
|
||
|
||
|
||
# --------------------------------------------------------
|
||
# 获取应盘物资清单(盘点基数)
|
||
# GET /api/v1/inbound/stock/stocktake/all-items
|
||
# --------------------------------------------------------
|
||
@bp.route('/stocktake/all-items', methods=['GET'])
|
||
@permission_required('inventory_stocktake')
|
||
def get_all_stocktake_items():
|
||
"""
|
||
获取所有应盘物资清单(库存 > 0 的物料)
|
||
作为盘点基数,用于统计已盘/未盘数量
|
||
"""
|
||
try:
|
||
keyword = request.args.get('keyword', '', type=str)
|
||
|
||
all_items = []
|
||
|
||
# 1. 采购件
|
||
buy_query = StockBuy.query.filter(StockBuy.stock_quantity > 0)
|
||
if keyword:
|
||
buy_query = buy_query.join(MaterialBase, StockBuy.base_id == MaterialBase.id).filter(
|
||
db.or_(
|
||
StockBuy.sku.ilike(f'%{keyword}%'),
|
||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
||
)
|
||
)
|
||
for item in buy_query.all():
|
||
all_items.append({
|
||
'id': item.id,
|
||
'sku': item.sku or '',
|
||
'barcode': item.barcode or '',
|
||
# ★ 安全提取批号/序列号:使用 getattr 降级
|
||
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
|
||
'material_name': item.base.name if item.base else '',
|
||
'spec_model': item.base.spec_model if item.base else '',
|
||
'stock_qty': float(item.stock_quantity or 0),
|
||
'available_qty': float(item.available_quantity or 0),
|
||
'source_table': 'stock_buy',
|
||
'warehouse_location': item.warehouse_location or ''
|
||
})
|
||
|
||
# 2. 半成品
|
||
if StockSemi:
|
||
semi_query = StockSemi.query.filter(StockSemi.stock_quantity > 0)
|
||
if keyword:
|
||
semi_query = semi_query.join(MaterialBase, StockSemi.base_id == MaterialBase.id).filter(
|
||
db.or_(
|
||
StockSemi.sku.ilike(f'%{keyword}%'),
|
||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
||
)
|
||
)
|
||
for item in semi_query.all():
|
||
all_items.append({
|
||
'id': item.id,
|
||
'sku': item.sku or '',
|
||
'barcode': item.barcode or '',
|
||
# ★ 安全提取批号/序列号:使用 getattr 降级
|
||
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
|
||
'material_name': item.base.name if item.base else '',
|
||
'spec_model': item.base.spec_model if item.base else '',
|
||
'stock_qty': float(item.stock_quantity or 0),
|
||
'available_qty': float(item.available_quantity or 0),
|
||
'source_table': 'stock_semi',
|
||
'warehouse_location': item.warehouse_location or ''
|
||
})
|
||
|
||
# 3. 成品
|
||
if StockProduct:
|
||
product_query = StockProduct.query.filter(StockProduct.stock_quantity > 0)
|
||
if keyword:
|
||
product_query = product_query.join(MaterialBase, StockProduct.base_id == MaterialBase.id).filter(
|
||
db.or_(
|
||
StockProduct.sku.ilike(f'%{keyword}%'),
|
||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
||
)
|
||
)
|
||
for item in product_query.all():
|
||
all_items.append({
|
||
'id': item.id,
|
||
'sku': item.sku or '',
|
||
'barcode': item.barcode or '',
|
||
# ★ 安全提取批号/序列号:使用 getattr 降级 (成品无此字段则为空)
|
||
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
|
||
'material_name': item.base.name if item.base else '',
|
||
'spec_model': item.base.spec_model if item.base else '',
|
||
'stock_qty': float(item.stock_quantity or 0),
|
||
'available_qty': float(item.available_quantity or 0),
|
||
'source_table': 'stock_product',
|
||
'warehouse_location': item.warehouse_location or ''
|
||
})
|
||
|
||
# 按 SKU 排序
|
||
all_items.sort(key=lambda x: (x['sku'] or '').lower())
|
||
|
||
return jsonify({
|
||
'code': 200,
|
||
'data': {
|
||
'items': all_items,
|
||
'total': len(all_items)
|
||
}
|
||
}), 200
|
||
|
||
except Exception as e:
|
||
import traceback
|
||
traceback.print_exc()
|
||
return jsonify({'code': 500, 'msg': f'获取应盘清单失败: {str(e)}'}), 500
|
||
|
||
|
||
# --------------------------------------------------------
|
||
# 更新盘点实盘数(手动修改)
|
||
# POST /api/v1/inbound/stock/stocktake/update-quantity
|
||
# --------------------------------------------------------
|
||
@bp.route('/stocktake/update-quantity', methods=['POST'])
|
||
@permission_required('inventory_stocktake:operation')
|
||
def update_stocktake_quantity():
|
||
"""
|
||
更新盘点实盘数
|
||
用于手动修改盘点数量
|
||
"""
|
||
try:
|
||
data = request.json
|
||
stock_id = data.get('stock_id')
|
||
source_table = data.get('source_table')
|
||
quantity = float(data.get('quantity', 0))
|
||
|
||
if not stock_id or not source_table:
|
||
return jsonify({'code': 400, 'msg': '缺少必要参数'}), 400
|
||
|
||
# 查找对应的盘点记录
|
||
draft = StocktakeDraft.query.filter_by(
|
||
stock_id=stock_id,
|
||
source_table=source_table
|
||
).first()
|
||
|
||
if not draft:
|
||
return jsonify({'code': 404, 'msg': '未找到盘点记录'}), 404
|
||
|
||
# 更新数量
|
||
draft.quantity = quantity
|
||
draft.scan_time = beijing_time()
|
||
|
||
# 计算差异
|
||
draft.diff_qty = quantity - float(draft.stock_qty or 0)
|
||
|
||
db.session.commit()
|
||
|
||
return jsonify({'code': 200, 'msg': '更新成功'}), 200
|
||
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
import traceback
|
||
traceback.print_exc()
|
||
return jsonify({'code': 500, 'msg': f'更新失败: {str(e)}'}), 500
|