480 lines
17 KiB
Python
480 lines
17 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, 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
|