Spaces:
Runtime error
Runtime error
| 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') | |
| }) | |
| class InventoryResource(Resource): | |
| 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 | |
| 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 | |
| class InventoryStats(Resource): | |
| 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 | |
| class InventoryExportResource(Resource): | |
| 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='批次号') | |
| }) | |
| class InventoryTransferResource(Resource): | |
| 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 | |
| class InventoryItemResource(Resource): | |
| 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 | |
| class InventoryCheckoutResource(Resource): | |
| 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 | |
| class InventoryProcessResource(Resource): | |
| 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 |