Files
KCGL/inventory-backend/app/api/v1/stock/adjustment.py

480 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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, timedelta
from sqlalchemy import func
adjustment_bp = Blueprint('adjustment', __name__, url_prefix='/stock/adjustment')
def get_beijing_time():
"""获取北京时间 (UTC+8)"""
return datetime.utcnow() + timedelta(hours=8)
def generate_order_no():
"""生成单号 ADJ-YYYYMMDD-XXXX按天严格自增"""
bj_time = get_beijing_time()
date_str = bj_time.strftime('%Y%m%d')
prefix = f"ADJ-{date_str}-"
# 查询今天已有的最大单号
last_order = StockAdjustment.query.filter(
StockAdjustment.order_no.like(f"{prefix}%")
).order_by(StockAdjustment.order_no.desc()).first()
if last_order:
# 解析最后的 4 位流水号并 +1
try:
last_seq = int(last_order.order_no.split('-')[-1])
new_seq = last_seq + 1
except (ValueError, IndexError):
new_seq = 1
else:
new_seq = 1
return f"{prefix}{new_seq:04d}" # 补齐 4 位,如 0001
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:
# 创建调整单(强制使用北京时间)
bj_time = get_beijing_time()
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,
create_time=bj_time,
update_time=bj_time
)
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:
# 获取分页参数
page = request.args.get('page', 1, type=int)
limit = request.args.get('limit', 20, type=int)
keyword = request.args.get('keyword', '', type=str)
# 先查询有差异的记录
query = StocktakeDraft.query.filter(StocktakeDraft.diff_qty != 0)
# 执行查询(不排序,因为在 Python 中排序)
drafts = query.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', '')
# 如果有关键词,进行 SKU 模糊匹配
if keyword and sku:
if keyword.lower() not in sku.lower():
continue
# 联表查询 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 ''
})
# 按 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({
'code': 200,
'data': {
'items': paginated_items,
'total': total,
'page': page,
'limit': limit
}
})
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()
# 创建调整单(强制使用北京时间)
bj_time = get_beijing_time()
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,
create_time=bj_time,
update_time=bj_time
)
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
# --------------------------------------------------------
# 7. 处理调整单关联入库SKU或出库单号
# POST /api/v1/stock/adjustment/<id>/process
# --------------------------------------------------------
@adjustment_bp.route('/<int:id>/process', methods=['POST'])
@jwt_required()
@permission_required('stock_adjustment:operation')
def process_adjustment(id):
"""处理待处理的调整单关联入库SKU或出库单号"""
identity = get_jwt_identity()
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:
return jsonify({'code': 400, 'msg': '缺少参数'}), 400
linked_sku = data.get('linked_sku', '')
linked_outbound_no = data.get('linked_outbound_no', '')
try:
adjustment = StockAdjustment.query.get(id)
if not adjustment:
return jsonify({'code': 404, 'msg': '调整单不存在'}), 404
if adjustment.status != 'pending':
return jsonify({'code': 400, 'msg': '只能处理待处理状态的调整单'}), 400
# 根据调整类型校验必填项
if adjustment.adjust_type == 'profit':
if not linked_sku:
return jsonify({'code': 400, 'msg': '盘盈调整必须关联入库SKU'}), 400
adjustment.linked_sku = linked_sku
elif adjustment.adjust_type == 'loss':
if not linked_outbound_no:
return jsonify({'code': 400, 'msg': '盘亏调整必须关联出库单号'}), 400
adjustment.linked_outbound_no = linked_outbound_no
adjustment.status = 'completed'
adjustment.operator = operator
adjustment.update_time = get_beijing_time()
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