stock-backend / app /api /inventory.py
fromozuzhouzzz
refactor(env): 更新环境变量配置和相关文档
52dec8c
Raw
History Blame Contribute Delete
47.1 kB
from flask import jsonify, request, current_app, send_file
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.models.inventory import Inventory
from app.models.user import User
from app.models.material import Material
from app.models.store import Store
from app.models.inventory_log import InventoryLog
from app.api import bp, ns_inventory, api
from app import db
from app.utils.validators import validate_json, validate_decimal
from app.utils.cache import cache, clear_cache
from flask_restx import Resource, fields
from sqlalchemy import func
from app.utils.export import export_to_excel, export_to_csv
from datetime import datetime, date
from decimal import Decimal
# pandas 依赖已从requirements.txt中移除
import tempfile
import os
import io
import csv
# 定义模型
inventory_model = api.model('Inventory', {
'material_id': fields.Integer(required=True),
'store_id': fields.Integer(required=True),
'batch_number': fields.String(),
'quantity': fields.Float(required=True),
'unit_price': fields.Float(),
'expiry_date': fields.Date(),
'production_date': fields.Date(),
'warning_threshold': fields.Float()
})
inventory_response = api.inherit('InventoryResponse', inventory_model, {
'id': fields.Integer(description='Inventory ID'),
'warning': fields.Boolean(description='Warning flag'),
'material_name': fields.String(description='Material name'),
'store_name': fields.String(description='Store name')
})
# 定义出库模型
checkout_model = api.model('CheckoutInventory', {
'material_id': fields.Integer(required=True, description='Material ID'),
'store_id': fields.Integer(required=True, description='Store ID'),
'quantity': fields.Float(required=True, description='Quantity to checkout')
})
# 出库明细响应模型
checkout_detail_model = api.model('CheckoutDetail', {
'batch_number': fields.String(description='Batch number'),
'quantity': fields.Float(description='Quantity'),
'unit_price': fields.Float(description='Unit price')
})
# 出库响应模型
checkout_response = api.model('CheckoutResponse', {
'success': fields.Boolean(description='Success status'),
'updated_inventories': fields.List(fields.Nested(inventory_response), description='Updated inventory records'),
'checkout_details': fields.List(fields.Nested(checkout_detail_model), description='Checkout details')
})
# 添加物料加工模型
process_model = api.model('ProcessMaterial', {
'inventory_id': fields.Integer(required=True, description='Inventory record ID'),
'material_id': fields.Integer(required=True, description='Material ID'),
'store_id': fields.Integer(required=True, description='Store ID'),
'quantity': fields.Float(required=True, description='Quantity to process'),
'batch_number': fields.String(required=False, description='Batch number')
})
@ns_inventory.route('/')
class InventoryResource(Resource):
@api.doc('list_inventory')
@jwt_required()
def get(self):
"""获取库存列表"""
try:
user_id = get_jwt_identity()
user = User.query.get(user_id)
# 构建基础查询
query = (Inventory.query
.join(Material)
.join(Store))
# 如果是门店经理,只能看到自己门店的库存
if user.role == 'store_manager':
query = query.filter(Inventory.store_id == user.store_id)
inventory_list = query.all()
result = [inv.to_dict() for inv in inventory_list]
return {'data': result}
except Exception as e:
current_app.logger.error(f'Error fetching inventory: {str(e)}')
return {'message': 'Error fetching inventory'}, 500
@api.doc('create_inventory')
@api.expect(inventory_model)
@jwt_required()
def post(self):
"""创建库存记录"""
try:
data = request.get_json()
current_app.logger.info(f'开始创建库存记录,数据: {data}')
# 处理日期字段
expiry_date = datetime.strptime(data['expiry_date'], '%Y-%m-%d').date() if data.get('expiry_date') else None
production_date = datetime.strptime(data['production_date'], '%Y-%m-%d').date() if data.get('production_date') else None
current_app.logger.info(f'日期处理完成 - 生产日期: {production_date}, 到期日期: {expiry_date}')
# 创建原始库存记录
inventory = Inventory(
material_id=data['material_id'],
store_id=data['store_id'],
batch_number=data.get('batch_number'),
quantity=data['quantity'],
unit_price=data.get('unit_price'),
expiry_date=expiry_date,
production_date=production_date,
warning_threshold=data.get('warning_threshold')
)
db.session.add(inventory)
current_app.logger.info(f'原始库存记录已添加到会话,ID将在提交后生成')
# 检查是否需要自动入库加工
auto_process = data.get('auto_process', False)
processed_inventory = None
current_app.logger.info(f'自动加工标志: {auto_process}')
if auto_process:
# 获取物料信息
material = Material.query.get(data['material_id'])
current_app.logger.info(f'物料查询结果: {material.name if material else "未找到"}, 加工信息: {material.processed_name if material else "无"}, 转换比例: {material.conversion_ratio if material else "无"}')
if material and material.processed_name and material.conversion_ratio:
current_app.logger.info(f'开始执行自动加工逻辑')
# 执行自动加工逻辑
processed_inventory = self._auto_process_material(
inventory, material, get_jwt_identity()
)
current_app.logger.info(f'自动加工完成,生成加工后库存记录')
else:
current_app.logger.warning(f'物料不支持自动加工,跳过加工步骤')
current_app.logger.info(f'准备提交数据库事务')
db.session.commit()
current_app.logger.info(f'数据库事务提交成功,原始库存ID: {inventory.id}')
# 返回结果
result = inventory.to_dict()
if processed_inventory:
result['processed_inventory'] = processed_inventory.to_dict()
result['auto_processed'] = True
current_app.logger.info(f'返回结果包含自动加工信息 - 原始ID: {inventory.id}, 加工后ID: {processed_inventory.id}')
else:
result['auto_processed'] = False
current_app.logger.info(f'返回结果不包含自动加工信息 - 库存ID: {inventory.id}')
current_app.logger.info(f'入库操作完成,返回结果: {result}')
return result
except Exception as e:
current_app.logger.error(f'Error creating inventory: {str(e)}')
db.session.rollback()
return {'message': 'Error creating inventory'}, 500
def _auto_process_material(self, original_inventory, material, user_id):
"""自动加工物料的辅助方法"""
try:
current_app.logger.info(f'开始自动加工 - 原材料: {material.name}, 数量: {original_inventory.quantity}, 转换比例: {material.conversion_ratio}')
# 计算加工后的数量
processed_quantity = float(original_inventory.quantity) * float(material.conversion_ratio)
current_app.logger.info(f'计算加工后数量: {original_inventory.quantity} * {material.conversion_ratio} = {processed_quantity}')
# 构建新批次号 (P前缀表示加工)
now = datetime.now()
processed_batch_number = f"P{now.strftime('%y%m%d%H%M%S')}"
current_app.logger.info(f'生成加工批次号: {processed_batch_number}')
# 创建加工后的库存记录
processed_inventory = Inventory(
material_id=original_inventory.material_id,
store_id=original_inventory.store_id,
batch_number=processed_batch_number,
quantity=processed_quantity,
unit_price=original_inventory.unit_price, # 使用原始单价
expiry_date=original_inventory.expiry_date, # 使用原始到期日期
production_date=original_inventory.production_date, # 使用原始生产日期
warning_threshold=material.processed_warning_threshold
)
db.session.add(processed_inventory)
current_app.logger.info(f'加工后库存记录已添加到会话')
# 注意:这里不创建 InventoryLog,因为 original_inventory.id 还是 None
# 日志记录将在主函数中处理,或者我们需要先 flush 来获取 ID
# 先 flush 以获取原始库存的 ID
db.session.flush()
current_app.logger.info(f'数据库会话已刷新,原始库存ID: {original_inventory.id}')
# 现在可以创建加工操作日志
process_log = InventoryLog(
inventory_id=original_inventory.id,
material_id=original_inventory.material_id,
store_id=original_inventory.store_id,
operation_type='auto_process',
quantity=float(original_inventory.quantity),
batch_number=original_inventory.batch_number,
user_id=user_id
)
db.session.add(process_log)
current_app.logger.info(f'加工操作日志已创建')
# 自动入库加工的业务逻辑:
# 1. 原材料入库(已完成)
# 2. 立即消耗原材料进行加工,生成加工后产品
# 3. 最终结果:原材料数量为0,加工后产品数量为转换后的数量
original_inventory.quantity = 0
current_app.logger.info(f'原材料已被自动加工消耗,数量设为0,生成加工后产品数量: {processed_quantity}')
return processed_inventory
except Exception as e:
current_app.logger.error(f'自动加工过程中发生错误: {str(e)}')
import traceback
current_app.logger.error(f'错误堆栈: {traceback.format_exc()}')
raise e
@ns_inventory.route('/stats/')
class InventoryStats(Resource):
@api.doc('get_inventory_stats')
@jwt_required()
def get(self):
"""获取库存统计数据"""
try:
user_id = get_jwt_identity()
user = User.query.get(user_id)
# 构建基础查询
query = (Inventory.query
.join(Material)
.join(Store))
# 如果是门店经理,只能看到自己门店的数据
if user.role == 'store_manager':
query = query.filter(Inventory.store_id == user.store_id)
# 获取物料总数
total_materials = Material.query.count()
# 获取所有库存记录
inventory_list = query.all()
# 计算总库存量
total_quantity = 0
# 使用字典合并同一门店同一物料的数量(加工前后合并)
material_store_totals = {}
for inv in inventory_list:
total_quantity += float(inv.quantity)
# 判断是否为加工物料
is_processed = inv.batch_number and inv.batch_number.startswith('P')
# 创建门店-物料的组合键(不区分加工状态,实现合并)
key = f"{inv.store_id}-{inv.material_id}"
# 累加同一门店同一物料的总库存(加工前后合并)
if key in material_store_totals:
material_store_totals[key]['total_quantity'] += float(inv.quantity)
# 分别记录加工前后的数量
if is_processed:
material_store_totals[key]['processed_quantity'] += float(inv.quantity)
else:
material_store_totals[key]['raw_quantity'] += float(inv.quantity)
else:
# 获取物料信息
material = inv.material
# 使用原始物料名称作为主要显示名称
material_name = material.name
# 预警阈值使用原始物料的阈值(因为预警基于原始需求)
warning_threshold = float(material.warning_threshold) if material.warning_threshold else 0
material_store_totals[key] = {
'material_name': material_name,
'store_name': inv.store.name,
'total_quantity': float(inv.quantity), # 总库存
'raw_quantity': float(inv.quantity) if not is_processed else 0, # 加工前数量
'processed_quantity': float(inv.quantity) if is_processed else 0, # 加工后数量
'warning_threshold': warning_threshold,
'unit': material.unit, # 使用原始单位
'material_id': inv.material_id,
'store_id': inv.store_id,
'has_processed': material.processed_name is not None, # 是否支持加工
'processed_name': material.processed_name, # 加工后名称
'processed_unit': material.processed_unit # 加工后单位
}
# 计算预警物料
warning_items = []
warning_count = 0
# 预先加载所有物料信息,避免循环中重复查询
material_ids = [item['material_id'] for item in material_store_totals.values()]
materials_dict = {}
try:
all_materials = Material.query.filter(Material.id.in_(material_ids)).all()
for material in all_materials:
materials_dict[material.id] = material
current_app.logger.info(f'Loaded {len(materials_dict)} materials')
except Exception as e:
current_app.logger.error(f'Error loading materials: {str(e)}')
for key, item in material_store_totals.items():
try:
# 基于合并后的总库存判断是否需要预警
warning_threshold = item['warning_threshold']
total_quantity = item['total_quantity']
# 判断总库存是否低于预警阈值
if warning_threshold > 0 and total_quantity <= warning_threshold:
warning_count += 1
# 构建预警项目信息
warning_item = {
'material_name': item['material_name'],
'store_name': item['store_name'],
'quantity': total_quantity, # 显示总库存
'warning_threshold': warning_threshold,
'unit': item['unit'],
'material_id': item['material_id'],
'store_id': item['store_id'],
# 额外信息:分别显示加工前后的数量
'raw_quantity': item['raw_quantity'],
'processed_quantity': item['processed_quantity'],
'has_processed': item['has_processed'],
'processed_name': item['processed_name'],
'processed_unit': item['processed_unit']
}
warning_items.append(warning_item)
current_app.logger.info(f'Warning: {item["material_name"]} at {item["store_name"]} - Total: {total_quantity}, Raw: {item["raw_quantity"]}, Processed: {item["processed_quantity"]}, Threshold: {warning_threshold}')
except Exception as e:
current_app.logger.error(f'Error processing inventory item: {str(e)}')
# 继续处理下一个物料,而不是中断整个流程
continue
# 构建并返回响应数据
response_data = {
'total_materials': total_materials,
'warning_count': warning_count,
'total_quantity': float(total_quantity),
'warning_items': warning_items
}
current_app.logger.info(f'Inventory stats response: {response_data}')
return response_data
except Exception as e:
current_app.logger.error(f'Error getting inventory stats: {str(e)}')
import traceback
current_app.logger.error(traceback.format_exc())
return {'message': 'Error getting inventory statistics', 'error': str(e)}, 500
@ns_inventory.route('/export/')
class InventoryExportResource(Resource):
@api.doc('export_inventory')
@jwt_required()
def get(self):
"""导出库存数据"""
try:
# 获取用户信息和权限检查
user_id = get_jwt_identity()
user = User.query.get(user_id)
# 构建查询
query = (Inventory.query
.join(Material)
.join(Store))
# 如果是门店经理,只能导出自己门店的数据
if user.role == 'store_manager':
query = query.filter(Inventory.store_id == user.store_id)
# 获取数据
records = query.all()
current_app.logger.debug(f"Found {len(records)} records to export")
# 先计算每个门店-物料组合的总库存
material_store_totals = {}
for inv in records:
key = f"{inv.store_id}-{inv.material_id}"
if key in material_store_totals:
material_store_totals[key]['quantity'] += float(inv.quantity)
else:
material_store_totals[key] = {
'quantity': float(inv.quantity),
'warning_threshold': float(inv.warning_threshold) if inv.warning_threshold else 0
}
inventory_data = []
for inv in records:
try:
# 获取当前物料-门店组合的总库存与预警状态
key = f"{inv.store_id}-{inv.material_id}"
total_data = material_store_totals[key]
is_warning = total_data['warning_threshold'] > 0 and total_data['quantity'] <= total_data['warning_threshold']
item = {
'ID': inv.id,
'Material': inv.material.name,
'Store': inv.store.name,
'Quantity': float(inv.quantity),
'Unit': inv.material.unit,
'Warning_Threshold': float(inv.warning_threshold) if inv.warning_threshold else 0,
'Status': '库存不足' if is_warning else '正常',
'Total_Quantity': total_data['quantity'],
'Last_Updated': inv.updated_at.strftime('%Y-%m-%d %H:%M:%S')
}
inventory_data.append(item)
except Exception as e:
current_app.logger.error(f"Error processing record {inv.id}: {str(e)}")
raise
# 导出为Excel
headers = ['ID', 'Material', 'Store', 'Quantity', 'Unit',
'Warning_Threshold', 'Total_Quantity', 'Status', 'Last_Updated']
response = export_to_excel(
data=inventory_data,
headers=headers,
sheet_name='Inventory'
)
# 添加CORS头
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
return response
except Exception as e:
current_app.logger.error(f'Error exporting inventory: {str(e)}')
current_app.logger.exception("Full traceback:")
return {'message': f'Error exporting inventory: {str(e)}'}, 500
# 添加物料调拨接口
transfer_model = api.model('TransferStock', {
'material_id': fields.Integer(required=True, description='要调拨的物料ID'),
'source_store_id': fields.Integer(required=True, description='源门店ID'),
'target_store_id': fields.Integer(required=True, description='目标门店ID'),
'quantity': fields.Float(required=True, description='调拨数量'),
'batch_number': fields.String(description='批次号')
})
@ns_inventory.route('/transfer/')
class InventoryTransferResource(Resource):
@api.doc('transfer_inventory')
@api.expect(transfer_model)
@jwt_required()
def post(self):
"""物料调拨 - 从一个门店调拨物料到另一个门店"""
try:
data = request.get_json()
user_id = get_jwt_identity()
user = User.query.get(user_id)
# 权限检查:只有管理员和总部采购员可以执行调拨操作
if user.role not in ['admin', 'purchaser']:
return {'message': '权限不足,无法执行调拨操作'}, 403
# 获取必要参数
material_id = data.get('material_id')
source_store_id = data.get('source_store_id')
target_store_id = data.get('target_store_id')
quantity = data.get('quantity')
batch_number = data.get('batch_number')
# 参数合法性验证
if not all([material_id, source_store_id, target_store_id, quantity]):
return {'message': '缺少必要参数'}, 400
if float(quantity) <= 0:
return {'message': '调拨数量必须大于0'}, 400
if source_store_id == target_store_id:
return {'message': '源门店和目标门店不能相同'}, 400
# 检查源库存是否充足 - 修改为考虑所有批次的总库存
source_inventories = Inventory.query.filter_by(
material_id=material_id,
store_id=source_store_id
).all()
if not source_inventories:
return {'message': '源门店中没有该物料库存'}, 404
# 计算所有批次的总库存
total_source_quantity = sum(float(inv.quantity) for inv in source_inventories)
if total_source_quantity < float(quantity):
return {'message': f'源门店库存不足,需要{quantity},但只有{total_source_quantity}'}, 400
# 获取物料和价格信息
material = Material.query.get(material_id)
if not material:
return {'message': '物料不存在'}, 404
# 获取目标门店的库存记录,若不存在则创建新记录
is_processed = batch_number and batch_number.startswith('P')
# 如果是加工物料,查找同物料ID、同门店且批次号以P开头的记录
if is_processed:
target_inventory = Inventory.query.filter(
Inventory.material_id == material_id,
Inventory.store_id == target_store_id,
Inventory.batch_number.like('P%')
).first()
else:
# 如果不是加工物料,查找同物料ID、同门店且批次号不以P开头的记录
target_inventory = Inventory.query.filter(
Inventory.material_id == material_id,
Inventory.store_id == target_store_id,
~Inventory.batch_number.like('P%')
).first()
# 获取调拨价格
from app.models.price import MaterialPrice
current_price = MaterialPrice.query.filter_by(
material_id=material_id,
status='active'
).first()
# 使用转移价格,若没有则使用源库存的单价
transfer_price = current_price.transfer_price if current_price else source_inventories[0].unit_price
db.session.begin_nested() # 创建保存点
try:
# 按照FIFO原则排序库存批次(先按生产日期,再按ID)
source_inventories.sort(key=lambda x: (x.production_date or datetime.min.date(), x.id))
remaining_quantity = float(quantity)
processed_inventories = []
# 从最早的批次开始扣减
for inv in source_inventories:
if remaining_quantity <= 0:
break
batch_quantity = float(inv.quantity)
batch_id = inv.id # 保存批次ID,以便后续创建日志
if batch_quantity <= remaining_quantity:
# 当前批次全部扣减
deduct_amount = batch_quantity
inv.quantity = Decimal('0') # 使用Decimal类型的0
# 不删除库存记录,只是将其数量设为0
to_be_deleted = False
else:
# 部分扣减
deduct_amount = remaining_quantity
# 使用Decimal类型进行减法运算,确保类型一致
inv.quantity = inv.quantity - Decimal(str(deduct_amount))
to_be_deleted = False
remaining_quantity -= deduct_amount
processed_inventories.append({
'id': batch_id,
'batch_number': inv.batch_number or '未指定批次',
'deducted': deduct_amount,
'delete_after_log': to_be_deleted, # 保留这个字段作为记录,但实际上已经不会删除
'inventory_id': inv.id # 替换完整对象引用为ID
})
# 更新或创建目标库存
if target_inventory:
# 将float转换为Decimal,避免类型不兼容
target_inventory.quantity = target_inventory.quantity + Decimal(str(quantity))
# 如果目标库存没有单价,则使用调拨价格
if not target_inventory.unit_price:
target_inventory.unit_price = transfer_price
else:
# 创建新的目标库存记录
new_batch_number = batch_number
if not new_batch_number: # 如果没有提供批次号
if is_processed:
# 为加工物料生成新的P前缀批次号
now = datetime.now()
new_batch_number = f"P{now.strftime('%y%m%d%H%M%S')}"
else:
# 非加工物料使用默认批次号
new_batch_number = f"调拨自{source_store_id}的多批次"
target_inventory = Inventory(
material_id=material_id,
store_id=target_store_id,
quantity=Decimal(str(quantity)), # 使用Decimal
batch_number=new_batch_number,
unit_price=transfer_price,
warning_threshold=material.processed_warning_threshold if is_processed else material.warning_threshold
)
db.session.add(target_inventory)
db.session.flush() # 添加flush确保新创建的目标库存有ID
# 创建日志记录
for proc in processed_inventories:
log = InventoryLog(
inventory_id=proc['id'],
material_id=material_id,
store_id=source_store_id,
operation_type='transfer_out',
quantity=Decimal(str(proc['deducted'])), # 确保使用Decimal类型
batch_number=proc['batch_number'],
unit_price=transfer_price,
user_id=user_id
)
db.session.add(log)
# 添加调入日志
log = InventoryLog(
inventory_id=target_inventory.id,
material_id=material_id,
store_id=target_store_id,
operation_type='transfer_in',
quantity=Decimal(str(quantity)), # 确保使用Decimal类型
batch_number=target_inventory.batch_number,
unit_price=transfer_price,
user_id=user_id
)
db.session.add(log)
# 直接提交,不需要先flush了
db.session.commit()
# 不再需要处理删除库存记录的代码
# 因为我们保留所有库存记录,只是将数量设为0
# 让我们确保返回的数据是可以序列化的
serializable_processed_batches = []
for proc in processed_inventories:
# 创建一个不包含任何对象引用的字典
serializable_proc = {
'id': proc['id'],
'batch_number': proc['batch_number'],
'deducted': float(proc['deducted']), # 转换为原生Python数字类型
'delete_after_log': proc['delete_after_log']
}
serializable_processed_batches.append(serializable_proc)
return {
'message': '物料调拨成功',
'source': [inv.to_dict() for inv in source_inventories if float(inv.quantity) > 0],
'target': target_inventory.to_dict(),
'processed_batches': serializable_processed_batches
}
except Exception as e:
db.session.rollback()
current_app.logger.error(f'调拨过程中发生错误: {str(e)}')
return {'message': f'调拨失败: {str(e)}'}, 500
except Exception as e:
current_app.logger.error(f'物料调拨失败: {str(e)}')
return {'message': f'物料调拨失败: {str(e)}'}, 500
@ns_inventory.route('/<int:id>/')
class InventoryItemResource(Resource):
@api.doc('update_inventory_item')
@api.expect(inventory_model)
@jwt_required()
def put(self, id):
"""更新特定库存记录"""
try:
# 获取用户信息
user_id = get_jwt_identity()
user = User.query.get(user_id)
# 查找要更新的库存记录
inventory = Inventory.query.get_or_404(id)
# 如果是门店经理,只能更新自己门店的库存
if user.role == 'store_manager' and inventory.store_id != user.store_id:
return {'message': '您没有权限更新其他门店的库存'}, 403
# 获取请求数据
data = request.get_json()
# 处理日期字段
expiry_date = None
if data.get('expiry_date'):
try:
expiry_date = datetime.strptime(data['expiry_date'], '%Y-%m-%d').date()
except ValueError:
pass
production_date = None
if data.get('production_date'):
try:
production_date = datetime.strptime(data['production_date'], '%Y-%m-%d').date()
except ValueError:
pass
# 更新库存记录的各个字段
if 'quantity' in data:
inventory.quantity = data['quantity']
if 'batch_number' in data:
inventory.batch_number = data['batch_number']
if 'unit_price' in data:
inventory.unit_price = data['unit_price']
if expiry_date:
inventory.expiry_date = expiry_date
if production_date:
inventory.production_date = production_date
if 'warning_threshold' in data:
inventory.warning_threshold = data['warning_threshold']
# 保存更改
db.session.commit()
# 返回更新后的记录
return inventory.to_dict()
except Exception as e:
current_app.logger.error(f'Error updating inventory: {str(e)}')
db.session.rollback()
return {'message': f'Error updating inventory: {str(e)}'}, 500
@ns_inventory.route('/checkout/')
class InventoryCheckoutResource(Resource):
@api.doc('checkout_inventory')
@api.expect(checkout_model)
@api.response(200, 'Success', checkout_response)
@api.response(400, 'Validation Error')
@api.response(401, 'Unauthorized')
@api.response(500, 'Internal Server Error')
@jwt_required()
def post(self):
"""物料出库 - 基于FIFO原则自动从最早入库的批次开始出库"""
try:
# 获取用户身份和权限检查
user_id = get_jwt_identity()
user = User.query.get(user_id)
# 门店经理只能操作自己门店的库存
if user.role == 'store_manager':
store_id = request.json.get('store_id')
if store_id != user.store_id:
return {'message': '您只能操作自己门店的库存'}, 403
# 获取请求数据
data = request.get_json()
material_id = data.get('material_id')
store_id = data.get('store_id')
checkout_quantity = float(data.get('quantity'))
# 验证必填字段
if not all([material_id, store_id, checkout_quantity]):
return {'message': '物料ID、门店ID和出库数量不能为空'}, 400
if checkout_quantity <= 0:
return {'message': '出库数量必须大于0'}, 400
# 查询物料信息
material = Material.query.get(material_id)
if not material:
return {'message': '物料不存在'}, 404
# 查询门店信息
store = Store.query.get(store_id)
if not store:
return {'message': '门店不存在'}, 404
# 查询该物料在该门店的所有批次库存,按生产日期和ID(创建时间)排序
# 对于没有生产日期的记录,将它们放在后面
inventory_batches = Inventory.query.filter_by(
material_id=material_id,
store_id=store_id
).order_by(
# 使用coalesce函数,如果production_date为NULL,则使用一个较大的日期
func.coalesce(Inventory.production_date, '9999-12-31'),
# 对于同一生产日期的,按ID排序(较小的ID表示较早创建的记录)
Inventory.id
).all()
# 检查总库存是否足够
total_quantity = sum(float(batch.quantity) for batch in inventory_batches)
if total_quantity < checkout_quantity:
return {'message': f'库存不足,当前库存: {total_quantity}{material.unit}'}, 400
remaining_checkout = checkout_quantity
updated_inventories = []
checkout_details = []
# 开始事务
with db.session.begin_nested():
# 按FIFO原则从最早的批次开始出库
for batch in inventory_batches:
batch_quantity = float(batch.quantity)
# 如果当前批次足够出库
if batch_quantity >= remaining_checkout:
# 创建出库明细记录
checkout_details.append({
'batch_number': batch.batch_number or '未指定批次',
'quantity': remaining_checkout,
'unit_price': float(batch.unit_price) if batch.unit_price else 0
})
# 记录出库日志
log = InventoryLog(
inventory_id=batch.id,
material_id=material_id,
store_id=store_id,
operation_type='out',
quantity=Decimal(str(remaining_checkout)),
batch_number=batch.batch_number,
unit_price=batch.unit_price,
user_id=user_id
)
db.session.add(log)
# 更新批次库存
batch.quantity = batch.quantity - Decimal(str(remaining_checkout))
updated_inventories.append(batch)
remaining_checkout = 0
break
else:
# 当前批次不足,全部出库,继续下一个批次
checkout_details.append({
'batch_number': batch.batch_number or '未指定批次',
'quantity': batch_quantity,
'unit_price': float(batch.unit_price) if batch.unit_price else 0
})
# 记录出库日志
log = InventoryLog(
inventory_id=batch.id,
material_id=material_id,
store_id=store_id,
operation_type='out',
quantity=batch.quantity, # 全部出库
batch_number=batch.batch_number,
unit_price=batch.unit_price,
user_id=user_id
)
db.session.add(log)
# 更新批次库存为0
batch.quantity = Decimal('0')
updated_inventories.append(batch)
remaining_checkout -= batch_quantity
# 提交事务
db.session.commit()
# 清除缓存
clear_cache('inventory')
# 返回成功响应
return {
'success': True,
'updated_inventories': [inv.to_dict() for inv in updated_inventories],
'checkout_details': checkout_details
}, 200
except Exception as e:
current_app.logger.error(f'Error during checkout: {str(e)}')
db.session.rollback()
return {'message': f'出库失败: {str(e)}'}, 500
@ns_inventory.route('/process/')
class InventoryProcessResource(Resource):
@api.doc('process_material')
@api.expect(process_model)
@api.response(200, 'Success')
@api.response(400, 'Validation Error')
@api.response(401, 'Unauthorized')
@api.response(500, 'Internal Server Error')
@jwt_required()
def post(self):
"""将原材料加工为成品"""
try:
# 获取当前用户
current_user_id = get_jwt_identity()
# 获取请求数据
data = request.get_json()
# 获取物料信息
material = Material.query.get_or_404(data['material_id'])
# 验证该物料是否有加工信息
if not material.processed_name or not material.conversion_ratio:
return {'message': 'Material does not have processing information'}, 400
# 验证加工数量是否为正数
quantity = float(data['quantity'])
if quantity <= 0:
return {'message': 'Quantity must be positive'}, 400
# 如果提供了库存ID,使用该条库存记录
if 'inventory_id' in data and data['inventory_id']:
inventory = Inventory.query.get_or_404(data['inventory_id'])
# 检查库存是否足够
if float(inventory.quantity) < quantity:
return {'message': 'Insufficient inventory'}, 400
# 扣减原材料库存
inventory.quantity = inventory.quantity - Decimal(str(quantity))
# 使用原始库存的批次号
orig_batch_number = inventory.batch_number
else:
# 查找该门店该物料的所有库存
inventories = Inventory.query.filter_by(
material_id=data['material_id'],
store_id=data['store_id']
).all()
# 检查库存总数是否足够
total_quantity = sum(float(inv.quantity) for inv in inventories)
if total_quantity < quantity:
return {'message': 'Insufficient inventory'}, 400
# 从库存中扣减,优先使用即将过期的库存
inventories.sort(key=lambda x: x.expiry_date if x.expiry_date else date.max)
remaining = Decimal(str(quantity))
# 使用指定批次号或默认使用第一个批次号
orig_batch_number = data.get('batch_number') or inventories[0].batch_number
for inv in inventories:
if remaining <= 0:
break
if float(inv.quantity) <= float(remaining):
# 如果当前库存不足以满足全部需求,全部扣减
remaining -= inv.quantity
inv.quantity = 0
else:
# 否则部分扣减
inv.quantity = inv.quantity - remaining
remaining = 0
# 计算加工后的数量
processed_quantity = quantity * float(material.conversion_ratio)
# 构建新批次号 (P前缀表示加工)
now = datetime.now()
processed_batch_number = f"P{now.strftime('%y%m%d%H%M%S')}"
# 查找加工后物料是否存在于同一门店,同一批次
processed_inventory = Inventory.query.filter_by(
material_id=data['material_id'],
store_id=data['store_id'],
batch_number=processed_batch_number
).first()
if processed_inventory:
# 如果存在,增加库存
processed_inventory.quantity = processed_inventory.quantity + Decimal(str(processed_quantity))
else:
# 如果不存在,创建新库存记录
processed_inventory = Inventory(
material_id=data['material_id'],
store_id=data['store_id'],
batch_number=processed_batch_number,
quantity=processed_quantity,
warning_threshold=material.processed_warning_threshold
)
db.session.add(processed_inventory)
# 创建加工操作日志
process_log = InventoryLog(
inventory_id=data.get('inventory_id'),
material_id=data['material_id'],
store_id=data['store_id'],
operation_type='process',
quantity=quantity,
batch_number=orig_batch_number,
user_id=current_user_id
)
db.session.add(process_log)
# 提交事务
db.session.commit()
# 返回结果
return {
'success': True,
'original_inventory': {
'material_id': data['material_id'],
'material_name': material.name,
'quantity': float(quantity),
'unit': material.unit
},
'processed_inventory': {
'material_id': data['material_id'],
'material_name': material.processed_name,
'quantity': processed_quantity,
'unit': material.processed_unit,
'batch_number': processed_batch_number
}
}
except Exception as e:
current_app.logger.error(f'Error processing material: {str(e)}')
db.session.rollback()
return {'message': f'Error processing material: {str(e)}'}, 500