377 lines
13 KiB
Python
377 lines
13 KiB
Python
# inventory-backend/app/api/v1/stock/adjustment.py
|
||
from flask import Blueprint, request, jsonify
|
||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||
from app.utils.decorators import permission_required
|
||
from app.extensions import db
|
||
from app.models.stock.adjustment import StockAdjustment
|
||
from app.models.base import MaterialBase
|
||
from app.models.inbound.buy import StockBuy
|
||
from app.models.inbound.semi import StockSemi
|
||
from app.models.inbound.product import StockProduct
|
||
from app.models.inbound.stocktake import StocktakeDraft
|
||
from datetime import datetime
|
||
import random
|
||
import string
|
||
|
||
adjustment_bp = Blueprint('adjustment', __name__, url_prefix='/stock/adjustment')
|
||
|
||
|
||
def generate_order_no():
|
||
"""生成单号 ADJ-YYYYMMDD-XXXX"""
|
||
today = datetime.now().strftime('%Y%m%d')
|
||
suffix = ''.join(random.choices(string.digits, k=4))
|
||
return f'ADJ-{today}-{suffix}'
|
||
|
||
|
||
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
|
||
|
||
|
||
# --------------------------------------------------------
|
||
# 1. 获取调整单列表
|
||
# GET /api/v1/stock/adjustment/list
|
||
# --------------------------------------------------------
|
||
@adjustment_bp.route('/list', methods=['GET'])
|
||
@jwt_required()
|
||
@permission_required('stock_adjustment:list')
|
||
def get_list():
|
||
page = int(request.args.get('page', 1))
|
||
limit = int(request.args.get('limit', 10))
|
||
keyword = request.args.get('keyword', '')
|
||
adjust_type = request.args.get('adjust_type', '')
|
||
status = request.args.get('status', '')
|
||
|
||
query = StockAdjustment.query
|
||
|
||
if keyword:
|
||
query = query.filter(
|
||
db.or_(
|
||
StockAdjustment.order_no.ilike(f'%{keyword}%'),
|
||
StockAdjustment.sku.ilike(f'%{keyword}%'),
|
||
StockAdjustment.material_name.ilike(f'%{keyword}%')
|
||
)
|
||
)
|
||
|
||
if adjust_type:
|
||
query = query.filter(StockAdjustment.adjust_type == adjust_type)
|
||
|
||
if status:
|
||
query = query.filter(StockAdjustment.status == status)
|
||
|
||
# 按创建时间降序
|
||
query = query.order_by(StockAdjustment.create_time.desc())
|
||
|
||
pagination = query.paginate(page=page, per_page=limit, error_out=False)
|
||
|
||
return jsonify({
|
||
'code': 200,
|
||
'data': {
|
||
'items': [item.to_dict() for item in pagination.items],
|
||
'total': pagination.total,
|
||
'page': page,
|
||
'limit': limit
|
||
}
|
||
})
|
||
|
||
|
||
# --------------------------------------------------------
|
||
# 2. 创建调整单
|
||
# POST /api/v1/stock/adjustment/create
|
||
# --------------------------------------------------------
|
||
@adjustment_bp.route('/create', methods=['POST'])
|
||
@jwt_required()
|
||
@permission_required('stock_adjustment:operation')
|
||
def create():
|
||
data = request.get_json()
|
||
if not data:
|
||
return jsonify({'code': 400, 'msg': '请求参数不能为空'}), 400
|
||
|
||
# 必填字段验证
|
||
required_fields = ['source_table', 'stock_id', 'adjust_type', 'adjust_quantity', 'reason']
|
||
for field in required_fields:
|
||
if field not in data or not data.get(field):
|
||
return jsonify({'code': 400, 'msg': f'{field} 为必填项'}), 400
|
||
|
||
source_table = data['source_table']
|
||
stock_id = int(data['stock_id'])
|
||
adjust_type = data['adjust_type'] # 'profit' or 'loss'
|
||
adjust_quantity = float(data['adjust_quantity'])
|
||
reason = data['reason']
|
||
operator = get_jwt_identity() or 'system'
|
||
|
||
# 获取库存记录
|
||
StockModel = get_stock_model(source_table)
|
||
if not StockModel:
|
||
return jsonify({'code': 400, 'msg': '无效的库存类型'}), 400
|
||
|
||
stock = StockModel.query.get(stock_id)
|
||
if not stock:
|
||
return jsonify({'code': 404, 'msg': '库存记录不存在'}), 404
|
||
|
||
# 获取物料信息
|
||
base_id = getattr(stock, 'base_id', None)
|
||
material = MaterialBase.query.get(base_id) if base_id else None
|
||
|
||
# 计算库存变动
|
||
if adjust_type == 'profit':
|
||
# 盘盈:增加库存
|
||
new_stock_qty = float(stock.stock_quantity or 0) + adjust_quantity
|
||
new_avail_qty = float(stock.available_quantity or 0) + adjust_quantity
|
||
elif adjust_type == 'loss':
|
||
# 盘亏:减少库存
|
||
new_stock_qty = float(stock.stock_quantity or 0) - adjust_quantity
|
||
new_avail_qty = float(stock.available_quantity or 0) - adjust_quantity
|
||
if new_stock_qty < 0 or new_avail_qty < 0:
|
||
return jsonify({'code': 400, 'msg': '库存不足,无法盘亏'}), 400
|
||
else:
|
||
return jsonify({'code': 400, 'msg': '无效的调整类型'}), 400
|
||
|
||
try:
|
||
# 创建调整单
|
||
adjustment = StockAdjustment(
|
||
order_no=generate_order_no(),
|
||
base_id=base_id,
|
||
stock_id=stock_id,
|
||
source_table=source_table,
|
||
sku=getattr(stock, 'sku', None) or getattr(stock, 'SKU', None),
|
||
material_name=material.name if material else getattr(stock, 'sku', '未知'),
|
||
spec_model=getattr(material, 'spec_model', None) if material else None,
|
||
warehouse_location=getattr(stock, 'warehouse_location', None),
|
||
adjust_type=adjust_type,
|
||
adjust_quantity=adjust_quantity,
|
||
reason=reason,
|
||
status='completed',
|
||
operator=operator
|
||
)
|
||
db.session.add(adjustment)
|
||
|
||
# 更新库存
|
||
stock.stock_quantity = new_stock_qty
|
||
stock.available_quantity = new_avail_qty
|
||
|
||
db.session.commit()
|
||
|
||
return jsonify({
|
||
'code': 200,
|
||
'msg': '调整成功',
|
||
'data': adjustment.to_dict()
|
||
})
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
return jsonify({'code': 500, 'msg': f'调整失败: {str(e)}'}), 500
|
||
|
||
|
||
# --------------------------------------------------------
|
||
# 3. 获取库存列表(用于选择物料)
|
||
# GET /api/v1/stock/adjustment/stocks
|
||
# --------------------------------------------------------
|
||
@adjustment_bp.route('/stocks', methods=['GET'])
|
||
@jwt_required()
|
||
@permission_required('stock_adjustment:list')
|
||
def get_stocks():
|
||
"""获取可用于调整的库存列表"""
|
||
source_table = request.args.get('source_table', 'stock_buy')
|
||
keyword = request.args.get('keyword', '')
|
||
page = int(request.args.get('page', 1))
|
||
limit = int(request.args.get('limit', 20))
|
||
|
||
StockModel = get_stock_model(source_table)
|
||
if not StockModel:
|
||
return jsonify({'code': 400, 'msg': '无效的库存类型'}), 400
|
||
|
||
query = StockModel.query.filter(StockModel.stock_quantity > 0)
|
||
|
||
if keyword:
|
||
query = query.filter(
|
||
db.or_(
|
||
StockModel.sku.ilike(f'%{keyword}%'),
|
||
StockModel.barcode.ilike(f'%{keyword}%')
|
||
)
|
||
)
|
||
|
||
pagination = query.paginate(page=page, per_page=limit, error_out=False)
|
||
|
||
items = []
|
||
for stock in pagination.items:
|
||
base_id = getattr(stock, 'base_id', None)
|
||
material = MaterialBase.query.get(base_id) if base_id else None
|
||
items.append({
|
||
'stock_id': stock.id,
|
||
'source_table': source_table,
|
||
'sku': getattr(stock, 'sku', None) or getattr(stock, 'SKU', None),
|
||
'material_name': material.name if material else getattr(stock, 'sku', '未知'),
|
||
'spec_model': getattr(material, 'spec_model', None) if material else None,
|
||
'stock_quantity': float(stock.stock_quantity or 0),
|
||
'available_quantity': float(stock.available_quantity or 0),
|
||
'warehouse_location': getattr(stock, 'warehouse_location', None),
|
||
})
|
||
|
||
return jsonify({
|
||
'code': 200,
|
||
'data': {
|
||
'items': items,
|
||
'total': pagination.total,
|
||
'page': page,
|
||
'limit': limit
|
||
}
|
||
})
|
||
|
||
|
||
# --------------------------------------------------------
|
||
# 5. 获取盘点差异列表
|
||
# GET /api/v1/stock/adjustment/stocktake-discrepancies
|
||
# --------------------------------------------------------
|
||
@adjustment_bp.route('/stocktake-discrepancies', methods=['GET'])
|
||
@jwt_required()
|
||
@permission_required('stock_adjustment:list')
|
||
def get_stocktake_discrepancies():
|
||
"""获取所有有差异的盘点记录"""
|
||
try:
|
||
# 查询所有有差异的盘点记录
|
||
drafts = StocktakeDraft.query.filter(StocktakeDraft.diff_qty != 0).all()
|
||
|
||
items = []
|
||
for draft in drafts:
|
||
diff = float(draft.diff_qty or 0)
|
||
if diff == 0:
|
||
continue
|
||
|
||
# 获取物料基础信息
|
||
base_id = None
|
||
material_name = ''
|
||
spec_model = ''
|
||
sku = ''
|
||
warehouse_location = ''
|
||
|
||
# 根据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:
|
||
base_id = getattr(stock, 'base_id', None)
|
||
sku = getattr(stock, 'sku', None) or getattr(stock, 'SKU', '')
|
||
warehouse_location = getattr(stock, 'warehouse_location', '')
|
||
|
||
# 联表查询 MaterialBase
|
||
if base_id:
|
||
material = MaterialBase.query.get(base_id)
|
||
if material:
|
||
material_name = material.name
|
||
spec_model = material.spec_model
|
||
|
||
items.append({
|
||
'draft_id': draft.id,
|
||
'source_table': draft.source_table,
|
||
'stock_id': draft.stock_id,
|
||
'base_id': base_id,
|
||
'sku': sku,
|
||
'material_name': material_name,
|
||
'spec_model': spec_model,
|
||
'warehouse_location': warehouse_location,
|
||
'stock_qty': float(draft.stock_qty or 0),
|
||
'quantity': float(draft.quantity or 0),
|
||
'diff_qty': diff,
|
||
'adjust_type': 'profit' if diff > 0 else 'loss',
|
||
'remark': draft.remark or ''
|
||
})
|
||
|
||
return jsonify({
|
||
'code': 200,
|
||
'data': {
|
||
'items': items,
|
||
'total': len(items)
|
||
}
|
||
})
|
||
|
||
except Exception as e:
|
||
return jsonify({'code': 500, 'msg': f'获取盘点差异失败: {str(e)}'}), 500
|
||
|
||
|
||
# --------------------------------------------------------
|
||
# 6. 批量导入盘点差异(按需勾选)
|
||
# POST /api/v1/stock/adjustment/import-from-stocktake
|
||
# --------------------------------------------------------
|
||
@adjustment_bp.route('/import-from-stocktake', methods=['POST'])
|
||
@jwt_required()
|
||
@permission_required('stock_adjustment:operation')
|
||
def import_from_stocktake():
|
||
"""批量导入选中的盘点差异记录"""
|
||
identity = get_jwt_identity()
|
||
# 修复操作人保存为0的Bug,确保保存真实的用户名
|
||
operator = identity.get('username', 'system') if isinstance(identity, dict) else str(identity)
|
||
if not operator or operator == '0':
|
||
operator = identity if identity else 'system'
|
||
|
||
data = request.get_json()
|
||
if not data or 'records' not in data:
|
||
return jsonify({'code': 400, 'msg': '缺少records参数'}), 400
|
||
|
||
records = data.get('records', [])
|
||
if not records:
|
||
return jsonify({'code': 400, 'msg': '请选择要导入的记录'}), 400
|
||
|
||
try:
|
||
count = 0
|
||
for item in records:
|
||
draft_id = item.get('draft_id')
|
||
reason = item.get('reason', '盘点差异导入')
|
||
|
||
# 获取对应的盘点记录
|
||
draft = StocktakeDraft.query.get(draft_id)
|
||
if not draft:
|
||
continue
|
||
|
||
diff = float(draft.diff_qty or 0)
|
||
if diff == 0:
|
||
continue
|
||
|
||
adjust_type = 'profit' if diff > 0 else 'loss'
|
||
adjust_quantity = abs(diff)
|
||
|
||
# 获取物料基础信息
|
||
base_id = None
|
||
sku = ''
|
||
warehouse_location = ''
|
||
|
||
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:
|
||
base_id = getattr(stock, 'base_id', None)
|
||
sku = getattr(stock, 'sku', None) or getattr(stock, 'SKU', '')
|
||
warehouse_location = getattr(stock, 'warehouse_location', '')
|
||
|
||
# 生成调整单号
|
||
order_no = generate_order_no()
|
||
|
||
# 创建调整单
|
||
adjustment = StockAdjustment(
|
||
order_no=order_no,
|
||
base_id=base_id,
|
||
stock_id=draft.stock_id,
|
||
source_table=draft.source_table,
|
||
sku=sku,
|
||
warehouse_location=warehouse_location,
|
||
adjust_type=adjust_type,
|
||
adjust_quantity=adjust_quantity,
|
||
reason=reason,
|
||
status='pending',
|
||
operator=operator
|
||
)
|
||
db.session.add(adjustment)
|
||
count += 1
|
||
|
||
db.session.commit()
|
||
return jsonify({'code': 200, 'msg': f'成功导入 {count} 条记录', 'data': {'count': count}})
|
||
|
||
except Exception as e:
|
||
db.session.rollback()
|
||
return jsonify({'code': 500, 'msg': f'导入失败: {str(e)}'}), 500
|