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('//') 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