| """
|
| محلل الأسعار لنظام إدارة المناقصات
|
| """
|
|
|
| import os
|
| import pandas as pd
|
| import numpy as np
|
| import matplotlib.pyplot as plt
|
| import seaborn as sns
|
| from datetime import datetime, timedelta
|
| from scipy import stats
|
| import logging
|
|
|
| logger = logging.getLogger('tender_system.pricing.analyzer')
|
|
|
| class PriceAnalyzer:
|
| """فئة تحليل الأسعار"""
|
|
|
| def __init__(self, db_connector):
|
| """تهيئة محلل الأسعار"""
|
| self.db = db_connector
|
| self.charts_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data", "charts")
|
|
|
|
|
| os.makedirs(self.charts_dir, exist_ok=True)
|
|
|
| def get_price_history(self, item_id, start_date=None, end_date=None):
|
| """الحصول على تاريخ الأسعار لبند معين
|
|
|
| المعلمات:
|
| item_id (int): معرف البند
|
| start_date (str, optional): تاريخ البداية بتنسيق 'YYYY-MM-DD'
|
| end_date (str, optional): تاريخ النهاية بتنسيق 'YYYY-MM-DD'
|
|
|
| العائد:
|
| pandas.DataFrame: إطار بيانات يحتوي على تاريخ الأسعار
|
| """
|
| try:
|
| query = """
|
| SELECT
|
| pih.id,
|
| pih.price,
|
| pih.price_date,
|
| pih.price_source,
|
| pih.notes,
|
| pib.code,
|
| pib.name,
|
| mu.name as unit_name,
|
| mu.symbol as unit_symbol
|
| FROM
|
| pricing_items_history pih
|
| JOIN
|
| pricing_items_base pib ON pih.base_item_id = pib.id
|
| LEFT JOIN
|
| measurement_units mu ON pib.unit_id = mu.id
|
| WHERE
|
| pih.base_item_id = ?
|
| """
|
|
|
| params = [item_id]
|
|
|
| if start_date:
|
| query += " AND pih.price_date >= ?"
|
| params.append(start_date)
|
|
|
| if end_date:
|
| query += " AND pih.price_date <= ?"
|
| params.append(end_date)
|
|
|
| query += " ORDER BY pih.price_date ASC"
|
|
|
| results = self.db.fetch_all(query, params)
|
|
|
| if not results:
|
| logger.warning(f"لا توجد بيانات تاريخية للسعر للبند رقم {item_id}")
|
| return pd.DataFrame()
|
|
|
|
|
| df = pd.DataFrame(results, columns=[
|
| 'id', 'price', 'price_date', 'price_source', 'notes',
|
| 'code', 'name', 'unit_name', 'unit_symbol'
|
| ])
|
|
|
|
|
| df['price_date'] = pd.to_datetime(df['price_date'])
|
|
|
| return df
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في الحصول على تاريخ الأسعار: {str(e)}")
|
| return pd.DataFrame()
|
|
|
| def analyze_price_trends(self, item_id, start_date=None, end_date=None):
|
| """تحليل اتجاهات الأسعار
|
|
|
| المعلمات:
|
| item_id (int): معرف البند
|
| start_date (str, optional): تاريخ البداية بتنسيق 'YYYY-MM-DD'
|
| end_date (str, optional): تاريخ النهاية بتنسيق 'YYYY-MM-DD'
|
|
|
| العائد:
|
| dict: قاموس يحتوي على نتائج تحليل اتجاهات الأسعار
|
| """
|
| try:
|
|
|
| df = self.get_price_history(item_id, start_date, end_date)
|
|
|
| if df.empty:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لا توجد بيانات كافية لتحليل اتجاهات الأسعار'
|
| }
|
|
|
|
|
| stats_data = {
|
| 'min_price': df['price'].min(),
|
| 'max_price': df['price'].max(),
|
| 'avg_price': df['price'].mean(),
|
| 'median_price': df['price'].median(),
|
| 'std_dev': df['price'].std(),
|
| 'price_range': df['price'].max() - df['price'].min(),
|
| 'count': len(df),
|
| 'start_date': df['price_date'].min().strftime('%Y-%m-%d'),
|
| 'end_date': df['price_date'].max().strftime('%Y-%m-%d'),
|
| 'duration_days': (df['price_date'].max() - df['price_date'].min()).days,
|
| 'item_name': df['name'].iloc[0],
|
| 'item_code': df['code'].iloc[0],
|
| 'unit': df['unit_symbol'].iloc[0] if not pd.isna(df['unit_symbol'].iloc[0]) else ''
|
| }
|
|
|
|
|
| if len(df) >= 2:
|
| first_price = df['price'].iloc[0]
|
| last_price = df['price'].iloc[-1]
|
|
|
| stats_data['absolute_change'] = last_price - first_price
|
| stats_data['percentage_change'] = ((last_price - first_price) / first_price) * 100
|
|
|
|
|
| years = stats_data['duration_days'] / 365.0
|
| if years > 0:
|
| stats_data['annual_change_rate'] = (((last_price / first_price) ** (1 / years)) - 1) * 100
|
| else:
|
| stats_data['annual_change_rate'] = 0
|
| else:
|
| stats_data['absolute_change'] = 0
|
| stats_data['percentage_change'] = 0
|
| stats_data['annual_change_rate'] = 0
|
|
|
|
|
| if len(df) >= 3:
|
|
|
| df['days'] = (df['price_date'] - df['price_date'].min()).dt.days
|
|
|
|
|
| slope, intercept, r_value, p_value, std_err = stats.linregress(df['days'], df['price'])
|
|
|
| stats_data['trend_slope'] = slope
|
| stats_data['trend_intercept'] = intercept
|
| stats_data['trend_r_squared'] = r_value ** 2
|
| stats_data['trend_p_value'] = p_value
|
| stats_data['trend_std_err'] = std_err
|
|
|
|
|
| if p_value < 0.05:
|
| if slope > 0:
|
| stats_data['trend_direction'] = 'upward'
|
| stats_data['trend_description'] = 'اتجاه تصاعدي'
|
| elif slope < 0:
|
| stats_data['trend_direction'] = 'downward'
|
| stats_data['trend_description'] = 'اتجاه تنازلي'
|
| else:
|
| stats_data['trend_direction'] = 'stable'
|
| stats_data['trend_description'] = 'مستقر'
|
| else:
|
| stats_data['trend_direction'] = 'no_significant_trend'
|
| stats_data['trend_description'] = 'لا يوجد اتجاه واضح'
|
|
|
|
|
| stats_data['volatility'] = (df['price'].std() / df['price'].mean()) * 100
|
|
|
|
|
| if stats_data['volatility'] < 5:
|
| stats_data['volatility_level'] = 'low'
|
| stats_data['volatility_description'] = 'منخفض'
|
| elif stats_data['volatility'] < 15:
|
| stats_data['volatility_level'] = 'medium'
|
| stats_data['volatility_description'] = 'متوسط'
|
| else:
|
| stats_data['volatility_level'] = 'high'
|
| stats_data['volatility_description'] = 'مرتفع'
|
| else:
|
| stats_data['trend_direction'] = 'insufficient_data'
|
| stats_data['trend_description'] = 'بيانات غير كافية'
|
| stats_data['volatility'] = 0
|
| stats_data['volatility_level'] = 'unknown'
|
| stats_data['volatility_description'] = 'غير معروف'
|
|
|
|
|
| chart_path = self._create_trend_chart(df, stats_data, item_id)
|
| stats_data['chart_path'] = chart_path
|
|
|
| return {
|
| 'status': 'success',
|
| 'data': stats_data
|
| }
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في تحليل اتجاهات الأسعار: {str(e)}")
|
| return {
|
| 'status': 'error',
|
| 'message': f'حدث خطأ أثناء تحليل اتجاهات الأسعار: {str(e)}'
|
| }
|
|
|
| def _create_trend_chart(self, df, stats_data, item_id):
|
| """إنشاء رسم بياني للاتجاه
|
|
|
| المعلمات:
|
| df (pandas.DataFrame): إطار البيانات
|
| stats_data (dict): بيانات الإحصاءات
|
| item_id (int): معرف البند
|
|
|
| العائد:
|
| str: مسار ملف الرسم البياني
|
| """
|
| try:
|
|
|
| plt.figure(figsize=(10, 6))
|
|
|
|
|
| plt.scatter(df['price_date'], df['price'], color='blue', alpha=0.6, label='أسعار فعلية')
|
|
|
|
|
| if len(df) >= 3 and 'trend_slope' in stats_data:
|
|
|
| x_trend = pd.date_range(start=df['price_date'].min(), end=df['price_date'].max(), periods=100)
|
| days_trend = [(date - df['price_date'].min()).days for date in x_trend]
|
| y_trend = stats_data['trend_slope'] * np.array(days_trend) + stats_data['trend_intercept']
|
|
|
|
|
| plt.plot(x_trend, y_trend, color='red', linestyle='--', label='خط الاتجاه')
|
|
|
|
|
| plt.axhline(y=stats_data['avg_price'], color='green', linestyle='-', alpha=0.5, label='متوسط السعر')
|
|
|
|
|
| plt.title(f"تحليل اتجاه السعر - {stats_data['item_name']} ({stats_data['item_code']})")
|
| plt.xlabel('التاريخ')
|
| plt.ylabel(f"السعر ({stats_data['unit']})")
|
|
|
|
|
| plt.grid(True, linestyle='--', alpha=0.7)
|
|
|
|
|
| plt.legend()
|
|
|
|
|
| plt.gcf().autofmt_xdate()
|
|
|
|
|
| info_text = (
|
| f"التغير: {stats_data['percentage_change']:.2f}%\n"
|
| f"التقلب: {stats_data['volatility']:.2f}%\n"
|
| )
|
|
|
| if 'trend_r_squared' in stats_data:
|
| info_text += f"R²: {stats_data['trend_r_squared']:.3f}"
|
|
|
| plt.annotate(info_text, xy=(0.02, 0.95), xycoords='axes fraction',
|
| bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="gray", alpha=0.8))
|
|
|
|
|
| chart_filename = f"price_trend_{item_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}.png"
|
| chart_path = os.path.join(self.charts_dir, chart_filename)
|
| plt.savefig(chart_path, dpi=100, bbox_inches='tight')
|
| plt.close()
|
|
|
| return chart_path
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في إنشاء رسم بياني للاتجاه: {str(e)}")
|
| return None
|
|
|
| def compare_prices(self, items, date=None):
|
| """مقارنة الأسعار بين عدة بنود
|
|
|
| المعلمات:
|
| items (list): قائمة بمعرفات البنود
|
| date (str, optional): تاريخ المقارنة بتنسيق 'YYYY-MM-DD'
|
|
|
| العائد:
|
| dict: قاموس يحتوي على نتائج مقارنة الأسعار
|
| """
|
| try:
|
| if not items:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لم يتم تحديد أي بنود للمقارنة'
|
| }
|
|
|
| comparison_data = []
|
|
|
| for item_id in items:
|
|
|
| item_query = """
|
| SELECT
|
| id, code, name, description,
|
| (SELECT name FROM measurement_units WHERE id = unit_id) as unit_name,
|
| (SELECT symbol FROM measurement_units WHERE id = unit_id) as unit_symbol,
|
| base_price, last_updated_date
|
| FROM
|
| pricing_items_base
|
| WHERE
|
| id = ?
|
| """
|
|
|
| item_result = self.db.fetch_one(item_query, [item_id])
|
|
|
| if not item_result:
|
| logger.warning(f"البند رقم {item_id} غير موجود")
|
| continue
|
|
|
| item_data = {
|
| 'id': item_result[0],
|
| 'code': item_result[1],
|
| 'name': item_result[2],
|
| 'description': item_result[3],
|
| 'unit_name': item_result[4],
|
| 'unit_symbol': item_result[5],
|
| 'base_price': item_result[6],
|
| 'last_updated_date': item_result[7]
|
| }
|
|
|
|
|
| if date:
|
| price_query = """
|
| SELECT price, price_date, price_source
|
| FROM pricing_items_history
|
| WHERE base_item_id = ?
|
| AND price_date <= ?
|
| ORDER BY price_date DESC
|
| LIMIT 1
|
| """
|
|
|
| price_result = self.db.fetch_one(price_query, [item_id, date])
|
|
|
| if price_result:
|
| item_data['price'] = price_result[0]
|
| item_data['price_date'] = price_result[1]
|
| item_data['price_source'] = price_result[2]
|
| else:
|
|
|
| item_data['price'] = item_data['base_price']
|
| item_data['price_date'] = item_data['last_updated_date']
|
| item_data['price_source'] = 'base_price'
|
| else:
|
|
|
| price_query = """
|
| SELECT price, price_date, price_source
|
| FROM pricing_items_history
|
| WHERE base_item_id = ?
|
| ORDER BY price_date DESC
|
| LIMIT 1
|
| """
|
|
|
| price_result = self.db.fetch_one(price_query, [item_id])
|
|
|
| if price_result:
|
| item_data['price'] = price_result[0]
|
| item_data['price_date'] = price_result[1]
|
| item_data['price_source'] = price_result[2]
|
| else:
|
|
|
| item_data['price'] = item_data['base_price']
|
| item_data['price_date'] = item_data['last_updated_date']
|
| item_data['price_source'] = 'base_price'
|
|
|
| comparison_data.append(item_data)
|
|
|
| if not comparison_data:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لم يتم العثور على أي بنود للمقارنة'
|
| }
|
|
|
|
|
| chart_path = self._create_comparison_chart(comparison_data, date)
|
|
|
| return {
|
| 'status': 'success',
|
| 'data': {
|
| 'items': comparison_data,
|
| 'comparison_date': date if date else 'latest',
|
| 'chart_path': chart_path
|
| }
|
| }
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في مقارنة الأسعار: {str(e)}")
|
| return {
|
| 'status': 'error',
|
| 'message': f'حدث خطأ أثناء مقارنة الأسعار: {str(e)}'
|
| }
|
|
|
| def _create_comparison_chart(self, comparison_data, date=None):
|
| """إنشاء رسم بياني للمقارنة
|
|
|
| المعلمات:
|
| comparison_data (list): بيانات المقارنة
|
| date (str, optional): تاريخ المقارنة
|
|
|
| العائد:
|
| str: مسار ملف الرسم البياني
|
| """
|
| try:
|
|
|
| plt.figure(figsize=(12, 6))
|
|
|
|
|
| names = [f"{item['code']} - {item['name']}" for item in comparison_data]
|
| prices = [item['price'] for item in comparison_data]
|
|
|
|
|
| bars = plt.bar(names, prices, color='skyblue', edgecolor='navy')
|
|
|
|
|
| for bar in bars:
|
| height = bar.get_height()
|
| plt.text(bar.get_x() + bar.get_width()/2., height + 0.1,
|
| f'{height:.2f}', ha='center', va='bottom')
|
|
|
|
|
| title = "مقارنة الأسعار"
|
| if date:
|
| title += f" (بتاريخ {date})"
|
|
|
| plt.title(title)
|
| plt.xlabel('البنود')
|
| plt.ylabel('السعر')
|
|
|
|
|
| plt.xticks(rotation=45, ha='right')
|
|
|
|
|
| plt.grid(True, linestyle='--', alpha=0.7, axis='y')
|
|
|
|
|
| plt.tight_layout()
|
|
|
|
|
| chart_filename = f"price_comparison_{datetime.now().strftime('%Y%m%d%H%M%S')}.png"
|
| chart_path = os.path.join(self.charts_dir, chart_filename)
|
| plt.savefig(chart_path, dpi=100, bbox_inches='tight')
|
| plt.close()
|
|
|
| return chart_path
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في إنشاء رسم بياني للمقارنة: {str(e)}")
|
| return None
|
|
|
| def calculate_price_volatility(self, item_id, period='1y'):
|
| """حساب تقلب الأسعار
|
|
|
| المعلمات:
|
| item_id (int): معرف البند
|
| period (str): الفترة الزمنية ('1m', '3m', '6m', '1y', '2y', '5y', 'all')
|
|
|
| العائد:
|
| dict: قاموس يحتوي على نتائج حساب تقلب الأسعار
|
| """
|
| try:
|
|
|
| end_date = datetime.now().strftime('%Y-%m-%d')
|
|
|
| if period == '1m':
|
| start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
|
| elif period == '3m':
|
| start_date = (datetime.now() - timedelta(days=90)).strftime('%Y-%m-%d')
|
| elif period == '6m':
|
| start_date = (datetime.now() - timedelta(days=180)).strftime('%Y-%m-%d')
|
| elif period == '1y':
|
| start_date = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d')
|
| elif period == '2y':
|
| start_date = (datetime.now() - timedelta(days=730)).strftime('%Y-%m-%d')
|
| elif period == '5y':
|
| start_date = (datetime.now() - timedelta(days=1825)).strftime('%Y-%m-%d')
|
| else:
|
| start_date = None
|
|
|
|
|
| df = self.get_price_history(item_id, start_date, end_date)
|
|
|
| if df.empty or len(df) < 2:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لا توجد بيانات كافية لحساب تقلب الأسعار'
|
| }
|
|
|
|
|
| mean_price = df['price'].mean()
|
| std_dev = df['price'].std()
|
| volatility = (std_dev / mean_price) * 100
|
|
|
|
|
| df['price_shift'] = df['price'].shift(1)
|
| df = df.dropna()
|
|
|
| if not df.empty:
|
| df['price_change_pct'] = ((df['price'] - df['price_shift']) / df['price_shift']) * 100
|
|
|
|
|
| max_increase = df['price_change_pct'].max()
|
| max_decrease = df['price_change_pct'].min()
|
| avg_change = df['price_change_pct'].mean()
|
| median_change = df['price_change_pct'].median()
|
|
|
|
|
| positive_changes = (df['price_change_pct'] > 0).sum()
|
| negative_changes = (df['price_change_pct'] < 0).sum()
|
| no_changes = (df['price_change_pct'] == 0).sum()
|
|
|
|
|
| if volatility < 5:
|
| volatility_level = 'low'
|
| volatility_description = 'منخفض'
|
| elif volatility < 15:
|
| volatility_level = 'medium'
|
| volatility_description = 'متوسط'
|
| else:
|
| volatility_level = 'high'
|
| volatility_description = 'مرتفع'
|
|
|
|
|
| chart_path = self._create_volatility_chart(df, item_id, period)
|
|
|
| return {
|
| 'status': 'success',
|
| 'data': {
|
| 'item_id': item_id,
|
| 'item_name': df['name'].iloc[0],
|
| 'item_code': df['code'].iloc[0],
|
| 'period': period,
|
| 'start_date': df['price_date'].min().strftime('%Y-%m-%d'),
|
| 'end_date': df['price_date'].max().strftime('%Y-%m-%d'),
|
| 'data_points': len(df),
|
| 'mean_price': mean_price,
|
| 'std_dev': std_dev,
|
| 'volatility': volatility,
|
| 'volatility_level': volatility_level,
|
| 'volatility_description': volatility_description,
|
| 'max_increase': max_increase,
|
| 'max_decrease': max_decrease,
|
| 'avg_change': avg_change,
|
| 'median_change': median_change,
|
| 'positive_changes': positive_changes,
|
| 'negative_changes': negative_changes,
|
| 'no_changes': no_changes,
|
| 'chart_path': chart_path
|
| }
|
| }
|
| else:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لا توجد بيانات كافية لحساب تقلب الأسعار بعد معالجة البيانات'
|
| }
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في حساب تقلب الأسعار: {str(e)}")
|
| return {
|
| 'status': 'error',
|
| 'message': f'حدث خطأ أثناء حساب تقلب الأسعار: {str(e)}'
|
| }
|
|
|
| def _create_volatility_chart(self, df, item_id, period):
|
| """إنشاء رسم بياني للتقلب
|
|
|
| المعلمات:
|
| df (pandas.DataFrame): إطار البيانات
|
| item_id (int): معرف البند
|
| period (str): الفترة الزمنية
|
|
|
| العائد:
|
| str: مسار ملف الرسم البياني
|
| """
|
| try:
|
|
|
| fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 10), gridspec_kw={'height_ratios': [2, 1]})
|
|
|
|
|
| ax1.plot(df['price_date'], df['price'], 'b-', linewidth=2)
|
| ax1.set_title(f"سعر البند عبر الزمن - {df['name'].iloc[0]} ({df['code'].iloc[0]})")
|
| ax1.set_xlabel('التاريخ')
|
| ax1.set_ylabel('السعر')
|
| ax1.grid(True, linestyle='--', alpha=0.7)
|
|
|
|
|
| mean_price = df['price'].mean()
|
| std_dev = df['price'].std()
|
|
|
| ax1.axhline(y=mean_price, color='g', linestyle='-', alpha=0.8, label='متوسط السعر')
|
| ax1.axhline(y=mean_price + std_dev, color='r', linestyle='--', alpha=0.5, label='انحراف معياري +1')
|
| ax1.axhline(y=mean_price - std_dev, color='r', linestyle='--', alpha=0.5, label='انحراف معياري -1')
|
|
|
| ax1.fill_between(df['price_date'], mean_price - std_dev, mean_price + std_dev, color='gray', alpha=0.2)
|
| ax1.legend()
|
|
|
|
|
| ax2.bar(df['price_date'], df['price_change_pct'], color='skyblue', edgecolor='navy', alpha=0.7)
|
| ax2.set_title('التغيرات النسبية في السعر (%)')
|
| ax2.set_xlabel('التاريخ')
|
| ax2.set_ylabel('التغير النسبي (%)')
|
| ax2.grid(True, linestyle='--', alpha=0.7)
|
|
|
|
|
| ax2.axhline(y=0, color='k', linestyle='-', alpha=0.3)
|
|
|
|
|
| fig.autofmt_xdate()
|
|
|
|
|
| plt.tight_layout()
|
|
|
|
|
| chart_filename = f"price_volatility_{item_id}_{period}_{datetime.now().strftime('%Y%m%d%H%M%S')}.png"
|
| chart_path = os.path.join(self.charts_dir, chart_filename)
|
| plt.savefig(chart_path, dpi=100, bbox_inches='tight')
|
| plt.close()
|
|
|
| return chart_path
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في إنشاء رسم بياني للتقلب: {str(e)}")
|
| return None
|
|
|
| def perform_sensitivity_analysis(self, project_id, variable_items, ranges):
|
| """إجراء تحليل الحساسية
|
|
|
| المعلمات:
|
| project_id (int): معرف المشروع
|
| variable_items (list): قائمة بمعرفات البنود المتغيرة
|
| ranges (dict): نطاقات التغيير لكل بند
|
|
|
| العائد:
|
| dict: قاموس يحتوي على نتائج تحليل الحساسية
|
| """
|
| try:
|
|
|
| query = """
|
| SELECT
|
| id, item_number, description, quantity, unit_price, total_price
|
| FROM
|
| project_pricing_items
|
| WHERE
|
| project_id = ?
|
| """
|
|
|
| results = self.db.fetch_all(query, [project_id])
|
|
|
| if not results:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لا توجد بنود للمشروع المحدد'
|
| }
|
|
|
|
|
| project_items = pd.DataFrame(results, columns=[
|
| 'id', 'item_number', 'description', 'quantity', 'unit_price', 'total_price'
|
| ])
|
|
|
|
|
| original_total = project_items['total_price'].sum()
|
|
|
|
|
| sensitivity_data = []
|
|
|
| for item_id in variable_items:
|
| if item_id not in project_items['id'].values:
|
| logger.warning(f"البند رقم {item_id} غير موجود في المشروع")
|
| continue
|
|
|
|
|
| item_info = project_items[project_items['id'] == item_id].iloc[0]
|
|
|
|
|
| if str(item_id) in ranges:
|
| item_range = ranges[str(item_id)]
|
| else:
|
|
|
| item_range = {'min': -20, 'max': 20, 'step': 10}
|
|
|
|
|
| change_percentages = list(range(
|
| item_range['min'],
|
| item_range['max'] + item_range['step'],
|
| item_range['step']
|
| ))
|
|
|
| item_sensitivity = {
|
| 'item_id': item_id,
|
| 'item_number': item_info['item_number'],
|
| 'description': item_info['description'],
|
| 'original_price': item_info['unit_price'],
|
| 'original_total': item_info['total_price'],
|
| 'changes': []
|
| }
|
|
|
|
|
| for percentage in change_percentages:
|
|
|
| new_price = item_info['unit_price'] * (1 + percentage / 100)
|
| new_total = new_price * item_info['quantity']
|
|
|
|
|
| project_total = original_total - item_info['total_price'] + new_total
|
|
|
|
|
| project_change = ((project_total - original_total) / original_total) * 100
|
|
|
| item_sensitivity['changes'].append({
|
| 'percentage': percentage,
|
| 'new_price': new_price,
|
| 'new_total': new_total,
|
| 'project_total': project_total,
|
| 'project_change': project_change
|
| })
|
|
|
| sensitivity_data.append(item_sensitivity)
|
|
|
| if not sensitivity_data:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لا توجد بنود صالحة لتحليل الحساسية'
|
| }
|
|
|
|
|
| chart_path = self._create_sensitivity_chart(sensitivity_data, original_total, project_id)
|
|
|
| return {
|
| 'status': 'success',
|
| 'data': {
|
| 'project_id': project_id,
|
| 'original_total': original_total,
|
| 'sensitivity_data': sensitivity_data,
|
| 'chart_path': chart_path
|
| }
|
| }
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في إجراء تحليل الحساسية: {str(e)}")
|
| return {
|
| 'status': 'error',
|
| 'message': f'حدث خطأ أثناء إجراء تحليل الحساسية: {str(e)}'
|
| }
|
|
|
| def _create_sensitivity_chart(self, sensitivity_data, original_total, project_id):
|
| """إنشاء رسم بياني لتحليل الحساسية
|
|
|
| المعلمات:
|
| sensitivity_data (list): بيانات تحليل الحساسية
|
| original_total (float): إجمالي المشروع الأصلي
|
| project_id (int): معرف المشروع
|
|
|
| العائد:
|
| str: مسار ملف الرسم البياني
|
| """
|
| try:
|
|
|
| plt.figure(figsize=(12, 8))
|
|
|
|
|
| for item in sensitivity_data:
|
| percentages = [change['percentage'] for change in item['changes']]
|
| project_changes = [change['project_change'] for change in item['changes']]
|
|
|
| plt.plot(percentages, project_changes, marker='o', linewidth=2,
|
| label=f"{item['item_number']} - {item['description'][:30]}...")
|
|
|
|
|
| plt.title(f"تحليل الحساسية للمشروع رقم {project_id}")
|
| plt.xlabel('نسبة التغيير في سعر البند (%)')
|
| plt.ylabel('نسبة التغيير في إجمالي المشروع (%)')
|
|
|
|
|
| plt.axhline(y=0, color='k', linestyle='-', alpha=0.3)
|
| plt.axvline(x=0, color='k', linestyle='-', alpha=0.3)
|
|
|
|
|
| plt.grid(True, linestyle='--', alpha=0.7)
|
|
|
|
|
| plt.legend(loc='best')
|
|
|
|
|
| info_text = f"إجمالي المشروع الأصلي: {original_total:,.2f}"
|
| plt.annotate(info_text, xy=(0.02, 0.02), xycoords='axes fraction',
|
| bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="gray", alpha=0.8))
|
|
|
|
|
| plt.tight_layout()
|
|
|
|
|
| chart_filename = f"sensitivity_analysis_{project_id}_{datetime.now().strftime('%Y%m%d%H%M%S')}.png"
|
| chart_path = os.path.join(self.charts_dir, chart_filename)
|
| plt.savefig(chart_path, dpi=100, bbox_inches='tight')
|
| plt.close()
|
|
|
| return chart_path
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في إنشاء رسم بياني لتحليل الحساسية: {str(e)}")
|
| return None
|
|
|
| def analyze_price_correlations(self, items):
|
| """تحليل ارتباطات الأسعار بين عدة بنود
|
|
|
| المعلمات:
|
| items (list): قائمة بمعرفات البنود
|
|
|
| العائد:
|
| dict: قاموس يحتوي على نتائج تحليل الارتباطات
|
| """
|
| try:
|
| if not items or len(items) < 2:
|
| return {
|
| 'status': 'error',
|
| 'message': 'يجب تحديد بندين على الأقل لتحليل الارتباطات'
|
| }
|
|
|
|
|
| all_prices = {}
|
| item_names = {}
|
|
|
| for item_id in items:
|
|
|
| df = self.get_price_history(item_id)
|
|
|
| if df.empty:
|
| logger.warning(f"لا توجد بيانات تاريخية للسعر للبند رقم {item_id}")
|
| continue
|
|
|
|
|
| all_prices[item_id] = df[['price_date', 'price']].copy()
|
| item_names[item_id] = f"{df['code'].iloc[0]} - {df['name'].iloc[0]}"
|
|
|
| if len(all_prices) < 2:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لا توجد بيانات كافية لتحليل الارتباطات'
|
| }
|
|
|
|
|
|
|
| all_dates = set()
|
| for item_id, df in all_prices.items():
|
| all_dates.update(df['price_date'].dt.strftime('%Y-%m-%d').tolist())
|
|
|
|
|
| unified_df = pd.DataFrame({'price_date': sorted(list(all_dates))})
|
| unified_df['price_date'] = pd.to_datetime(unified_df['price_date'])
|
|
|
|
|
| for item_id, df in all_prices.items():
|
|
|
| price_series = df.set_index('price_date')['price']
|
|
|
|
|
| unified_df[f'price_{item_id}'] = unified_df['price_date'].map(
|
| lambda x: price_series.get(x, None)
|
| )
|
|
|
|
|
| price_columns = [col for col in unified_df.columns if col.startswith('price_')]
|
| unified_df[price_columns] = unified_df[price_columns].interpolate(method='linear')
|
|
|
|
|
| unified_df = unified_df.dropna()
|
|
|
| if len(unified_df) < 3:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لا توجد بيانات كافية بعد معالجة التواريخ المشتركة'
|
| }
|
|
|
|
|
| correlation_matrix = unified_df[price_columns].corr()
|
|
|
|
|
| correlation_data = []
|
|
|
| for i, item1_id in enumerate(items):
|
| if f'price_{item1_id}' not in correlation_matrix.columns:
|
| continue
|
|
|
| for j, item2_id in enumerate(items):
|
| if f'price_{item2_id}' not in correlation_matrix.columns or i >= j:
|
| continue
|
|
|
| correlation = correlation_matrix.loc[f'price_{item1_id}', f'price_{item2_id}']
|
|
|
|
|
| if abs(correlation) < 0.3:
|
| strength = 'weak'
|
| strength_description = 'ضعيف'
|
| elif abs(correlation) < 0.7:
|
| strength = 'moderate'
|
| strength_description = 'متوسط'
|
| else:
|
| strength = 'strong'
|
| strength_description = 'قوي'
|
|
|
| if correlation > 0:
|
| direction = 'positive'
|
| direction_description = 'طردي'
|
| else:
|
| direction = 'negative'
|
| direction_description = 'عكسي'
|
|
|
| correlation_data.append({
|
| 'item1_id': item1_id,
|
| 'item1_name': item_names.get(item1_id, f'البند {item1_id}'),
|
| 'item2_id': item2_id,
|
| 'item2_name': item_names.get(item2_id, f'البند {item2_id}'),
|
| 'correlation': correlation,
|
| 'strength': strength,
|
| 'strength_description': strength_description,
|
| 'direction': direction,
|
| 'direction_description': direction_description
|
| })
|
|
|
| if not correlation_data:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لم يتم العثور على ارتباطات بين البنود المحددة'
|
| }
|
|
|
|
|
| chart_path = self._create_correlation_chart(correlation_matrix, item_names)
|
|
|
|
|
| trends_chart_path = self._create_price_trends_chart(unified_df, price_columns, item_names)
|
|
|
| return {
|
| 'status': 'success',
|
| 'data': {
|
| 'correlation_data': correlation_data,
|
| 'chart_path': chart_path,
|
| 'trends_chart_path': trends_chart_path
|
| }
|
| }
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في تحليل ارتباطات الأسعار: {str(e)}")
|
| return {
|
| 'status': 'error',
|
| 'message': f'حدث خطأ أثناء تحليل ارتباطات الأسعار: {str(e)}'
|
| }
|
|
|
| def _create_correlation_chart(self, correlation_matrix, item_names):
|
| """إنشاء رسم بياني لمصفوفة الارتباط
|
|
|
| المعلمات:
|
| correlation_matrix (pandas.DataFrame): مصفوفة الارتباط
|
| item_names (dict): قاموس بأسماء البنود
|
|
|
| العائد:
|
| str: مسار ملف الرسم البياني
|
| """
|
| try:
|
|
|
| plt.figure(figsize=(10, 8))
|
|
|
|
|
| mask = np.triu(np.ones_like(correlation_matrix, dtype=bool))
|
| cmap = sns.diverging_palette(230, 20, as_cmap=True)
|
|
|
|
|
| labels = [item_names.get(int(col.split('_')[1]), col) for col in correlation_matrix.columns]
|
|
|
|
|
| sns.heatmap(correlation_matrix, mask=mask, cmap=cmap, vmax=1, vmin=-1, center=0,
|
| square=True, linewidths=.5, cbar_kws={"shrink": .5}, annot=True,
|
| xticklabels=labels, yticklabels=labels)
|
|
|
|
|
| plt.title('مصفوفة ارتباط الأسعار بين البنود')
|
|
|
|
|
| plt.tight_layout()
|
|
|
|
|
| chart_filename = f"price_correlation_{datetime.now().strftime('%Y%m%d%H%M%S')}.png"
|
| chart_path = os.path.join(self.charts_dir, chart_filename)
|
| plt.savefig(chart_path, dpi=100, bbox_inches='tight')
|
| plt.close()
|
|
|
| return chart_path
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في إنشاء رسم بياني لمصفوفة الارتباط: {str(e)}")
|
| return None
|
|
|
| def _create_price_trends_chart(self, unified_df, price_columns, item_names):
|
| """إنشاء رسم بياني لتطور الأسعار
|
|
|
| المعلمات:
|
| unified_df (pandas.DataFrame): إطار البيانات الموحد
|
| price_columns (list): أسماء أعمدة الأسعار
|
| item_names (dict): قاموس بأسماء البنود
|
|
|
| العائد:
|
| str: مسار ملف الرسم البياني
|
| """
|
| try:
|
|
|
| plt.figure(figsize=(12, 6))
|
|
|
|
|
| for col in price_columns:
|
| item_id = int(col.split('_')[1])
|
| item_name = item_names.get(item_id, f'البند {item_id}')
|
|
|
|
|
| first_price = unified_df[col].iloc[0]
|
| normalized_prices = (unified_df[col] / first_price) * 100
|
|
|
| plt.plot(unified_df['price_date'], normalized_prices, linewidth=2, label=item_name)
|
|
|
|
|
| plt.title('تطور الأسعار النسبية للبنود (القيمة الأولى = 100)')
|
| plt.xlabel('التاريخ')
|
| plt.ylabel('السعر النسبي')
|
|
|
|
|
| plt.grid(True, linestyle='--', alpha=0.7)
|
|
|
|
|
| plt.legend(loc='best')
|
|
|
|
|
| plt.gcf().autofmt_xdate()
|
|
|
|
|
| plt.tight_layout()
|
|
|
|
|
| chart_filename = f"price_trends_{datetime.now().strftime('%Y%m%d%H%M%S')}.png"
|
| chart_path = os.path.join(self.charts_dir, chart_filename)
|
| plt.savefig(chart_path, dpi=100, bbox_inches='tight')
|
| plt.close()
|
|
|
| return chart_path
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في إنشاء رسم بياني لتطور الأسعار: {str(e)}")
|
| return None
|
|
|
| def compare_with_market_prices(self, items):
|
| """مقارنة أسعار البنود مع أسعار السوق
|
|
|
| المعلمات:
|
| items (list): قائمة بمعرفات البنود
|
|
|
| العائد:
|
| dict: قاموس يحتوي على نتائج المقارنة
|
| """
|
| try:
|
| if not items:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لم يتم تحديد أي بنود للمقارنة'
|
| }
|
|
|
| comparison_data = []
|
|
|
| for item_id in items:
|
|
|
| item_query = """
|
| SELECT
|
| id, code, name, description,
|
| (SELECT name FROM measurement_units WHERE id = unit_id) as unit_name,
|
| (SELECT symbol FROM measurement_units WHERE id = unit_id) as unit_symbol,
|
| base_price, last_updated_date
|
| FROM
|
| pricing_items_base
|
| WHERE
|
| id = ?
|
| """
|
|
|
| item_result = self.db.fetch_one(item_query, [item_id])
|
|
|
| if not item_result:
|
| logger.warning(f"البند رقم {item_id} غير موجود")
|
| continue
|
|
|
| item_data = {
|
| 'id': item_result[0],
|
| 'code': item_result[1],
|
| 'name': item_result[2],
|
| 'description': item_result[3],
|
| 'unit_name': item_result[4],
|
| 'unit_symbol': item_result[5],
|
| 'base_price': item_result[6],
|
| 'last_updated_date': item_result[7]
|
| }
|
|
|
|
|
| price_query = """
|
| SELECT price, price_date, price_source
|
| FROM pricing_items_history
|
| WHERE base_item_id = ?
|
| ORDER BY price_date DESC
|
| LIMIT 1
|
| """
|
|
|
| price_result = self.db.fetch_one(price_query, [item_id])
|
|
|
| if price_result:
|
| item_data['current_price'] = price_result[0]
|
| item_data['price_date'] = price_result[1]
|
| item_data['price_source'] = price_result[2]
|
| else:
|
|
|
| item_data['current_price'] = item_data['base_price']
|
| item_data['price_date'] = item_data['last_updated_date']
|
| item_data['price_source'] = 'base_price'
|
|
|
|
|
| market_query = """
|
| SELECT AVG(price) as avg_price
|
| FROM pricing_items_history
|
| WHERE base_item_id = ? AND price_source != 'internal'
|
| AND price_date >= date('now', '-6 months')
|
| """
|
|
|
| market_result = self.db.fetch_one(market_query, [item_id])
|
|
|
| if market_result and market_result[0]:
|
| item_data['market_price'] = market_result[0]
|
|
|
|
|
| item_data['price_difference'] = item_data['current_price'] - item_data['market_price']
|
| item_data['price_difference_percentage'] = (item_data['price_difference'] / item_data['market_price']) * 100
|
|
|
|
|
| if abs(item_data['price_difference_percentage']) < 5:
|
| item_data['price_status'] = 'competitive'
|
| item_data['price_status_description'] = 'تنافسي'
|
| elif item_data['price_difference_percentage'] < 0:
|
| item_data['price_status'] = 'below_market'
|
| item_data['price_status_description'] = 'أقل من السوق'
|
| else:
|
| item_data['price_status'] = 'above_market'
|
| item_data['price_status_description'] = 'أعلى من السوق'
|
| else:
|
|
|
| internal_query = """
|
| SELECT AVG(price) as avg_price
|
| FROM pricing_items_history
|
| WHERE base_item_id = ?
|
| AND price_date >= date('now', '-6 months')
|
| """
|
|
|
| internal_result = self.db.fetch_one(internal_query, [item_id])
|
|
|
| if internal_result and internal_result[0]:
|
| item_data['market_price'] = internal_result[0]
|
| item_data['price_difference'] = item_data['current_price'] - item_data['market_price']
|
| item_data['price_difference_percentage'] = (item_data['price_difference'] / item_data['market_price']) * 100
|
|
|
|
|
| if abs(item_data['price_difference_percentage']) < 5:
|
| item_data['price_status'] = 'competitive'
|
| item_data['price_status_description'] = 'تنافسي'
|
| elif item_data['price_difference_percentage'] < 0:
|
| item_data['price_status'] = 'below_average'
|
| item_data['price_status_description'] = 'أقل من المتوسط'
|
| else:
|
| item_data['price_status'] = 'above_average'
|
| item_data['price_status_description'] = 'أعلى من المتوسط'
|
| else:
|
| item_data['market_price'] = None
|
| item_data['price_difference'] = None
|
| item_data['price_difference_percentage'] = None
|
| item_data['price_status'] = 'unknown'
|
| item_data['price_status_description'] = 'غير معروف'
|
|
|
| comparison_data.append(item_data)
|
|
|
| if not comparison_data:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لم يتم العثور على أي بنود للمقارنة'
|
| }
|
|
|
|
|
| chart_path = self._create_market_comparison_chart(comparison_data)
|
|
|
| return {
|
| 'status': 'success',
|
| 'data': {
|
| 'items': comparison_data,
|
| 'chart_path': chart_path
|
| }
|
| }
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في مقارنة الأسعار مع أسعار السوق: {str(e)}")
|
| return {
|
| 'status': 'error',
|
| 'message': f'حدث خطأ أثناء مقارنة الأسعار مع أسعار السوق: {str(e)}'
|
| }
|
|
|
| def _create_market_comparison_chart(self, comparison_data):
|
| """إنشاء رسم بياني لمقارنة الأسعار مع أسعار السوق
|
|
|
| المعلمات:
|
| comparison_data (list): بيانات المقارنة
|
|
|
| العائد:
|
| str: مسار ملف الرسم البياني
|
| """
|
| try:
|
|
|
| valid_items = [item for item in comparison_data if item.get('market_price') is not None]
|
|
|
| if not valid_items:
|
| return None
|
|
|
|
|
| plt.figure(figsize=(12, 6))
|
|
|
|
|
| names = [f"{item['code']} - {item['name'][:20]}..." for item in valid_items]
|
| current_prices = [item['current_price'] for item in valid_items]
|
| market_prices = [item['market_price'] for item in valid_items]
|
|
|
|
|
| x = np.arange(len(names))
|
| width = 0.35
|
|
|
|
|
| plt.bar(x - width/2, current_prices, width, label='السعر الحالي', color='skyblue')
|
| plt.bar(x + width/2, market_prices, width, label='سعر السوق', color='lightgreen')
|
|
|
|
|
| plt.xlabel('البنود')
|
| plt.ylabel('السعر')
|
| plt.title('مقارنة الأسعار الحالية مع أسعار السوق')
|
| plt.xticks(x, names, rotation=45, ha='right')
|
| plt.legend()
|
|
|
|
|
| plt.grid(True, linestyle='--', alpha=0.7, axis='y')
|
|
|
|
|
| for i, item in enumerate(valid_items):
|
| if 'price_difference_percentage' in item and item['price_difference_percentage'] is not None:
|
| percentage = item['price_difference_percentage']
|
| color = 'green' if percentage < 0 else 'red' if percentage > 0 else 'black'
|
| plt.annotate(f"{percentage:.1f}%",
|
| xy=(x[i], max(current_prices[i], market_prices[i]) * 1.05),
|
| ha='center', va='bottom', color=color,
|
| bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="gray", alpha=0.8))
|
|
|
|
|
| plt.tight_layout()
|
|
|
|
|
| chart_filename = f"market_comparison_{datetime.now().strftime('%Y%m%d%H%M%S')}.png"
|
| chart_path = os.path.join(self.charts_dir, chart_filename)
|
| plt.savefig(chart_path, dpi=100, bbox_inches='tight')
|
| plt.close()
|
|
|
| return chart_path
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في إنشاء رسم بياني لمقارنة الأسعار مع أسعار السوق: {str(e)}")
|
| return None
|
|
|
| def analyze_cost_drivers(self, project_id):
|
| """تحليل محركات التكلفة للمشروع
|
|
|
| المعلمات:
|
| project_id (int): معرف المشروع
|
|
|
| العائد:
|
| dict: قاموس يحتوي على نتائج تحليل محركات التكلفة
|
| """
|
| try:
|
|
|
| query = """
|
| SELECT
|
| id, item_number, description, quantity, unit_price, total_price,
|
| (SELECT name FROM pricing_categories WHERE id =
|
| (SELECT category_id FROM pricing_items_base WHERE id = base_item_id)
|
| ) as category_name
|
| FROM
|
| project_pricing_items
|
| WHERE
|
| project_id = ?
|
| """
|
|
|
| results = self.db.fetch_all(query, [project_id])
|
|
|
| if not results:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لا توجد بنود للمشروع المحدد'
|
| }
|
|
|
|
|
| df = pd.DataFrame(results, columns=[
|
| 'id', 'item_number', 'description', 'quantity', 'unit_price',
|
| 'total_price', 'category_name'
|
| ])
|
|
|
|
|
| df['category_name'] = df['category_name'].fillna('أخرى')
|
|
|
|
|
| project_total = df['total_price'].sum()
|
|
|
|
|
| category_analysis = df.groupby('category_name').agg({
|
| 'total_price': 'sum'
|
| }).reset_index()
|
|
|
|
|
| category_analysis['percentage'] = (category_analysis['total_price'] / project_total) * 100
|
|
|
|
|
| category_analysis = category_analysis.sort_values('total_price', ascending=False)
|
|
|
|
|
| top_items = df.sort_values('total_price', ascending=False).head(10)
|
| top_items['percentage'] = (top_items['total_price'] / project_total) * 100
|
|
|
|
|
| df_sorted = df.sort_values('total_price', ascending=False)
|
| df_sorted['cumulative_cost'] = df_sorted['total_price'].cumsum()
|
| df_sorted['cumulative_percentage'] = (df_sorted['cumulative_cost'] / project_total) * 100
|
|
|
|
|
| items_80_percent = len(df_sorted[df_sorted['cumulative_percentage'] <= 80])
|
| if items_80_percent == 0:
|
| items_80_percent = 1
|
|
|
| pareto_ratio = items_80_percent / len(df)
|
|
|
|
|
| category_chart_path = self._create_category_chart(category_analysis)
|
| top_items_chart_path = self._create_top_items_chart(top_items)
|
| pareto_chart_path = self._create_pareto_chart(df_sorted)
|
|
|
| return {
|
| 'status': 'success',
|
| 'data': {
|
| 'project_id': project_id,
|
| 'project_total': project_total,
|
| 'category_analysis': category_analysis.to_dict('records'),
|
| 'top_items': top_items.to_dict('records'),
|
| 'pareto_ratio': pareto_ratio,
|
| 'items_80_percent': items_80_percent,
|
| 'total_items': len(df),
|
| 'category_chart_path': category_chart_path,
|
| 'top_items_chart_path': top_items_chart_path,
|
| 'pareto_chart_path': pareto_chart_path
|
| }
|
| }
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في تحليل محركات التكلفة: {str(e)}")
|
| return {
|
| 'status': 'error',
|
| 'message': f'حدث خطأ أثناء تحليل محركات التكلفة: {str(e)}'
|
| }
|
|
|
| def _create_category_chart(self, category_analysis):
|
| """إنشاء رسم بياني للتكاليف حسب الفئة
|
|
|
| المعلمات:
|
| category_analysis (pandas.DataFrame): تحليل الفئات
|
|
|
| العائد:
|
| str: مسار ملف الرسم البياني
|
| """
|
| try:
|
|
|
| plt.figure(figsize=(10, 6))
|
|
|
|
|
| plt.pie(
|
| category_analysis['total_price'],
|
| labels=category_analysis['category_name'],
|
| autopct='%1.1f%%',
|
| startangle=90,
|
| shadow=False,
|
| wedgeprops={'edgecolor': 'white', 'linewidth': 1}
|
| )
|
|
|
|
|
| plt.title('توزيع التكاليف حسب الفئة')
|
|
|
|
|
| plt.axis('equal')
|
|
|
|
|
| plt.tight_layout()
|
|
|
|
|
| chart_filename = f"cost_category_{datetime.now().strftime('%Y%m%d%H%M%S')}.png"
|
| chart_path = os.path.join(self.charts_dir, chart_filename)
|
| plt.savefig(chart_path, dpi=100, bbox_inches='tight')
|
| plt.close()
|
|
|
| return chart_path
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في إنشاء رسم بياني للتكاليف حسب الفئة: {str(e)}")
|
| return None
|
|
|
| def _create_top_items_chart(self, top_items):
|
| """إنشاء رسم بياني للبنود الأعلى تكلفة
|
|
|
| المعلمات:
|
| top_items (pandas.DataFrame): البنود الأعلى تكلفة
|
|
|
| العائد:
|
| str: مسار ملف الرسم البياني
|
| """
|
| try:
|
|
|
| plt.figure(figsize=(12, 6))
|
|
|
|
|
| items = [f"{row['item_number']} - {row['description'][:20]}..." for _, row in top_items.iterrows()]
|
| costs = top_items['total_price'].tolist()
|
|
|
|
|
| bars = plt.barh(items, costs, color='skyblue', edgecolor='navy')
|
|
|
|
|
| for i, bar in enumerate(bars):
|
| width = bar.get_width()
|
| plt.text(width * 1.01, bar.get_y() + bar.get_height()/2,
|
| f'{width:,.0f} ({top_items["percentage"].iloc[i]:.1f}%)',
|
| va='center')
|
|
|
|
|
| plt.title('البنود الأعلى تكلفة')
|
| plt.xlabel('التكلفة')
|
| plt.ylabel('البنود')
|
|
|
|
|
| plt.grid(True, linestyle='--', alpha=0.7, axis='x')
|
|
|
|
|
| plt.tight_layout()
|
|
|
|
|
| chart_filename = f"top_cost_items_{datetime.now().strftime('%Y%m%d%H%M%S')}.png"
|
| chart_path = os.path.join(self.charts_dir, chart_filename)
|
| plt.savefig(chart_path, dpi=100, bbox_inches='tight')
|
| plt.close()
|
|
|
| return chart_path
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في إنشاء رسم بياني للبنود الأعلى تكلفة: {str(e)}")
|
| return None
|
|
|
| def _create_pareto_chart(self, df_sorted):
|
| """إنشاء رسم بياني لتحليل باريتو
|
|
|
| المعلمات:
|
| df_sorted (pandas.DataFrame): إطار البيانات المرتب
|
|
|
| العائد:
|
| str: مسار ملف الرسم البياني
|
| """
|
| try:
|
|
|
| fig, ax1 = plt.subplots(figsize=(12, 6))
|
|
|
|
|
| x = range(1, len(df_sorted) + 1)
|
| y1 = df_sorted['total_price'].tolist()
|
| y2 = df_sorted['cumulative_percentage'].tolist()
|
|
|
|
|
| ax1.bar(x, y1, color='skyblue', alpha=0.7)
|
| ax1.set_xlabel('عدد البنود')
|
| ax1.set_ylabel('التكلفة', color='navy')
|
| ax1.tick_params(axis='y', labelcolor='navy')
|
|
|
|
|
| ax2 = ax1.twinx()
|
|
|
|
|
| ax2.plot(x, y2, 'r-', linewidth=2, marker='o', markersize=4)
|
| ax2.set_ylabel('النسبة التراكمية (%)', color='red')
|
| ax2.tick_params(axis='y', labelcolor='red')
|
|
|
|
|
| ax2.axhline(y=80, color='green', linestyle='--', alpha=0.7)
|
|
|
|
|
| plt.title('تحليل باريتو للتكاليف')
|
|
|
|
|
| ax1.grid(True, linestyle='--', alpha=0.7)
|
|
|
|
|
| fig.tight_layout()
|
|
|
|
|
| chart_filename = f"pareto_analysis_{datetime.now().strftime('%Y%m%d%H%M%S')}.png"
|
| chart_path = os.path.join(self.charts_dir, chart_filename)
|
| plt.savefig(chart_path, dpi=100, bbox_inches='tight')
|
| plt.close()
|
|
|
| return chart_path
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في إنشاء رسم بياني لتحليل باريتو: {str(e)}")
|
| return None
|
|
|
| def generate_price_analysis_charts(self, analysis_type, params):
|
| """إنشاء رسوم بيانية لتحليل الأسعار
|
|
|
| المعلمات:
|
| analysis_type (str): نوع التحليل
|
| params (dict): معلمات التحليل
|
|
|
| العائد:
|
| dict: قاموس يحتوي على مسارات الرسوم البيانية
|
| """
|
| try:
|
| if analysis_type == 'trend':
|
|
|
| if 'item_id' not in params:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لم يتم تحديد معرف البند'
|
| }
|
|
|
| result = self.analyze_price_trends(
|
| params['item_id'],
|
| params.get('start_date'),
|
| params.get('end_date')
|
| )
|
|
|
| if result['status'] == 'success':
|
| return {
|
| 'status': 'success',
|
| 'charts': [result['data']['chart_path']]
|
| }
|
| else:
|
| return result
|
|
|
| elif analysis_type == 'comparison':
|
|
|
| if 'items' not in params:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لم يتم تحديد البنود للمقارنة'
|
| }
|
|
|
| result = self.compare_prices(
|
| params['items'],
|
| params.get('date')
|
| )
|
|
|
| if result['status'] == 'success':
|
| return {
|
| 'status': 'success',
|
| 'charts': [result['data']['chart_path']]
|
| }
|
| else:
|
| return result
|
|
|
| elif analysis_type == 'volatility':
|
|
|
| if 'item_id' not in params:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لم يتم تحديد معرف البند'
|
| }
|
|
|
| result = self.calculate_price_volatility(
|
| params['item_id'],
|
| params.get('period', '1y')
|
| )
|
|
|
| if result['status'] == 'success':
|
| return {
|
| 'status': 'success',
|
| 'charts': [result['data']['chart_path']]
|
| }
|
| else:
|
| return result
|
|
|
| elif analysis_type == 'sensitivity':
|
|
|
| if 'project_id' not in params or 'variable_items' not in params:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لم يتم تحديد معرف المشروع أو البنود المتغيرة'
|
| }
|
|
|
| result = self.perform_sensitivity_analysis(
|
| params['project_id'],
|
| params['variable_items'],
|
| params.get('ranges', {})
|
| )
|
|
|
| if result['status'] == 'success':
|
| return {
|
| 'status': 'success',
|
| 'charts': [result['data']['chart_path']]
|
| }
|
| else:
|
| return result
|
|
|
| elif analysis_type == 'correlation':
|
|
|
| if 'items' not in params:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لم يتم تحديد البنود للتحليل'
|
| }
|
|
|
| result = self.analyze_price_correlations(params['items'])
|
|
|
| if result['status'] == 'success':
|
| return {
|
| 'status': 'success',
|
| 'charts': [result['data']['chart_path'], result['data']['trends_chart_path']]
|
| }
|
| else:
|
| return result
|
|
|
| elif analysis_type == 'market_comparison':
|
|
|
| if 'items' not in params:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لم يتم تحديد البنود للمقارنة'
|
| }
|
|
|
| result = self.compare_with_market_prices(params['items'])
|
|
|
| if result['status'] == 'success':
|
| return {
|
| 'status': 'success',
|
| 'charts': [result['data']['chart_path']]
|
| }
|
| else:
|
| return result
|
|
|
| elif analysis_type == 'cost_drivers':
|
|
|
| if 'project_id' not in params:
|
| return {
|
| 'status': 'error',
|
| 'message': 'لم يتم تحديد معرف المشروع'
|
| }
|
|
|
| result = self.analyze_cost_drivers(params['project_id'])
|
|
|
| if result['status'] == 'success':
|
| return {
|
| 'status': 'success',
|
| 'charts': [
|
| result['data']['category_chart_path'],
|
| result['data']['top_items_chart_path'],
|
| result['data']['pareto_chart_path']
|
| ]
|
| }
|
| else:
|
| return result
|
|
|
| else:
|
| return {
|
| 'status': 'error',
|
| 'message': f'نوع التحليل غير معروف: {analysis_type}'
|
| }
|
|
|
| except Exception as e:
|
| logger.error(f"خطأ في إنشاء رسوم بيانية لتحليل الأسعار: {str(e)}")
|
| return {
|
| 'status': 'error',
|
| 'message': f'حدث خطأ أثناء إنشاء رسوم بيانية لتحليل الأسعار: {str(e)}'
|
| }
|
|
|